Python:DevUI Fixes (#1035)

* fix event reset on thread change, enable multiline input, enable pasting of files and screenshots

* UI updates and improved remove discovery

* ui and other fixes

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
This commit is contained in:
Victor Dibia
2025-09-30 17:21:22 -07:00
committed by GitHub
Unverified
parent 042099009f
commit fa9f5c1aed
41 changed files with 2949 additions and 814 deletions
+238 -80
View File
@@ -7,12 +7,14 @@ import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { AppHeader } from "@/components/shared/app-header";
import { DebugPanel } from "@/components/shared/debug-panel";
import { AboutModal } from "@/components/shared/about-modal";
import { SettingsModal } from "@/components/shared/settings-modal";
import { GalleryView } from "@/components/gallery";
import { AgentView } from "@/components/agent/agent-view";
import { WorkflowView } from "@/components/workflow/workflow-view";
import { LoadingState } from "@/components/ui/loading-state";
import { apiClient } from "@/services/api";
import { ChevronLeft } from "lucide-react";
import { ChevronLeft, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import type {
AgentInfo,
WorkflowInfo,
@@ -27,23 +29,23 @@ export default function App() {
isLoading: true,
});
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>([]);
const [debugPanelOpen, setDebugPanelOpen] = useState(true);
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
// Initialize from localStorage or default to 320
const savedWidth = localStorage.getItem("debugPanelWidth");
return savedWidth ? parseInt(savedWidth, 10) : 320;
});
const [isResizing, setIsResizing] = useState(false);
const [showAboutModal, setShowAboutModal] = useState(false);
const [showGallery, setShowGallery] = useState(false);
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// Load agents and workflows in parallel
const [agents, workflows] = await Promise.all([
apiClient.getAgents(),
apiClient.getWorkflows(),
@@ -135,6 +137,109 @@ export default function App() {
}
}, []);
// Handle adding sample entity
const handleAddSample = useCallback(async (sample: SampleEntity) => {
setAddingEntityId(sample.id);
setErrorEntityId(null);
setErrorMessage(null);
try {
// Call backend to fetch and add entity
const newEntity = await apiClient.addEntity(sample.url, {
source: 'remote_gallery',
originalUrl: sample.url,
sampleId: sample.id
});
// Convert backend entity to frontend format
const convertedEntity = {
id: newEntity.id,
name: newEntity.name,
description: newEntity.description,
type: newEntity.type,
source: (newEntity.source as "directory" | "in_memory" | "remote_gallery") || 'remote_gallery',
has_env: false,
module_path: undefined
};
// Update app state
if (newEntity.type === 'agent') {
const agentEntity = {
...convertedEntity,
tools: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
)
} as AgentInfo;
setAppState(prev => ({
...prev,
agents: [...prev.agents, agentEntity],
selectedAgent: agentEntity
}));
} else {
const workflowEntity = {
...convertedEntity,
executors: (newEntity.tools || []).map(tool =>
typeof tool === 'string' ? tool : JSON.stringify(tool)
),
input_schema: { type: "string" },
input_type_name: "Input",
start_executor_id: (newEntity.tools && newEntity.tools.length > 0)
? (typeof newEntity.tools[0] === 'string' ? newEntity.tools[0] : JSON.stringify(newEntity.tools[0]))
: "unknown"
} as WorkflowInfo;
setAppState(prev => ({
...prev,
workflows: [...prev.workflows, workflowEntity],
selectedAgent: workflowEntity
}));
}
// Close gallery and clear debug events
setShowGallery(false);
setDebugEvents([]);
} catch (error) {
const errMsg = error instanceof Error ? error.message : 'Failed to add sample entity';
console.error('Failed to add sample entity:', errMsg);
setErrorEntityId(sample.id);
setErrorMessage(errMsg);
} finally {
setAddingEntityId(null);
}
}, []);
const handleClearError = useCallback(() => {
setErrorEntityId(null);
setErrorMessage(null);
}, []);
// Handle removing entity
const handleRemoveEntity = useCallback(async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
// Update app state
setAppState(prev => ({
...prev,
agents: prev.agents.filter(a => a.id !== entityId),
workflows: prev.workflows.filter(w => w.id !== entityId),
selectedAgent: prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent
}));
// Clear debug events if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
setDebugEvents([]);
}
} catch (error) {
console.error('Failed to remove entity:', error);
}
}, [appState.selectedAgent?.id]);
// Show loading state while initializing
if (appState.isLoading) {
return (
@@ -167,54 +272,77 @@ export default function App() {
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
onRemove={handleRemoveEntity}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Error Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-destructive text-lg font-medium">
Failed to load entities
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center space-y-6 max-w-2xl">
{/* Icon */}
<div className="flex justify-center">
<div className="rounded-full bg-muted p-4 animate-pulse">
<ServerOff className="h-12 w-12 text-muted-foreground" />
</div>
</div>
<p className="text-muted-foreground text-sm">{appState.error}</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
Can't Connect to Backend
</h2>
<p className="text-muted-foreground text-base">
No worries! Just start the DevUI backend server and you'll be good to go.
</p>
</div>
{/* Command Instructions */}
<div className="space-y-3">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Start the backend:</p>
<code className="block bg-background px-3 py-2 rounded border text-sm font-mono text-foreground">
devui ./agents --port 8080
</code>
<p className="text-xs text-muted-foreground">
Or launch programmatically with <code className="text-xs">serve(entities=[agent])</code>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default: <span className="font-mono">http://localhost:8080</span>
</p>
</div>
{/* Error Details (Collapsible) */}
{appState.error && (
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Error details
</summary>
<p className="mt-2 text-xs text-muted-foreground font-mono bg-muted/30 p-3 rounded border">
{appState.error}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no agents or workflows are available
if (
!appState.isLoading &&
appState.agents.length === 0 &&
appState.workflows.length === 0
) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
{/* Empty State Content */}
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4 max-w-md">
<div className="text-lg font-medium">No entities configured</div>
<p className="text-muted-foreground text-sm">
No agents or workflows were found in your configuration. Please
check your setup and ensure entities are properly configured.
</p>
<Button onClick={() => window.location.reload()} variant="outline">
Retry
</Button>
</div>
</div>
</div>
);
}
@@ -226,35 +354,63 @@ export default function App() {
workflows={appState.workflows}
selectedItem={appState.selectedAgent}
onSelect={handleEntitySelect}
onRemove={handleRemoveEntity}
onBrowseGallery={() => setShowGallery(true)}
isLoading={appState.isLoading}
onSettingsClick={() => setShowAboutModal(true)}
/>
{/* Main Content - Split Panel */}
{/* Main Content - Split Panel or Gallery */}
<div className="flex flex-1 overflow-hidden">
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
{showGallery ? (
// Show gallery full screen (w-full ensures it takes entire width)
<div className="flex-1 w-full">
<GalleryView
variant="route"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
onClose={() => setShowGallery(false)}
hasExistingEntities={appState.agents.length > 0 || appState.workflows.length > 0}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
// Empty state - show gallery inline (full width, no debug panel)
<GalleryView
variant="inline"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
/>
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select an agent or workflow to get started.
</div>
)}
</div>
)}
</div>
{/* Resize Handle */}
{debugPanelOpen && (
{/* Resize Handle */}
{debugPanelOpen && (
<div
className={`w-1 cursor-col-resize flex-shrink-0 relative group transition-colors duration-200 ease-in-out ${
isResizing ? "bg-primary/40" : "bg-border hover:bg-primary/20"
@@ -288,22 +444,24 @@ export default function App() {
</div>
)}
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
</div>
{/* Right Panel - Debug */}
{debugPanelOpen && (
<div
className="flex-shrink-0"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
/>
</div>
)}
</>
)}
</div>
{/* About Modal */}
<AboutModal
{/* Settings Modal */}
<SettingsModal
open={showAboutModal}
onOpenChange={setShowAboutModal}
/>
@@ -5,7 +5,7 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileUpload } from "@/components/ui/file-upload";
import {
@@ -21,7 +21,22 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send, User, Bot, Plus, AlertCircle } from "lucide-react";
import {
SendHorizontal,
User,
Bot,
Plus,
AlertCircle,
Paperclip,
FileText,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
CheckCircle,
XCircle,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
AgentInfo,
@@ -36,7 +51,7 @@ interface ChatState {
isStreaming: boolean;
}
type DebugEventHandler = (event: ExtendedResponseStreamEvent | 'clear') => void;
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
interface AgentViewProps {
selectedAgent: AgentInfo;
@@ -136,10 +151,15 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [loadingThreads, setLoadingThreads] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [detailsExpanded, setDetailsExpanded] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const accumulatedText = useRef<string>("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
@@ -163,7 +183,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Load messages for the selected thread
try {
const threadMessages = await apiClient.getThreadMessages(mostRecentThread.id);
const threadMessages = await apiClient.getThreadMessages(
mostRecentThread.id
);
setChatState({
messages: threadMessages,
isStreaming: false,
@@ -262,10 +284,137 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
};
// Paste handler
const handlePaste = async (e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const files: File[] = [];
let hasProcessedText = false;
const TEXT_THRESHOLD = 8000; // Convert to file if text is larger than this
for (const item of items) {
// Handle pasted images (screenshots)
if (item.type.startsWith("image/")) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const timestamp = Date.now();
files.push(
new File([blob], `screenshot-${timestamp}.png`, { type: blob.type })
);
}
}
// Handle text - only process first text item (browsers often duplicate)
else if (item.type === "text/plain" && !hasProcessedText) {
hasProcessedText = true;
// We need to check the text synchronously to decide whether to prevent default
// Unfortunately, getAsString is async, so we'll prevent default for all text
// and then decide whether to actually create a file or manually insert the text
e.preventDefault();
await new Promise<void>((resolve) => {
item.getAsString((text) => {
// Check if text should be converted to file
const lineCount = (text.match(/\n/g) || []).length;
const shouldConvert =
text.length > TEXT_THRESHOLD ||
lineCount > 50 || // Many lines suggests logs/data
/^\s*[{[][\s\S]*[}\]]\s*$/.test(text) || // JSON-like
/^<\?xml|^<html|^<!DOCTYPE/i.test(text); // XML/HTML
if (shouldConvert) {
// Create file for large/complex text
const extension = detectFileExtension(text);
const timestamp = Date.now();
const blob = new Blob([text], { type: "text/plain" });
files.push(
new File([blob], `pasted-text-${timestamp}${extension}`, {
type: "text/plain",
})
);
} else {
// For small text, manually insert into textarea since we prevented default
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const currentValue = textarea.value;
const newValue = currentValue.slice(0, start) + text + currentValue.slice(end);
setInputValue(newValue);
// Restore cursor position after the inserted text
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + text.length;
textarea.focus();
}, 0);
}
}
resolve();
});
});
}
}
// Process collected files
if (files.length > 0) {
await handleFilesSelected(files);
// Show notification with appropriate icon
const message =
files.length === 1
? files[0].name.includes("screenshot")
? "Screenshot added as attachment"
: "Large text converted to file"
: `${files.length} files added`;
setPasteNotification(message);
setTimeout(() => setPasteNotification(null), 3000);
}
};
// Detect file extension from content
const detectFileExtension = (text: string): string => {
const trimmed = text.trim();
const lines = trimmed.split('\n');
// JSON detection
if (/^{[\s\S]*}$|^\[[\s\S]*\]$/.test(trimmed)) return ".json";
// XML/HTML detection
if (/^<\?xml|^<html|^<!DOCTYPE/i.test(trimmed)) return ".html";
// Markdown detection (code blocks)
if (/^```/.test(trimmed)) return ".md";
// TSV detection (tabs with multiple lines)
if (/\t/.test(text) && lines.length > 1) return ".tsv";
// CSV detection (more strict) - need multiple lines with consistent comma patterns
if (lines.length > 2) {
const commaLines = lines.filter(line => line.includes(','));
const semicolonLines = lines.filter(line => line.includes(';'));
// If >50% of lines have commas and it looks tabular
if (commaLines.length > lines.length * 0.5) {
const avgCommas = commaLines.reduce((sum, line) => sum + (line.match(/,/g) || []).length, 0) / commaLines.length;
if (avgCommas >= 2) return ".csv";
}
// If >50% of lines have semicolons and it looks tabular
if (semicolonLines.length > lines.length * 0.5) {
const avgSemicolons = semicolonLines.reduce((sum, line) => sum + (line.match(/;/g) || []).length, 0) / semicolonLines.length;
if (avgSemicolons >= 2) return ".csv";
}
}
return ".txt";
};
// Helper functions
const getFileType = (file: File): AttachmentItem["type"] => {
if (file.type.startsWith("image/")) return "image";
if (file.type === "application/pdf") return "pdf";
if (file.type.startsWith("audio/")) return "audio";
return "other";
};
@@ -304,6 +453,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setCurrentThread(thread);
// Clear debug panel when switching threads
onDebugEvent("clear");
try {
// Load thread messages from backend
const threadMessages = await apiClient.getThreadMessages(threadId);
@@ -354,10 +506,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} as import("@/types/agent-framework").DataContent);
} else if (contentItem.type === "input_file") {
const dataUri = `data:application/octet-stream;base64,${contentItem.file_data}`;
// Determine media type from filename
const filename = (contentItem as import("@/types/agent-framework").ResponseInputFileParam).filename || "";
let mediaType = "application/octet-stream";
if (filename.endsWith(".pdf")) mediaType = "application/pdf";
else if (filename.endsWith(".txt")) mediaType = "text/plain";
else if (filename.endsWith(".json")) mediaType = "application/json";
else if (filename.endsWith(".csv")) mediaType = "text/csv";
else if (filename.endsWith(".html")) mediaType = "text/html";
else if (filename.endsWith(".md")) mediaType = "text/markdown";
attachmentContents.push({
type: "data",
uri: dataUri,
media_type: "application/pdf", // Should be dynamic based on filename
media_type: mediaType,
} as import("@/types/agent-framework").DataContent);
}
}
@@ -427,7 +590,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedText.current = "";
// Clear debug panel events for new agent run
onDebugEvent('clear');
onDebugEvent("clear");
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
@@ -576,8 +739,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type: "input_image",
image_url: dataUri,
} as import("@/types/agent-framework").ResponseInputImageParam);
} else if (
attachment.file.type === "text/plain" &&
(attachment.file.name.includes("pasted-text-") ||
attachment.file.name.endsWith(".txt") ||
attachment.file.name.endsWith(".csv") ||
attachment.file.name.endsWith(".json") ||
attachment.file.name.endsWith(".html") ||
attachment.file.name.endsWith(".md") ||
attachment.file.name.endsWith(".tsv"))
) {
// Convert all text files (from pasted large text) back to input_text
const text = await attachment.file.text();
content.push({
text: text,
type: "input_text",
} as import("@/types/agent-framework").ResponseInputTextParam);
} else {
// EXACT OpenAI ResponseInputFileParam (but we need to handle the required fields)
// EXACT OpenAI ResponseInputFileParam for other files
const base64Data = dataUri.split(",")[1]; // Extract base64 part
content.push({
type: "input_file",
@@ -641,22 +820,36 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Chat with {selectedAgent.name || selectedAgent.id}
</div>
</h2>
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<h2 className="font-semibold text-sm truncate">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">Chat with {selectedAgent.name || selectedAgent.id}</span>
</div>
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
</Button>
</div>
{/* Thread Controls */}
<div className="flex items-center gap-2">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
<Select
value={currentThread?.id || ""}
onValueChange={handleThreadSelect}
disabled={loadingThreads || isSubmitting}
>
<SelectTrigger className="w-48">
<SelectTrigger className="w-full sm:w-48">
<SelectValue
placeholder={
loadingThreads
@@ -690,6 +883,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
size="lg"
onClick={handleNewThread}
disabled={!selectedAgent || isSubmitting}
className="whitespace-nowrap"
>
<Plus className="h-4 w-4 mr-2" />
New Thread
@@ -702,6 +896,68 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{selectedAgent.description}
</p>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Tools */}
<div className="flex items-center gap-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Tools:</span>
<span className="font-mono">
{selectedAgent.tools.length > 0
? selectedAgent.tools.join(", ")
: "No tools"}
</span>
<span className="text-muted-foreground">
({selectedAgent.tools.length})
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedAgent.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedAgent.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedAgent.source === "directory"
? "Local"
: selectedAgent.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedAgent.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedAgent.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedAgent.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedAgent.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Messages */}
@@ -764,16 +1020,43 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
)}
{/* Paste notification */}
{pasteNotification && (
<div
className="absolute bottom-24 left-1/2 -translate-x-1/2 z-20
bg-blue-500 text-white px-4 py-2 rounded-full text-sm
animate-in slide-in-from-bottom-2 fade-in duration-200
flex items-center gap-2 shadow-lg"
>
{pasteNotification.includes("screenshot") ? (
<Paperclip className="h-3 w-3" />
) : (
<FileText className="h-3 w-3" />
)}
{pasteNotification}
</div>
)}
{/* Input form */}
<form onSubmit={handleSubmit} className="flex gap-2">
<Input
<form onSubmit={handleSubmit} className="flex gap-2 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
// Submit on Enter (without shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}...`}
}... (Shift+Enter for new line)`}
disabled={isSubmitting || chatState.isStreaming}
className="flex-1"
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
@@ -783,12 +1066,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
type="submit"
size="icon"
disabled={!canSendMessage}
className="shrink-0"
className="shrink-0 h-10"
>
{isSubmitting ? (
<LoadingSpinner size="sm" />
) : (
<Send className="h-4 w-4" />
<SendHorizontal className="h-4 w-4" />
)}
</Button>
</form>
@@ -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';
@@ -3,7 +3,15 @@
*/
import { useState } from "react";
import { Download, FileText, AlertCircle, Code } from "lucide-react";
import {
Download,
FileText,
AlertCircle,
Code,
ChevronDown,
ChevronUp,
Music,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type { RenderProps } from "./types";
import {
@@ -13,14 +21,52 @@ import {
} from "@/types/agent-framework";
function TextContentRenderer({ content, isStreaming, className }: RenderProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!isTextContent(content)) return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH && !isStreaming;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
{content.text}
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
</div>
{isStreaming && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
{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>
);
}
@@ -38,6 +84,7 @@ function DataContentRenderer({ content, className }: RenderProps) {
const isImage = mediaType.startsWith("image/");
const isPdf = mediaType === "application/pdf";
const isAudio = mediaType.startsWith("audio/");
if (isImage && !imageError) {
return (
@@ -58,7 +105,23 @@ function DataContentRenderer({ content, className }: RenderProps) {
);
}
// Fallback for non-images or failed images
if (isAudio) {
return (
<div className={`my-2 p-3 border rounded-lg bg-purple-50 dark:bg-purple-950/20 ${className || ""}`}>
<div className="flex items-center gap-2 mb-2">
<Music className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium text-purple-800 dark:text-purple-300">Audio File</span>
<span className="text-xs text-muted-foreground">({mediaType})</span>
</div>
<audio controls className="w-full max-w-md">
<source src={dataUri} type={mediaType} />
Your browser does not support the audio element.
</audio>
</div>
);
}
// Fallback for non-images/non-audio or failed images
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div className="flex items-center gap-2">
@@ -115,9 +178,7 @@ function FunctionCallRenderer({ content, className }: RenderProps) {
<span className="text-sm font-medium text-blue-800">
Function Call: {content.name}
</span>
<span className="text-xs text-blue-600">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-xs text-blue-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
@@ -137,7 +198,9 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
if (!isFunctionResultContent(content)) return null;
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}>
<div
className={`my-2 p-3 border rounded-lg bg-green-50 ${className || ""}`}
>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -146,9 +209,7 @@ function FunctionResultRenderer({ content, className }: RenderProps) {
<span className="text-sm font-medium text-green-800">
Function Result
</span>
<span className="text-xs text-green-600">
{isExpanded ? "▼" : "▶"}
</span>
<span className="text-xs text-green-600">{isExpanded ? "▼" : "▶"}</span>
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white p-2 rounded border">
@@ -230,7 +291,11 @@ function UriContentRenderer({ content, className }: RenderProps) {
);
}
export function ContentRenderer({ content, isStreaming, className }: RenderProps) {
export function ContentRenderer({
content,
isStreaming,
className,
}: RenderProps) {
switch (content.type) {
case "text":
return (
@@ -245,19 +310,17 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
case "uri":
return <UriContentRenderer content={content} className={className} />;
case "function_call":
return (
<FunctionCallRenderer content={content} className={className} />
);
return <FunctionCallRenderer content={content} className={className} />;
case "function_result":
return (
<FunctionResultRenderer content={content} className={className} />
);
return <FunctionResultRenderer content={content} className={className} />;
case "error":
return <ErrorContentRenderer content={content} className={className} />;
default:
// Fallback for unsupported content types
return (
<div className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}>
<div
className={`my-2 p-2 bg-gray-100 rounded text-xs ${className || ""}`}
>
<div>Unsupported content type: {content.type}</div>
<pre className="mt-1 text-xs whitespace-pre-wrap">
{JSON.stringify(content, null, 2)}
@@ -265,4 +328,4 @@ export function ContentRenderer({ content, isStreaming, className }: RenderProps
</div>
);
}
}
}
@@ -21,12 +21,13 @@ export function AboutModal({ open, onOpenChange }: AboutModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogHeader className="p-6 pb-4">
<DialogTitle>About DevUI</DialogTitle>
<DialogClose onClose={() => onOpenChange(false)} />
</DialogHeader>
<div className="p-4 space-y-4">
<DialogClose onClose={() => onOpenChange(false)} />
<div className="px-6 pb-6 space-y-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
@@ -14,6 +14,8 @@ interface AppHeaderProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
onSettingsClick?: () => void;
}
@@ -23,6 +25,8 @@ export function AppHeader({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
@@ -34,6 +38,8 @@ export function AppHeader({
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onRemove={onRemove}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
@@ -973,6 +973,10 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
<span className="font-mono bg-accent/10 px-1 rounded">
ENABLE_OTEL=true
</span>{" "}
or restart devui with the tracing flag{" "}
<div className="font-mono bg-accent/10 px-1 rounded">
devui --enable-tracing
</div>
to enable tracing.
</div>
)}
@@ -15,7 +15,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, FolderOpen, Database } from "lucide-react";
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
@@ -23,6 +23,8 @@ interface EntitySelectorProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
}
@@ -30,8 +32,22 @@ const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
const getSourceIcon = (source: "directory" | "in_memory") => {
return source === "directory" ? FolderOpen : Database;
const getSourceIcon = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return FolderOpen;
case "in_memory": return Database;
case "remote_gallery": return Globe;
default: return Database;
}
};
const getSourceLabel = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return "Local";
case "in_memory": return "Memory";
case "remote_gallery": return "Gallery";
default: return "Unknown";
}
};
export function EntitySelector({
@@ -39,6 +55,8 @@ export function EntitySelector({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
@@ -53,7 +71,7 @@ export function EntitySelector({
};
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Entity";
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const itemCount =
selectedItem?.type === "workflow"
? (selectedItem as WorkflowInfo).executors?.length || 0
@@ -102,11 +120,13 @@ export function EntitySelector({
return (
<DropdownMenuItem
key={agent.id}
onClick={() => handleSelect(agent)}
className="cursor-pointer"
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(agent)}
>
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
@@ -122,8 +142,26 @@ export function EntitySelector({
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(agent.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{agent.tools.length}
</Badge>
{/* Remove button for gallery entities */}
{agent.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(agent.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
@@ -144,11 +182,13 @@ export function EntitySelector({
return (
<DropdownMenuItem
key={workflow.id}
onClick={() => handleSelect(workflow)}
className="cursor-pointer"
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 min-w-0">
<div
className="flex items-center gap-2 min-w-0 flex-1"
onClick={() => handleSelect(workflow)}
>
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0">
<div className="truncate font-medium">
@@ -164,8 +204,26 @@ export function EntitySelector({
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(workflow.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{workflow.executors.length}
</Badge>
{/* Remove button for gallery entities */}
{workflow.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(workflow.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
@@ -177,10 +235,23 @@ export function EntitySelector({
{allItems.length === 0 && (
<DropdownMenuItem disabled>
<div className="text-center text-muted-foreground py-2">
{isLoading ? "Loading entities..." : "No entities found"}
{isLoading ? "Loading agents and workflows..." : "No agents or workflows found"}
</div>
</DropdownMenuItem>
)}
{/* Browse Gallery option */}
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer text-primary"
onClick={() => {
onBrowseGallery?.();
setOpen(false);
}}
>
<Plus className="h-4 w-4 mr-2" />
Browse Gallery
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -0,0 +1,195 @@
/**
* Settings Modal - Tabbed settings dialog with About and Settings tabs
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ExternalLink, RotateCcw } from "lucide-react";
interface SettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBackendUrlChange?: (url: string) => void;
}
type Tab = "about" | "settings";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("about");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
const [backendUrl, setBackendUrl] = useState(() => {
return localStorage.getItem("devui_backend_url") || defaultUrl;
});
const [tempUrl, setTempUrl] = useState(backendUrl);
const handleSave = () => {
// Validate URL format
try {
new URL(tempUrl);
localStorage.setItem("devui_backend_url", tempUrl);
setBackendUrl(tempUrl);
onBackendUrlChange?.(tempUrl);
onOpenChange(false);
// Reload to apply new backend URL
window.location.reload();
} catch {
alert("Please enter a valid URL (e.g., http://localhost:8080)");
}
};
const handleReset = () => {
localStorage.removeItem("devui_backend_url");
setTempUrl(defaultUrl);
setBackendUrl(defaultUrl);
onBackendUrlChange?.(defaultUrl);
// Reload to apply default backend URL
window.location.reload();
};
const isModified = tempUrl !== backendUrl;
const isDefault = !localStorage.getItem("devui_backend_url");
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[600px] max-w-[90vw]">
<DialogHeader className="p-6 pb-2">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<DialogClose onClose={() => onOpenChange(false)} />
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("settings")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "settings"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Settings
{activeTab === "settings" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
{activeTab === "settings" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="backend-url" className="text-sm font-medium">
Backend URL
</Label>
{!isDefault && (
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="h-7 text-xs"
title="Reset to default"
>
<RotateCcw className="h-3 w-3 mr-1" />
Reset
</Button>
)}
</div>
<Input
id="backend-url"
type="url"
value={tempUrl}
onChange={(e) => setTempUrl(e.target.value)}
placeholder="http://localhost:8080"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Default: <span className="font-mono">{defaultUrl}</span>
</p>
{/* Reserve space for buttons to prevent layout shift */}
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button
onClick={handleSave}
size="sm"
className="flex-1"
>
Apply & Reload
</Button>
<Button
onClick={() => setTempUrl(backendUrl)}
variant="outline"
size="sm"
className="flex-1"
>
Cancel
</Button>
</>
)}
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,13 +3,13 @@
*/
import { useState } from "react";
import { FileText, Image, Trash2 } from "lucide-react";
import { FileText, Image, Trash2, Music } from "lucide-react";
export interface AttachmentItem {
id: string;
file: File;
preview?: string; // Data URL for preview
type: "image" | "pdf" | "other";
type: "image" | "pdf" | "audio" | "other";
}
interface AttachmentGalleryProps {
@@ -69,6 +69,14 @@ function AttachmentPreview({ attachment, onRemove }: AttachmentPreviewProps) {
</div>
);
case "audio":
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-purple-50">
<Music className="h-6 w-6 text-purple-500 mb-1" />
<span className="text-xs text-purple-600">AUDIO</span>
</div>
);
default:
return (
<div className="flex flex-col items-center justify-center w-full h-full bg-gray-100">
@@ -15,10 +15,17 @@ interface DialogContentProps {
interface DialogHeaderProps {
children: React.ReactNode;
className?: string;
}
interface DialogTitleProps {
children: React.ReactNode;
className?: string;
}
interface DialogDescriptionProps {
children: React.ReactNode;
className?: string;
}
interface DialogFooterProps {
@@ -46,30 +53,43 @@ export function DialogContent({
children,
className = "",
}: DialogContentProps) {
// Default width classes if none provided
const hasWidthClass = className.includes('w-[') || className.includes('w-full') || className.includes('max-w-');
const defaultWidthClasses = hasWidthClass ? '' : 'max-w-lg w-full';
return (
<div
className={`relative bg-background border rounded-lg shadow-lg max-w-lg w-full max-h-[90vh] overflow-hidden ${className}`}
className={`relative bg-background border rounded-lg shadow-lg max-h-[90vh] overflow-hidden ${defaultWidthClasses} ${className}`}
>
{children}
</div>
);
}
export function DialogHeader({ children }: DialogHeaderProps) {
export function DialogHeader({ children, className = "" }: DialogHeaderProps) {
return (
<div className="flex items-center justify-between p-4 border-b">
<div className={`space-y-2 ${className}`}>
{children}
</div>
);
}
export function DialogTitle({ children }: DialogTitleProps) {
return <h2 className="text-lg font-semibold">{children}</h2>;
export function DialogTitle({ children, className = "" }: DialogTitleProps) {
return <h2 className={`text-lg font-semibold ${className}`}>{children}</h2>;
}
export function DialogDescription({ children, className = "" }: DialogDescriptionProps) {
return <p className={`text-sm text-muted-foreground ${className}`}>{children}</p>;
}
export function DialogClose({ onClose }: { onClose: () => void }) {
return (
<Button variant="ghost" size="sm" onClick={onClose} className="h-6 w-6 p-0">
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="absolute top-4 right-4 h-8 w-8 p-0 rounded-sm opacity-70 hover:opacity-100"
>
<X className="h-4 w-4" />
</Button>
);
@@ -17,7 +17,7 @@ interface FileUploadProps {
export function FileUpload({
onFilesSelected,
accept = "image/*,.pdf",
accept = "image/*,.pdf,audio/*,.wav,.mp3,.m4a,.ogg",
multiple = true,
maxSize = 50 * 1024 * 1024, // 50MB default for local dev tool
disabled = false,
@@ -105,7 +105,7 @@ export function FileUpload({
onDrop={handleDrop}
onDragOver={handleDragOver}
className="shrink-0 transition-colors hover:bg-muted"
title="Upload files (images, PDFs)"
title="Upload files (images, PDFs, audio)"
>
<Upload className="h-4 w-4" />
</Button>
@@ -1,13 +1,8 @@
import { memo } from "react";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
CheckCircle,
XCircle,
Clock,
Loader2,
AlertCircle,
Play,
Flag,
Workflow,
Home,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -29,6 +24,7 @@ export interface ExecutorNodeData extends Record<string, unknown> {
isSelected?: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
layoutDirection?: "LR" | "TB";
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void;
}
@@ -36,54 +32,34 @@ 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",
borderColor: "border-[#643FB2] dark:border-[#8B5CF6]",
glow: "shadow-lg shadow-[#643FB2]/20",
badgeColor: "bg-[#643FB2] dark:bg-[#8B5CF6]",
};
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",
badgeColor: "bg-green-500 dark:bg-green-400",
};
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",
badgeColor: "bg-red-500 dark:bg-red-400",
};
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",
badgeColor: "bg-orange-500 dark:bg-orange-400",
};
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: "",
badgeColor: "bg-gray-400 dark:bg-gray-500",
};
}
};
@@ -91,11 +67,15 @@ const getExecutorStateConfig = (state: ExecutorState) => {
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";
// 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 = [];
@@ -175,89 +155,67 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
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 */}
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={Position.Left}
className="!w-2 !h-5 !rounded-r-sm !-ml-1 !border-0 transition-colors"
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" ? "#3b82f6" :
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
{/* 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"
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" ? "#3b82f6" :
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#9ca3af"
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<div className="p-4">
<div className="p-3">
{/* 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 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">
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{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">
@@ -267,7 +225,7 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
{/* 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 className="absolute inset-0 rounded border-2 border-[#643FB2]/30 dark:border-[#8B5CF6]/30 animate-pulse pointer-events-none" />
)}
</div>
</div>
@@ -7,6 +7,7 @@ import {
Maximize,
Shuffle,
Zap,
ArrowDown,
} from "lucide-react";
import {
DropdownMenu,
@@ -53,11 +54,15 @@ function ViewOptionsPanel({
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();
@@ -71,9 +76,17 @@ function ViewOptionsPanel({
const handleAutoArrange = () => {
if (!workflowDump) return;
const currentNodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
const currentNodes = convertWorkflowDumpToNodes(
workflowDump,
onNodeSelect,
layoutDirection
);
const currentEdges = convertWorkflowDumpToEdges(workflowDump);
const layoutedNodes = applyDagreLayout(currentNodes, currentEdges, "LR");
const layoutedNodes = applyDagreLayout(
currentNodes,
currentEdges,
layoutDirection
);
setNodes(layoutedNodes);
};
@@ -122,6 +135,35 @@ function ViewOptionsPanel({
<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
@@ -154,6 +196,8 @@ interface WorkflowFlowProps {
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
}
// Animation handler component that runs inside ReactFlow context
@@ -212,6 +256,8 @@ export function WorkflowFlow({
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(() => {
@@ -219,18 +265,24 @@ export function WorkflowFlow({
return { initialNodes: [], initialEdges: [] };
}
const nodes = convertWorkflowDumpToNodes(workflowDump, onNodeSelect);
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, "LR") : nodes;
nodes.length > 0
? applyDagreLayout(nodes, edges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect]);
}, [workflowDump, onNodeSelect, layoutDirection]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -388,7 +440,7 @@ export function WorkflowFlow({
const state = data?.state;
switch (state) {
case "running":
return "#3b82f6";
return "#643FB2";
case "completed":
return "#10b981";
case "failed":
@@ -420,6 +472,8 @@ export function WorkflowFlow({
onNodeSelect={onNodeSelect}
viewOptions={viewOptions}
onToggleViewOption={onToggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={onLayoutDirectionChange}
/>
</ReactFlow>
@@ -12,6 +12,12 @@ import {
Settings,
RotateCcw,
ChevronDown,
Package,
FolderOpen,
Database,
Globe,
XCircle,
Workflow as WorkflowIcon,
} from "lucide-react";
import { LoadingState } from "@/components/ui/loading-state";
import { WorkflowInputForm } from "@/components/workflow/workflow-input-form";
@@ -197,7 +203,7 @@ function RunWorkflowButton({
{/* 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>
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
</DialogHeader>
@@ -267,6 +273,7 @@ export function WorkflowView({
const [workflowResult, setWorkflowResult] = useState<string>("");
const [workflowError, setWorkflowError] = useState<string>("");
const accumulatedText = useRef<string>("");
const [detailsExpanded, setDetailsExpanded] = useState(false);
// Panel resize state
const [bottomPanelHeight, setBottomPanelHeight] = useState(() => {
@@ -287,6 +294,12 @@ export function WorkflowView({
};
});
// Layout direction state
const [layoutDirection, setLayoutDirection] = useState<"LR" | "TB">(() => {
const saved = localStorage.getItem("workflowLayoutDirection");
return (saved as "LR" | "TB") || "LR";
});
const { selectExecutor, getExecutorData } = useWorkflowEventCorrelation(
openAIEvents,
isStreaming
@@ -297,6 +310,11 @@ export function WorkflowView({
localStorage.setItem("workflowViewOptions", JSON.stringify(viewOptions));
}, [viewOptions]);
// Save layout direction to localStorage
useEffect(() => {
localStorage.setItem("workflowLayoutDirection", layoutDirection);
}, [layoutDirection]);
// View option handlers
const toggleViewOption = (key: keyof typeof viewOptions) => {
setViewOptions((prev: typeof viewOptions) => ({
@@ -467,7 +485,11 @@ export function WorkflowView({
event_type?: string;
data?: unknown;
};
if (data.event_type === "WorkflowCompletedEvent" && data.data) {
if (
(data.event_type === "WorkflowCompletedEvent" ||
data.event_type === "WorkflowOutputEvent") &&
data.data
) {
setWorkflowResult(String(data.data));
}
}
@@ -517,55 +539,149 @@ export function WorkflowView({
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 */}
{/* Header */}
<div className="border-b pb-2 p-4 flex-shrink-0">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<h2 className="font-semibold text-sm truncate">
<div className="flex items-center gap-2">
<WorkflowIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedWorkflow.name || selectedWorkflow.id}
</span>
</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}
</h2>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsExpanded(!detailsExpanded)}
className="h-6 w-6 p-0 flex-shrink-0"
>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
detailsExpanded ? "rotate-180" : ""
}`}
/>
</Button>
</div>
{/* Run Workflow Controls */}
{workflowInfo && (
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-shrink-0">
<RunWorkflowButton
inputSchema={workflowInfo.input_schema}
onRun={handleSendWorkflowData}
isSubmitting={isStreaming}
workflowState={
isStreaming
? "running"
: workflowError
? "error"
: executorHistory.length > 0
? "completed"
: "ready"
}
executorHistory={executorHistory}
workflowError={workflowError}
/>
</div>
)}
</div>
{selectedWorkflow.description && (
<p className="text-sm text-muted-foreground">
{selectedWorkflow.description}
</p>
)}
{/* Executors - Always visible */}
{selectedWorkflow.executors.length > 0 && (
<div className="flex items-center gap-2 text-xs mt-2 mb-2">
<Package className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Executors:</span>
<span className="font-mono">
{selectedWorkflow.executors.slice(0, 3).join(", ")}
{selectedWorkflow.executors.length > 3 && "..."}
</span>
<span className="text-muted-foreground">
({selectedWorkflow.executors.length})
</span>
</div>
)}
{/* Collapsible Details Section */}
<div
className={`overflow-hidden transition-all duration-200 ease-in-out ${
detailsExpanded ? "max-h-40 mt-3" : "max-h-0"
}`}
>
<div className="space-y-2 text-xs">
{/* Start Executor */}
<div className="flex items-center gap-2">
<WorkflowIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Start:</span>
<span className="font-mono">
{selectedWorkflow.start_executor_id}
</span>
</div>
{/* Source */}
<div className="flex items-center gap-2">
{selectedWorkflow.source === "directory" ? (
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
) : selectedWorkflow.source === "in_memory" ? (
<Database className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<Globe className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">Source:</span>
<span>
{selectedWorkflow.source === "directory"
? "Local"
: selectedWorkflow.source === "in_memory"
? "In-Memory"
: "Gallery"}
</span>
{selectedWorkflow.module_path && (
<span className="text-muted-foreground font-mono text-[11px]">
({selectedWorkflow.module_path})
</span>
)}
</div>
{/* Environment */}
<div className="flex items-center gap-2">
{selectedWorkflow.has_env ? (
<XCircle className="h-3.5 w-3.5 text-orange-500" />
) : (
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
)}
<span className="text-muted-foreground">Environment:</span>
<span>
{selectedWorkflow.has_env
? "Requires environment variables"
: "No environment variables required"}
</span>
</div>
</div>
</div>
</div>
{/* Workflow Visualization */}
<div className="flex-1 min-h-0">
{workflowInfo?.workflow_dump && (
<WorkflowFlow
workflowDump={workflowInfo.workflow_dump}
events={openAIEvents}
isStreaming={isStreaming}
onNodeSelect={handleNodeSelect}
className="h-full"
viewOptions={viewOptions}
onToggleViewOption={toggleViewOption}
layoutDirection={layoutDirection}
onLayoutDirectionChange={setLayoutDirection}
/>
)}
</div>
{/* Resize Handle */}
@@ -598,13 +714,17 @@ export function WorkflowView({
executorHistory.length > 0 ||
workflowResult ||
workflowError ? (
<div className="h-full space-y-4">
<div className="h-full flex gap-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">
<div
className={`border border-border rounded bg-card shadow-sm flex flex-col ${
workflowResult || workflowError ? "flex-1" : "w-full"
}`}
>
<div className="border-b border-border px-4 py-3 bg-muted rounded-t flex-shrink-0">
<h4 className="text-sm font-medium text-foreground">
{selectedExecutor
? `Executor: ${
@@ -615,14 +735,14 @@ export function WorkflowView({
: "Last Executor"}
</h4>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
{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"
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
: selectedExecutor.state === "completed"
? "bg-green-500 dark:bg-green-400"
: selectedExecutor.state === "failed"
@@ -754,7 +874,7 @@ export function WorkflowView({
<div
className={`w-3 h-3 rounded-full ${
isStreaming
? "bg-blue-500 dark:bg-blue-400 animate-pulse"
? "bg-[#643FB2] dark:bg-[#8B5CF6] animate-pulse"
: historyItem?.status === "completed"
? "bg-green-500 dark:bg-green-400"
: historyItem?.status === "error"
@@ -791,8 +911,8 @@ export function WorkflowView({
{/* 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="border-2 border-emerald-300 dark:border-emerald-600 rounded bg-emerald-50 dark:bg-emerald-950/50 shadow flex-1 flex flex-col">
<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 flex-shrink-0">
<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">
@@ -800,7 +920,7 @@ export function WorkflowView({
</h4>
</div>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
<div className="text-emerald-700 dark:text-emerald-300 whitespace-pre-wrap break-words text-sm">
{workflowResult}
</div>
@@ -810,8 +930,8 @@ export function WorkflowView({
{/* 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="border-2 border-destructive/70 rounded bg-destructive/5 shadow flex-1 flex flex-col">
<div className="border-b border-destructive/70 px-4 py-3 bg-destructive/10 rounded-t flex-shrink-0">
<div className="flex items-center gap-3">
<AlertCircle className="w-4 h-4 text-destructive" />
<h4 className="text-sm font-semibold text-destructive">
@@ -819,7 +939,7 @@ export function WorkflowView({
</h4>
</div>
</div>
<div className="p-4">
<div className="p-4 overflow-auto flex-1">
<div className="text-destructive whitespace-pre-wrap break-words text-sm">
{workflowError}
</div>
@@ -0,0 +1,5 @@
/**
* Gallery data exports
*/
export * from './sample-entities';
@@ -0,0 +1,185 @@
/**
* Sample entities for the gallery - curated examples to help users learn Agent Framework
*/
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface SampleEntity {
id: string;
name: string;
description: string;
type: "agent" | "workflow";
url: string;
tags: string[];
author: string;
difficulty: "beginner" | "intermediate" | "advanced";
features: string[];
requiredEnvVars?: EnvVarRequirement[];
}
export const SAMPLE_ENTITIES: SampleEntity[] = [
// Beginner Agents
{
id: "weather-agent",
name: "Weather Agent",
description:
"Simple weather agent with mock data demonstrating basic tool usage",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent/agent.py",
tags: ["openai", "tools", "basic"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Function calling",
"Mock weather data",
"Simple tool integration",
],
requiredEnvVars: [
{
name: "OPENAI_API_KEY",
description: "OpenAI API key for chat completions",
required: true,
},
{
name: "OPENAI_CHAT_MODEL_ID",
description: "OpenAI model ID (e.g., gpt-4o)",
required: false,
example: "gpt-4o",
},
],
},
{
id: "foundry-weather-agent",
name: "Azure AI Weather Agent",
description:
"Weather agent using Azure AI Agent (Foundry) with Azure CLI authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/foundry_agent/agent.py",
tags: ["azure-ai", "foundry", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure AI Agent integration",
"Azure CLI authentication",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "AZURE_AI_PROJECT_ENDPOINT",
description: "Azure AI Foundry project endpoint URL",
required: true,
example: "https://your-project.api.azureml.ms",
},
{
name: "FOUNDRY_MODEL_DEPLOYMENT_NAME",
description: "Name of the deployed model in Azure AI Foundry",
required: true,
example: "gpt-4o",
},
],
},
{
id: "weather-agent-azure",
name: "Azure OpenAI Weather Agent",
description:
"Weather agent using Azure OpenAI with API key authentication",
type: "agent",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/weather_agent_azure/agent.py",
tags: ["azure", "openai", "tools"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Azure OpenAI integration",
"API key authentication",
"Function calling",
"Mock weather tools",
],
requiredEnvVars: [
{
name: "AZURE_OPENAI_API_KEY",
description: "Azure OpenAI API key",
required: true,
},
{
name: "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME",
description: "Name of the deployed model in Azure OpenAI",
required: true,
example: "gpt-4o",
},
{
name: "AZURE_OPENAI_ENDPOINT",
description: "Azure OpenAI endpoint URL",
required: true,
example: "https://your-resource.openai.azure.com",
},
],
},
// Beginner Workflows
{
id: "spam-workflow",
name: "Spam Detection Workflow",
description:
"5-step workflow demonstrating email spam detection with branching logic",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/spam_workflow/workflow.py",
tags: ["workflow", "branching", "multi-step"],
author: "Microsoft",
difficulty: "beginner",
features: [
"Sequential execution",
"Conditional branching",
"Mock spam detection",
],
},
// Advanced Workflows
{
id: "fanout-workflow",
name: "Complex Fan-In/Fan-Out Workflow",
description:
"Advanced data processing workflow with parallel validation, transformation, and quality assurance stages",
type: "workflow",
url: "https://raw.githubusercontent.com/microsoft/agent-framework/main/python/packages/devui/samples/fanout_workflow/workflow.py",
tags: ["workflow", "fan-out", "fan-in", "parallel"],
author: "Microsoft",
difficulty: "advanced",
features: [
"Fan-out pattern",
"Parallel execution",
"Complex state management",
"Multi-stage processing",
],
},
];
// Group samples by category for better organization
export const SAMPLE_CATEGORIES = {
all: SAMPLE_ENTITIES,
agents: SAMPLE_ENTITIES.filter((e) => e.type === "agent"),
workflows: SAMPLE_ENTITIES.filter((e) => e.type === "workflow"),
beginner: SAMPLE_ENTITIES.filter((e) => e.difficulty === "beginner"),
intermediate: SAMPLE_ENTITIES.filter((e) => e.difficulty === "intermediate"),
advanced: SAMPLE_ENTITIES.filter((e) => e.difficulty === "advanced"),
};
// Get difficulty color for badges
export const getDifficultyColor = (difficulty: SampleEntity["difficulty"]) => {
switch (difficulty) {
case "beginner":
return "bg-green-100 text-green-700 border-green-200";
case "intermediate":
return "bg-yellow-100 text-yellow-700 border-yellow-200";
case "advanced":
return "bg-red-100 text-red-700 border-red-200";
default:
return "bg-gray-100 text-gray-700 border-gray-200";
}
};
+3 -3
View File
@@ -49,7 +49,7 @@
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary: oklch(0.48 0.18 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
@@ -83,8 +83,8 @@
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--primary: oklch(0.62 0.20 290);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
@@ -5,6 +5,7 @@
import type {
AgentInfo,
AgentSource,
HealthResponse,
RunAgentRequest,
RunWorkflowRequest,
@@ -22,6 +23,8 @@ interface EntityInfo {
framework: string;
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
executors?: string[];
workflow_dump?: Record<string, unknown>;
input_schema?: Record<string, unknown>;
@@ -52,16 +55,30 @@ interface ThreadApiObject {
created_at?: string;
}
const API_BASE_URL =
const DEFAULT_API_BASE_URL =
import.meta.env.VITE_API_BASE_URL !== undefined
? import.meta.env.VITE_API_BASE_URL
: "http://localhost:8080";
// Get backend URL from localStorage or default
function getBackendUrl(): string {
return localStorage.getItem("devui_backend_url") || DEFAULT_API_BASE_URL;
}
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string = API_BASE_URL) {
this.baseUrl = baseUrl;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || getBackendUrl();
}
// Allow updating the base URL at runtime
setBaseUrl(url: string) {
this.baseUrl = url;
}
getBaseUrl(): string {
return this.baseUrl;
}
private async request<T>(
@@ -79,9 +96,17 @@ class ApiClient {
});
if (!response.ok) {
throw new Error(
`API request failed: ${response.status} ${response.statusText}`
);
// Try to extract error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// If parsing fails, use default message
}
throw new Error(errorMessage);
}
return response.json();
@@ -111,7 +136,7 @@ class ApiClient {
name: entity.name,
description: entity.description,
type: "agent",
source: "directory", // Default source
source: (entity.source as AgentSource) || "directory",
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
@@ -130,7 +155,7 @@ class ApiClient {
name: entity.name,
description: entity.description,
type: "workflow",
source: "directory",
source: (entity.source as AgentSource) || "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
@@ -440,6 +465,31 @@ class ApiClient {
body: JSON.stringify(request),
});
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<EntityInfo> {
const response = await this.request<{ success: boolean; entity: EntityInfo }>("/v1/entities/add", {
method: "POST",
body: JSON.stringify({ url, metadata }),
});
if (!response.success || !response.entity) {
throw new Error("Failed to add entity");
}
return response.entity;
}
// Remove entity by ID
async removeEntity(entityId: string): Promise<void> {
const response = await this.request<{ success: boolean }>(`/v1/entities/${entityId}`, {
method: "DELETE",
});
if (!response.success) {
throw new Error("Failed to remove entity");
}
}
}
// Export singleton instance
@@ -238,9 +238,10 @@ export interface AgentThread {
// Workflow events
export interface WorkflowEvent {
type?: string; // Event class name like "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
type?: string; // Event class name like "WorkflowOutputEvent", "WorkflowCompletedEvent", "ExecutorInvokedEvent", etc.
data?: unknown;
executor_id?: string; // Present for executor-related events
source_executor_id?: string; // Present for WorkflowOutputEvent
}
export interface WorkflowStartedEvent extends WorkflowEvent {
@@ -249,10 +250,16 @@ export interface WorkflowStartedEvent extends WorkflowEvent {
}
export interface WorkflowCompletedEvent extends WorkflowEvent {
// Event-specific data for workflow completion
// Event-specific data for workflow completion (legacy)
readonly event_type: "workflow_completed";
}
export interface WorkflowOutputEvent extends WorkflowEvent {
// Event-specific data for workflow output (new)
readonly event_type: "workflow_output";
source_executor_id: string; // ID of executor that yielded the output
}
export interface WorkflowWarningEvent extends WorkflowEvent {
data: string; // Warning message
}
@@ -4,7 +4,7 @@
*/
export type AgentType = "agent" | "workflow";
export type AgentSource = "directory" | "in_memory";
export type AgentSource = "directory" | "in_memory" | "remote_gallery";
export type StreamEventType =
| "agent_run_update"
| "workflow_event"
@@ -14,6 +14,13 @@ export type StreamEventType =
| "debug_trace"
| "trace_span";
export interface EnvVarRequirement {
name: string;
description: string;
required: boolean;
example?: string;
}
export interface AgentInfo {
id: string;
name?: string;
@@ -23,6 +30,7 @@ export interface AgentInfo {
tools: string[];
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
}
// JSON Schema types for workflow input
@@ -45,7 +45,7 @@ export function applySimpleLayout(
// Constants for spacing
const NODE_WIDTH = 220;
const NODE_HEIGHT = 120;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 200;
const HORIZONTAL_SPACING = direction === "LR" ? 350 : 280;
const VERTICAL_SPACING = direction === "TB" ? 250 : 180;
// Track positioned nodes and level information
@@ -54,7 +54,8 @@ export interface NodeUpdate {
*/
export function convertWorkflowDumpToNodes(
workflowDump: Workflow | Record<string, unknown> | undefined,
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void
onNodeClick?: (executorId: string, data: ExecutorNodeData) => void,
layoutDirection?: "LR" | "TB"
): Node<ExecutorNodeData>[] {
if (!workflowDump) {
console.warn("convertWorkflowDumpToNodes: workflowDump is undefined");
@@ -108,6 +109,7 @@ export function convertWorkflowDumpToNodes(
name: executor.name || executor.id,
state: "pending" as ExecutorState,
isStartNode: executor.id === startExecutorId,
layoutDirection: layoutDirection || "LR",
onNodeClick,
},
}));
@@ -339,7 +341,7 @@ export function processWorkflowEvents(
error = typeof eventData === "string" ? eventData : "Execution failed";
} else if (eventType?.includes("Cancel")) {
state = "cancelled";
} else if (eventType === "WorkflowCompletedEvent") {
} else if (eventType === "WorkflowCompletedEvent" || eventType === "WorkflowOutputEvent") {
state = "completed";
}
@@ -376,6 +378,8 @@ export function updateNodesWithEvents(
state: update.state,
outputData: update.data,
error: update.error,
// Preserve layoutDirection
layoutDirection: node.data.layoutDirection,
},
};
}
@@ -478,7 +482,7 @@ export function updateEdgesWithSequenceAnalysis(
// Active edge: source completed and target is currently executing
if (sourceState?.completed && targetIsExecuting) {
style = {
stroke: "#3b82f6", // Blue
stroke: "#643FB2", // Purple accent
strokeWidth: 3,
strokeDasharray: "5,5",
};