Revert "Merge from main"

This reverts commit b8206a85d7.
This commit is contained in:
Dmytro Struk
2025-11-11 18:44:25 -08:00
Unverified
parent b8206a85d7
commit 85fcd230bf
231 changed files with 4138 additions and 19654 deletions
@@ -15,9 +15,7 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.12",
"@xyflow/react": "^12.8.4",
+81 -272
View File
@@ -3,49 +3,33 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useState } from "react";
import { useEffect, useCallback } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
import { WorkflowView } from "@/components/features/workflow";
import { Toast, ToastContainer } from "@/components/ui/toast";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronLeft, ChevronDown, ServerOff, Rocket, Lock } from "lucide-react";
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
export default function App() {
// Local state for auth handling
const [authRequired, setAuthRequired] = useState(false);
const [authToken, setAuthToken] = useState("");
const [isTestingToken, setIsTestingToken] = useState(false);
const [authError, setAuthError] = useState("");
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
const workflows = useDevUIStore((state) => state.workflows);
const entities = useDevUIStore((state) => state.entities);
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
const entityError = useDevUIStore((state) => state.entityError);
// OpenAI proxy mode
const oaiMode = useDevUIStore((state) => state.oaiMode);
// UI mode
const uiMode = useDevUIStore((state) => state.uiMode);
// Entity actions
const setAgents = useDevUIStore((state) => state.setAgents);
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
const setEntities = useDevUIStore((state) => state.setEntities);
const selectEntity = useDevUIStore((state) => state.selectEntity);
const updateAgent = useDevUIStore((state) => state.updateAgent);
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
@@ -54,14 +38,12 @@ export default function App() {
// UI state from Zustand
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
const debugPanelMinimized = useDevUIStore((state) => state.debugPanelMinimized);
const debugPanelWidth = useDevUIStore((state) => state.debugPanelWidth);
const debugEvents = useDevUIStore((state) => state.debugEvents);
const isResizing = useDevUIStore((state) => state.isResizing);
// UI actions
const setShowDebugPanel = useDevUIStore((state) => state.setShowDebugPanel);
const setDebugPanelMinimized = useDevUIStore((state) => state.setDebugPanelMinimized);
const setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
@@ -79,40 +61,13 @@ export default function App() {
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Toast state and actions
const toasts = useDevUIStore((state) => state.toasts);
const removeToast = useDevUIStore((state) => state.removeToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
// Fetch server metadata first (ui_mode, capabilities, auth status)
const meta = await apiClient.getMeta();
// Check if auth is required
if (meta.auth_required) {
setAuthRequired(true);
// If we don't have a token, stop here and show auth UI
if (!apiClient.getAuthToken()) {
setEntityError("UNAUTHORIZED");
setIsLoadingEntities(false);
return;
}
}
useDevUIStore.getState().setServerMeta({
uiMode: meta.ui_mode,
runtime: meta.runtime,
capabilities: meta.capabilities,
authRequired: meta.auth_required,
});
// Single API call instead of two parallel calls to same endpoint
const { entities: allEntities, agents: agentList, workflows: workflowList } = await apiClient.getEntities();
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setEntities(allEntities);
setAgents(agentList);
setWorkflows(workflowList);
@@ -124,7 +79,9 @@ export default function App() {
// Try to find entity from URL parameter first
if (entityId) {
selectedEntity = allEntities.find((e) => e.id === entityId);
selectedEntity =
agentList.find((a) => a.id === entityId) ||
workflowList.find((w) => w.id === entityId);
// If entity not found but was requested, show notification
if (!selectedEntity) {
@@ -134,9 +91,12 @@ export default function App() {
// Fallback to first available entity if URL entity not found
if (!selectedEntity) {
// Use the first entity from the backend's original order
// This respects the backend's intended display order
selectedEntity = allEntities.length > 0 ? allEntities[0] : undefined;
selectedEntity =
agentList.length > 0
? agentList[0]
: workflowList.length > 0
? workflowList[0]
: undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedEntity) {
@@ -180,14 +140,9 @@ export default function App() {
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load data";
// Check if this is an auth error
if (errorMessage === "UNAUTHORIZED") {
setAuthRequired(true);
}
setEntityError(errorMessage);
setEntityError(
error instanceof Error ? error.message : "Failed to load data"
);
setIsLoadingEntities(false);
}
};
@@ -195,47 +150,6 @@ export default function App() {
loadData();
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
// Handle auth token submission
const handleAuthTokenSubmit = useCallback(async () => {
if (!authToken.trim()) return;
setIsTestingToken(true);
setAuthError("");
try {
// Set token in API client (stores in localStorage)
apiClient.setAuthToken(authToken.trim());
// Test the token with an actual PROTECTED endpoint (not /meta which is public)
await apiClient.getEntities();
// If successful, reload to initialize with new token
window.location.reload();
} catch (error) {
// Token is invalid - clear it and show error
apiClient.clearAuthToken();
setIsTestingToken(false);
const errorMsg = error instanceof Error ? error.message : "Unknown error";
if (errorMsg === "UNAUTHORIZED") {
setAuthError("Invalid token. Please check and try again.");
} else {
setAuthError(`Failed to connect: ${errorMsg}`);
}
}
}, [authToken]);
// Auto-switch from workflow to agent when OpenAI proxy mode is enabled
useEffect(() => {
if (oaiMode.enabled && selectedAgent?.type === "workflow") {
// Workflows don't work with OpenAI proxy - switch to first available agent
const firstAgent = agents[0];
if (firstAgent) {
selectEntity(firstAgent);
}
}
}, [oaiMode.enabled, selectedAgent, agents, selectEntity]);
// Handle resize drag
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
@@ -328,14 +242,12 @@ export default function App() {
// Show error state if loading failed
if (entityError) {
const currentBackendUrl = apiClient.getBaseUrl();
const isAuthError = entityError === "UNAUTHORIZED" || authRequired;
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
agents={[]}
workflows={[]}
entities={[]}
selectedItem={undefined}
onSelect={() => {}}
isLoading={false}
@@ -348,124 +260,63 @@ export default function App() {
{/* Icon */}
<div className="flex justify-center">
<div className="rounded-full bg-muted p-4 animate-pulse">
{isAuthError ? (
<Lock className="h-12 w-12 text-muted-foreground" />
) : (
<ServerOff className="h-12 w-12 text-muted-foreground" />
)}
<ServerOff className="h-12 w-12 text-muted-foreground" />
</div>
</div>
{/* Heading */}
<div className="space-y-2">
<h2 className="text-2xl font-semibold text-foreground">
{isAuthError ? "Authentication Required" : "Can't Connect to Backend"}
Can't Connect to Backend
</h2>
<p className="text-muted-foreground text-base">
{isAuthError
? "This backend requires a bearer token to access."
: "No worries! Just start the DevUI backend server and you'll be good to go."}
No worries! Just start the DevUI backend server and you'll be
good to go.
</p>
</div>
{/* Auth Input or Command Instructions */}
{isAuthError ? (
<div className="space-y-4">
<div className="text-left bg-muted/50 rounded-lg p-4 space-y-3">
<p className="text-sm font-medium text-foreground">
Enter Authentication Token
</p>
<Input
type="password"
placeholder="Paste token from server logs"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isTestingToken) {
handleAuthTokenSubmit();
}
}}
disabled={isTestingToken}
className="font-mono text-sm"
/>
<Button
onClick={handleAuthTokenSubmit}
disabled={!authToken.trim() || isTestingToken}
className="w-full"
>
{isTestingToken ? "Verifying..." : "Connect"}
</Button>
{/* Error message */}
{authError && (
<p className="text-sm text-red-600 dark:text-red-400 text-center">
{authError}
</p>
)}
</div>
<details className="text-left group">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground flex items-center gap-2 justify-center">
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" />
Where do I find the token?
</summary>
<div className="mt-3 text-left bg-muted/30 rounded-lg p-3 space-y-2">
<p className="text-xs text-muted-foreground">
Look for this in your DevUI server startup logs:
</p>
<code className="block bg-background px-2 py-1 rounded text-xs font-mono text-foreground">
🔑 DEV TOKEN (localhost only, shown once):
<br />
&nbsp;&nbsp; abc123xyz...
</code>
</div>
</details>
{/* 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>
) : (
<>
<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">{currentBackendUrl}</span>
</p>
</div>
<p className="text-xs text-muted-foreground">
Default:{" "}
<span className="font-mono">{currentBackendUrl}</span>
</p>
</div>
{/* Error Details (Collapsible) */}
{entityError && (
<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">
{entityError}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</>
{/* Error Details (Collapsible) */}
{entityError && (
<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">
{entityError}
</p>
</details>
)}
{/* Retry Button */}
<Button
onClick={() => window.location.reload()}
variant="default"
className="mt-2"
>
Retry Connection
</Button>
</div>
</div>
@@ -480,7 +331,6 @@ export default function App() {
<AppHeader
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onBrowseGallery={() => setShowGallery(true)}
@@ -527,7 +377,7 @@ export default function App() {
)}
</div>
{uiMode === "developer" && showDebugPanel ? (
{showDebugPanel ? (
<>
{/* Resize Handle */}
<div
@@ -550,68 +400,31 @@ export default function App() {
{/* Right Panel - Debug */}
<div
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: debugPanelMinimized ? '2.5rem' : `${debugPanelWidth}px` }}
style={{ width: `${debugPanelWidth}px` }}
>
{debugPanelMinimized ? (
/* Minimized Debug Panel - Vertical Bar (fully clickable) */
<div
className="h-full w-10 bg-background border-l flex flex-col items-center py-2 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => setDebugPanelMinimized(false)}
title="Expand debug panel"
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onClose={() => setShowDebugPanel(false)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
>
{/* Expand button at top (visual affordance) */}
<div className="h-8 w-8 flex items-center justify-center">
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
</div>
{/* Text and count centered in middle */}
<div className="flex-1 flex flex-col items-center justify-center gap-2 pointer-events-none">
<div
className="text-xs text-muted-foreground select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)'
}}
>
Debug Panel
</div>
{debugEvents.length > 0 && (
<div className="bg-primary text-primary-foreground rounded-full w-5 h-5 flex items-center justify-center"
style={{ fontSize: '10px' }}>
{debugEvents.length}
</div>
)}
</div>
</div>
) : (
<>
<DebugPanel
events={debugEvents}
isStreaming={false} // Each view manages its own streaming state
onMinimize={() => setDebugPanelMinimized(true)}
/>
{/* Deploy Footer - Pinned to bottom */}
<div className="border-t bg-muted/30 px-3 py-2.5 flex-shrink-0">
<Button
onClick={() => setShowDeployModal(true)}
className="w-full"
variant="outline"
size="sm"
>
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
{azureDeploymentEnabled && selectedAgent?.deployment_supported
? "Deploy to Azure"
: "Deployment Guide"}
</span>
</Button>
</div>
</>
)}
<Rocket className="h-3 w-3 mr-2 flex-shrink-0" />
<span className="truncate text-xs">
Deployment Guide for {selectedAgent?.name || "Agent"}
</span>
</Button>
</div>
</div>
</>
) : uiMode === "developer" ? (
) : (
/* Button to reopen when closed */
<div className="flex-shrink-0">
<Button
@@ -624,7 +437,7 @@ export default function App() {
<PanelRightOpen className="h-4 w-4" />
</Button>
</div>
) : null}
)}
</>
)}
</div>
@@ -637,7 +450,6 @@ export default function App() {
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
entity={selectedAgent}
/>
{/* Toast Notification */}
@@ -648,9 +460,6 @@ export default function App() {
onClose={() => setShowEntityNotFoundToast(false)}
/>
)}
{/* Toast Container for reload and other notifications */}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}
@@ -36,7 +36,6 @@ import {
X,
Copy,
CheckCheck,
RefreshCw,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -119,7 +118,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
>
<div className="relative group">
<div
className={`rounded px-3 py-2 text-sm ${
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
@@ -162,12 +161,7 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
<span>
{item.created_at
? new Date(item.created_at * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString() // Fallback for legacy items without timestamp
}
</span>
<span>{new Date().toLocaleTimeString()}</span>
{!isUser && item.usage && (
<>
<span></span>
@@ -213,10 +207,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const uiMode = useDevUIStore((state) => state.uiMode);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
const oaiMode = useDevUIStore((state) => state.oaiMode);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
@@ -235,12 +227,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationError, setConversationError] = useState<{
message: string;
code?: string;
type?: string;
} | null>(null);
const [isReloading, setIsReloading] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -618,21 +604,10 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation]);
setChatItems([]);
setIsStreaming(false);
setConversationError(null); // Clear any previous errors
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch (error) {
} catch {
setAvailableConversations([]);
setChatItems([]);
setIsStreaming(false);
// Extract error details for display
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
} finally {
setLoadingConversations(false);
}
@@ -881,22 +856,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
setConversationError(null); // Clear any previous errors
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedTextRef.current = "";
// Update localStorage cache with new conversation
const cachedKey = `devui_convs_${selectedAgent.id}`;
const updated = [newConversation, ...availableConversations];
localStorage.setItem(cachedKey, JSON.stringify(updated));
} catch (error) {
// Failed to create conversation - show error to user
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
} catch {
// Failed to create conversation
}
}, [selectedAgent, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
@@ -951,42 +915,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
[availableConversations, currentConversation, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle entity reload (hot reload)
const handleReloadEntity = useCallback(async () => {
if (isReloading || !selectedAgent) return;
setIsReloading(true);
const addToast = useDevUIStore.getState().addToast;
const updateAgent = useDevUIStore.getState().updateAgent;
try {
// Call backend reload endpoint
await apiClient.reloadEntity(selectedAgent.id);
// Fetch updated entity info
const updatedAgent = await apiClient.getAgentInfo(selectedAgent.id);
// Update store with fresh metadata
updateAgent(updatedAgent);
// Show success toast
addToast({
message: `${selectedAgent.name} has been reloaded successfully`,
type: "success",
});
} catch (error) {
// Show error toast
const errorMessage = error instanceof Error ? error.message : "Failed to reload entity";
addToast({
message: `Failed to reload: ${errorMessage}`,
type: "error",
duration: 6000,
});
} finally {
setIsReloading(false);
}
}, [isReloading, selectedAgent]);
// Handle conversation selection
const handleConversationSelect = useCallback(
async (conversationId: string) => {
@@ -1074,27 +1002,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approval = pendingApprovals.find((a) => a.request_id === request_id);
if (!approval) return;
// Add user's decision as a visible message in the chat
const messageTimestamp = Math.floor(Date.now() / 1000);
const userDecisionMessage: import("@/types/openai").ConversationMessage = {
id: `user-approval-${Date.now()}`,
type: "message",
role: "user",
content: [
{
type: "function_approval_request",
request_id: request_id,
status: approved ? "approved" : "rejected",
function_call: approval.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "completed",
created_at: messageTimestamp,
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, userDecisionMessage]);
// Create approval response in OpenAI-compatible format
const approvalInput: import("@/types/agent-framework").ResponseInputParam = [
{
@@ -1112,12 +1019,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
];
// Send approval response through the conversation
// We'll call handleSendMessage directly when invoked (it's defined below)
const request: RunAgentRequest = {
input: approvalInput,
conversation_id: currentConversation?.id,
};
// Remove from pending immediately
// Remove from pending immediately (will be confirmed by backend event)
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
@@ -1131,14 +1039,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
async (request: RunAgentRequest) => {
if (!selectedAgent) return;
// Check if this is a function approval response (internal, don't show in chat)
const isApprovalResponse = request.input.some(
(inputItem) =>
inputItem.type === "message" &&
Array.isArray(inputItem.content) &&
inputItem.content.some((c) => c.type === "function_approval_response")
);
// Extract content from OpenAI format to create ConversationMessage
const messageContent: import("@/types/openai").MessageContent[] = [];
@@ -1169,23 +1069,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
}
// Capture timestamp once for both user and assistant messages
const messageTimestamp = Math.floor(Date.now() / 1000); // Unix seconds
// Only add user message to UI if it's not an approval response (internal messages)
if (!isApprovalResponse && messageContent.length > 0) {
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
}
// Add user message to UI state (OpenAI ConversationMessage)
const userMessage: import("@/types/openai").ConversationMessage = {
id: `user-${Date.now()}`,
type: "message",
role: "user",
content: messageContent,
status: "completed",
};
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
@@ -1195,7 +1088,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
role: "assistant",
content: [], // Will be filled during streaming
status: "in_progress",
created_at: messageTimestamp,
};
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
@@ -1210,17 +1102,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
setCurrentConversation(conversationToUse);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
setConversationError(null); // Clear any previous errors
} catch (error) {
// Failed to create conversation - show error and stop execution
const errorMessage = error instanceof Error ? error.message : "Failed to create conversation";
setConversationError({
message: errorMessage,
type: "conversation_creation_error",
});
setIsSubmitting(false);
setIsStreaming(false);
return; // Stop execution - can't send message without conversation
} catch {
// Failed to create conversation
}
}
@@ -1262,25 +1145,16 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
continue; // Continue processing other events
}
// Handle response.failed event (OpenAI standard)
// Handle response.failed event
if (openAIEvent.type === "response.failed") {
const failedEvent = openAIEvent as import("@/types/openai").ResponseFailedEvent;
const error = failedEvent.response?.error;
const errorMessage = error
? typeof error === "object" && "message" in error
? (error as any).message
: JSON.stringify(error)
: "Request failed";
// Format error message with details
let errorMessage = "Request failed";
if (error) {
if (typeof error === "object" && "message" in error) {
errorMessage = error.message as string;
if ("code" in error && error.code) {
errorMessage += ` (Code: ${error.code})`;
}
} else if (typeof error === "string") {
errorMessage = error;
}
}
// Update assistant message with error
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1297,14 +1171,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: item
));
setIsStreaming(false);
return; // Exit stream processing on failure
return;
}
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals (for popup)
// Add to pending approvals
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -1312,46 +1186,17 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
function_call: approvalEvent.function_call,
},
]);
// Also add to chat UI to show function call progress
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
return {
...item,
content: [
...item.content,
{
type: "function_approval_request",
request_id: approvalEvent.request_id,
status: "pending",
function_call: approvalEvent.function_call,
} as import("@/types/openai").MessageFunctionApprovalRequestContent,
],
status: "in_progress" as const,
};
}
return item;
}));
continue;
continue; // Don't add approval requests to chat UI
}
// Handle function result events (after function execution)
if (openAIEvent.type === "response.function_result.complete") {
const resultEvent = openAIEvent as import("@/types/openai").ResponseFunctionResultComplete;
// Handle function approval response events
if (openAIEvent.type === "response.function_approval.responded") {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Add function result as a separate conversation item for clear visibility
const functionResultItem: import("@/types/openai").ConversationFunctionCallOutput = {
id: `result-${Date.now()}`,
type: "function_call_output",
call_id: resultEvent.call_id,
output: resultEvent.output,
status: resultEvent.status === "completed" ? "completed" : "incomplete",
created_at: Math.floor(Date.now() / 1000),
};
const currentItems = useDevUIStore.getState().chatItems;
setChatItems([...currentItems, functionResultItem]);
// Remove from pending approvals
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
continue;
}
@@ -1382,57 +1227,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
return; // Exit stream processing early on error
}
// Handle output item added events (images, files, data)
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
// Add output items to assistant message content
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((chatItem) => {
if (chatItem.id === assistantMessage.id && chatItem.type === "message") {
const existingContent = chatItem.content;
let newContent: import("@/types/openai").MessageContent | null = null;
// Map output items to message content
if (item.type === "output_image") {
newContent = {
type: "output_image",
image_url: item.image_url,
alt_text: item.alt_text,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputImage;
} else if (item.type === "output_file") {
newContent = {
type: "output_file",
filename: item.filename,
file_url: item.file_url,
file_data: item.file_data,
mime_type: item.mime_type,
} as import("@/types/openai").MessageOutputFile;
} else if (item.type === "output_data") {
newContent = {
type: "output_data",
data: item.data,
mime_type: item.mime_type,
description: item.description,
} as import("@/types/openai").MessageOutputData;
}
// If we created new content, append it
if (newContent) {
return {
...chatItem,
content: [...existingContent, newContent],
status: "in_progress" as const,
};
}
}
return chatItem;
}));
continue; // Continue to next event
}
// Handle text delta events for chat
if (
openAIEvent.type === "response.output_text.delta" &&
@@ -1442,26 +1236,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
}
// Handle completion/error by detecting when streaming stops
@@ -1646,42 +1435,19 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{oaiMode.enabled
? `Chat with ${oaiMode.model}`
: `Chat with ${selectedAgent.name || selectedAgent.id}`
}
Chat with {selectedAgent.name || selectedAgent.id}
</span>
</div>
</h2>
{!oaiMode.enabled && uiMode === "developer" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleReloadEntity}
disabled={isReloading || selectedAgent.metadata?.source === "in_memory"}
className="h-6 w-6 p-0 flex-shrink-0"
title={
selectedAgent.metadata?.source === "in_memory"
? "In-memory entities cannot be reloaded"
: isReloading
? "Reloading..."
: "Reload entity code (hot reload)"
}
>
<RefreshCw className={`h-4 w-4 ${isReloading ? "animate-spin" : ""}`} />
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setDetailsModalOpen(true)}
className="h-6 w-6 p-0 flex-shrink-0"
title="View agent details"
>
<Info className="h-4 w-4" />
</Button>
</div>
{/* Conversation Controls */}
@@ -1773,46 +1539,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
{oaiMode.enabled ? (
{selectedAgent.description && (
<p className="text-sm text-muted-foreground">
Using OpenAI model directly. Local agent tools and instructions are not applied.
{selectedAgent.description}
</p>
) : (
selectedAgent.description && (
<p className="text-sm text-muted-foreground">
{selectedAgent.description}
</p>
)
)}
</div>
{/* Error Banner */}
{conversationError && (
<div className="mx-4 mt-2 p-3 bg-destructive/10 border border-destructive/30 rounded-md flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-destructive">
Failed to Create Conversation
</div>
<div className="text-xs text-destructive/90 mt-1 break-words">
{conversationError.message}
</div>
{conversationError.code && (
<div className="text-xs text-destructive/70 mt-1">
Error Code: {conversationError.code}
</div>
)}
</div>
<button
onClick={() => setConversationError(null)}
className="text-destructive hover:text-destructive/80 flex-shrink-0"
title="Dismiss error"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
@@ -11,9 +11,6 @@ import {
ChevronDown,
ChevronRight,
Music,
Check,
X,
Clock,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
@@ -40,12 +37,12 @@ function TextContentRenderer({ content, className, isStreaming }: ContentRendere
);
}
// Image content renderer (handles both input and output images)
// Image content renderer
function ImageContentRenderer({ content, className }: ContentRendererProps) {
const [imageError, setImageError] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "input_image" && content.type !== "output_image") return null;
if (content.type !== "input_image") return null;
const imageUrl = content.image_url;
@@ -80,9 +77,9 @@ function ImageContentRenderer({ content, className }: ContentRendererProps) {
);
}
// File content renderer (handles both input and output files)
// File content renderer
function FileContentRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "input_file" && content.type !== "output_file") return null;
if (content.type !== "input_file") return null;
const fileUrl = content.file_url || content.file_data;
const filename = content.filename || "file";
@@ -159,129 +156,6 @@ function FileContentRenderer({ content, className }: ContentRendererProps) {
);
}
// Data content renderer (for generic structured data outputs)
function DataContentRenderer({ content, className }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "output_data") return null;
const data = content.data;
const mimeType = content.mime_type;
const description = content.description;
// Try to parse as JSON for pretty printing
let displayData = data;
try {
const parsed = JSON.parse(data);
displayData = JSON.stringify(parsed, null, 2);
} catch {
// Not JSON, display as-is
}
return (
<div className={`my-2 p-3 border rounded-lg bg-muted ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{description || "Data Output"}
</span>
<span className="text-xs text-muted-foreground ml-auto">{mimeType}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</div>
{isExpanded && (
<pre className="mt-2 text-xs overflow-auto max-h-64 bg-background p-2 rounded border font-mono">
{displayData}
</pre>
)}
</div>
);
}
// Function approval request renderer
function FunctionApprovalRequestRenderer({ content, className }: ContentRendererProps) {
if (content.type !== "function_approval_request") return null;
const [isExpanded, setIsExpanded] = useState(false);
const { status, function_call } = content;
// Status styling
const statusConfig = {
pending: {
icon: Clock,
color: "amber",
label: "Awaiting Approval",
bgClass: "bg-amber-50 dark:bg-amber-950/20",
borderClass: "border-amber-200 dark:border-amber-800",
iconClass: "text-amber-600 dark:text-amber-400",
textClass: "text-amber-800 dark:text-amber-300",
},
approved: {
icon: Check,
color: "green",
label: "Approved",
bgClass: "bg-green-50 dark:bg-green-950/20",
borderClass: "border-green-200 dark:border-green-800",
iconClass: "text-green-600 dark:text-green-400",
textClass: "text-green-800 dark:text-green-300",
},
rejected: {
icon: X,
color: "red",
label: "Rejected",
bgClass: "bg-red-50 dark:bg-red-950/20",
borderClass: "border-red-200 dark:border-red-800",
iconClass: "text-red-600 dark:text-red-400",
textClass: "text-red-800 dark:text-red-300",
},
};
const config = statusConfig[status];
const StatusIcon = config.icon;
let parsedArgs;
try {
parsedArgs = typeof function_call.arguments === "string"
? JSON.parse(function_call.arguments)
: function_call.arguments;
} catch {
parsedArgs = function_call.arguments;
}
return (
<div className={`my-2 p-3 border rounded ${config.bgClass} ${config.borderClass} ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<StatusIcon className={`h-4 w-4 ${config.iconClass}`} />
<span className={`text-sm font-medium ${config.textClass}`}>
{config.label}: {function_call.name}
</span>
{isExpanded ? (
<ChevronDown className={`h-4 w-4 ${config.iconClass} ml-auto`} />
) : (
<ChevronRight className={`h-4 w-4 ${config.iconClass} ml-auto`} />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
<div className={`${config.textClass} mb-1`}>Arguments:</div>
<pre className="whitespace-pre-wrap">
{JSON.stringify(parsedArgs, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Main content renderer that delegates to specific renderers
export function OpenAIContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
switch (content.type) {
@@ -290,15 +164,9 @@ export function OpenAIContentRenderer({ content, className, isStreaming }: Conte
case "output_text":
return <TextContentRenderer content={content} className={className} isStreaming={isStreaming} />;
case "input_image":
case "output_image":
return <ImageContentRenderer content={content} className={className} />;
case "input_file":
case "output_file":
return <FileContentRenderer content={content} className={className} />;
case "output_data":
return <DataContentRenderer content={content} className={className} />;
case "function_approval_request":
return <FunctionApprovalRequestRenderer content={content} className={className} />;
default:
return null;
}
@@ -1,528 +0,0 @@
/**
* ExecutionTimeline - Vertical timeline showing workflow executor runs
* Features: Chronological executor execution, expandable output, bidirectional graph highlighting
*/
import { useState, useEffect, useMemo, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Loader2,
CheckCircle,
XCircle,
AlertCircle,
ChevronDown,
ChevronRight,
Copy,
Check,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
import type { ExecutorState } from "./executor-node";
import { truncateText } from "@/utils/workflow-utils";
interface ExecutorRun {
executorId: string;
executorName: string;
itemId: string; // Unique ID for this specific run
state: ExecutorState;
output: string;
error?: string;
timestamp: number;
runNumber: number; // For multiple runs of same executor
}
interface ExecutionTimelineProps {
events: ExtendedResponseStreamEvent[];
itemOutputs: Record<string, string>;
currentExecutorId: string | null;
isStreaming: boolean;
onExecutorClick?: (executorId: string) => void;
selectedExecutorId?: string | null;
workflowResult?: string;
}
function getStateIcon(state: ExecutorState) {
switch (state) {
case "running":
return <Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin" />;
case "completed":
return <CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />;
case "failed":
return <XCircle className="w-4 h-4 text-red-500 dark:text-red-400" />;
case "cancelled":
return <AlertCircle className="w-4 h-4 text-orange-500 dark:text-orange-400" />;
default:
return <div className="w-4 h-4 rounded-full border-2 border-gray-400 dark:border-gray-500" />;
}
}
function getStateBadgeClass(state: ExecutorState) {
switch (state) {
case "running":
return "bg-[#643FB2]/10 text-[#643FB2] dark:bg-[#8B5CF6]/10 dark:text-[#8B5CF6] border-[#643FB2]/20 dark:border-[#8B5CF6]/20";
case "completed":
return "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20";
case "failed":
return "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20";
case "cancelled":
return "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20";
default:
return "bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20";
}
}
function ExecutorRunItem({
run,
isExpanded,
onToggle,
onClick,
isSelected,
}: {
run: ExecutorRun;
isExpanded: boolean;
onToggle: () => void;
onClick: () => void;
isSelected: boolean;
}) {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const hasOutput = run.output.trim().length > 0;
const canExpand = hasOutput || run.error;
const outputRef = useRef<HTMLPreElement>(null);
// Auto-scroll output to bottom when content changes (during streaming)
useEffect(() => {
if (isExpanded && run.state === "running" && outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [run.output, isExpanded, run.state]);
return (
<div
className={`border rounded-lg transition-all ${
isSelected
? "border-blue-500 dark:border-blue-400 bg-blue-500/5 dark:bg-blue-500/10"
: "border-border hover:border-muted-foreground/30"
}`}
>
{/* Header - Always Visible */}
<div
className="p-3 cursor-pointer"
onClick={() => {
onClick();
if (canExpand) onToggle();
}}
>
<div className="grid grid-cols-[auto_auto_1fr_auto] items-center gap-2 mb-1">
<div className="w-3 text-muted-foreground">
{canExpand && (
<>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</>
)}
</div>
<div>{getStateIcon(run.state)}</div>
<span className="font-medium text-sm truncate overflow-hidden">
{run.executorName}
</span>
{run.runNumber > 1 ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
Run #{run.runNumber}
</Badge>
) : (
<div></div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground ml-5">
<span className="font-mono">{timestamp}</span>
<Badge
variant="outline"
className={`text-xs border ${getStateBadgeClass(run.state)}`}
>
{run.state}
</Badge>
</div>
</div>
{/* Expandable Content */}
{isExpanded && canExpand && (
<div className="border-t px-3 py-2 bg-muted/30">
{run.error ? (
<div className="space-y-1">
<div className="text-xs font-medium text-red-600 dark:text-red-400">
Error:
</div>
<pre className="text-xs bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded p-2 overflow-y-auto overflow-x-hidden max-h-40 whitespace-pre-wrap break-all">
{run.error}
</pre>
</div>
) : (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Output:
</div>
<pre
ref={outputRef}
className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all"
>
{run.output}
</pre>
</div>
)}
</div>
)}
</div>
);
}
export function ExecutionTimeline({
events,
itemOutputs,
currentExecutorId,
isStreaming,
onExecutorClick,
selectedExecutorId,
workflowResult,
}: ExecutionTimelineProps) {
const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
const [updateTrigger, setUpdateTrigger] = useState(0);
const [copied, setCopied] = useState(false);
const lastScrolledRunRef = useRef<string | null>(null);
const timelineEndRef = useRef<HTMLDivElement>(null);
// Force re-render when streaming to show updated outputs from itemOutputs ref
// Note: itemOutputs is a ref (not state), so changes don't trigger re-renders automatically.
// This polling approach ensures the UI updates during streaming. Could be optimized by:
// 1. Converting itemOutputs to state (increases re-renders)
// 2. Using requestAnimationFrame instead of setInterval
// 3. Having parent component trigger updates via callback
useEffect(() => {
if (isStreaming) {
const interval = setInterval(() => {
setUpdateTrigger((prev) => prev + 1);
}, 100); // Update 10 times per second during streaming
return () => clearInterval(interval);
}
}, [isStreaming]);
// Process events to extract executor runs - memoized to prevent recalculation
const { executorRuns, executorRunCount } = useMemo(() => {
const runs: ExecutorRun[] = [];
const runCount = new Map<string, number>();
events.forEach((event) => {
// Extract UI timestamp (captured when event arrived, won't change on re-render)
const uiTimestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? event._uiTimestamp * 1000
: Date.now();
// Handle new standard OpenAI events
if (event.type === "response.output_item.added") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; created_at?: number; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const executorId = item.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message items from Magentic agents
const executorId = item.metadata.agent_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: "running",
output: itemOutputs[itemId] || "",
timestamp: uiTimestamp,
runNumber,
});
}
}
// Handle completion events
if (event.type === "response.output_item.done") {
const item = (event as { item?: { type?: string; executor_id?: string; id?: string; status?: string; error?: string; metadata?: any } }).item;
// Handle both executor_action items AND message items from Magentic agents
if (item && item.type === "executor_action" && item.executor_id && item.id) {
const itemId = item.id;
// Find the run by ITEM ID (not executor ID!) to handle multiple runs correctly
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state =
item.status === "completed"
? "completed"
: item.status === "failed"
? "failed"
: "completed";
// Use item-specific output, not executor-wide output
existingRun.output = itemOutputs[itemId] || "";
if (item.status === "failed" && item.error) {
existingRun.error = item.error;
}
}
} else if (item && item.type === "message" && item.metadata?.agent_id && item.metadata?.source === "magentic" && item.id) {
// Handle message completion from Magentic agents
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
}
}
// Fallback support for workflow_event format (used for unhandled event types and status/warning/error events)
if (
event.type === "response.workflow_event.completed" &&
"data" in event &&
event.data
) {
const data = event.data as { executor_id?: string; event_type?: string; data?: unknown; timestamp?: string };
const executorId = data.executor_id;
if (!executorId) return;
const eventType = data.event_type;
if (eventType === "ExecutorInvokedEvent") {
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
// Create synthetic item ID for fallback format (no real item.id from backend)
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId: syntheticItemId,
state: "running",
output: itemOutputs[syntheticItemId] || "",
timestamp: uiTimestamp,
runNumber,
});
} else if (eventType === "ExecutorCompletedEvent") {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "completed";
existingRun.output = itemOutputs[existingRun.itemId] || "";
}
} else if (
eventType?.includes("Error") ||
eventType?.includes("Failed")
) {
// Find the most recent running instance of this executor (search from end)
let existingRun: ExecutorRun | undefined;
for (let i = runs.length - 1; i >= 0; i--) {
if (runs[i].executorId === executorId && runs[i].state === "running") {
existingRun = runs[i];
break;
}
}
if (existingRun) {
existingRun.state = "failed";
existingRun.error =
typeof data.data === "string" ? data.data : "Execution failed";
}
}
}
});
// Update outputs for running executors using item-specific outputs
// This ensures each run gets its own output, even for multiple runs of the same executor
runs.forEach((run) => {
if (run.state === "running" && itemOutputs[run.itemId]) {
run.output = itemOutputs[run.itemId];
}
});
return { executorRuns: runs, executorRunCount: runCount };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [events, itemOutputs, updateTrigger]);
// Auto-expand running executors
useEffect(() => {
if (currentExecutorId) {
setExpandedRuns((prev) => {
const next = new Set(prev);
next.add(`${currentExecutorId}-${executorRunCount.get(currentExecutorId) || 1}`);
return next;
});
}
}, [currentExecutorId, executorRunCount]);
// Auto-scroll to newest executor when it appears or changes
useEffect(() => {
if (executorRuns.length > 0 && isStreaming) {
const latestRun = executorRuns[executorRuns.length - 1];
const latestRunKey = `${latestRun.executorId}-${latestRun.runNumber}`;
// Only scroll if this is a new run we haven't scrolled to yet
if (latestRunKey !== lastScrolledRunRef.current) {
lastScrolledRunRef.current = latestRunKey;
// Scroll to the end of the timeline
if (timelineEndRef.current) {
timelineEndRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}
}
}, [executorRuns, isStreaming]);
// Auto-scroll to show workflow result when it appears (after streaming completes)
useEffect(() => {
if (workflowResult && !isStreaming && timelineEndRef.current) {
// Small delay to ensure the result card is rendered before scrolling
setTimeout(() => {
timelineEndRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}, 100);
}
}, [workflowResult, isStreaming]);
const handleCopyAll = () => {
const text = executorRuns
.map((run) => {
const timestamp = new Date(run.timestamp).toLocaleTimeString();
const header = `[${timestamp}] ${run.executorName} (${run.state})`;
const content = run.error || run.output || "(no output)";
return `${header}\n${content}\n`;
})
.join("\n");
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="h-full flex flex-col border-l bg-muted/30">
{/* Header */}
<div className="p-3 border-b bg-background flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">Execution Timeline</span>
<Badge variant="outline" className="text-xs">
{executorRuns.length}
</Badge>
{isStreaming && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<div className="h-2 w-2 animate-pulse rounded-full bg-[#643FB2] dark:bg-[#8B5CF6]" />
<span>Running</span>
</div>
)}
</div>
{executorRuns.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleCopyAll}
className={`h-7 px-2 text-xs ${copied ? "text-green-600 dark:text-green-400" : ""}`}
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy All
</>
)}
</Button>
)}
</div>
{/* Timeline Content */}
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{executorRuns.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
No executor runs yet. Start the workflow to see execution timeline.
</div>
) : (
executorRuns.map((run, index) => {
const runKey = `${run.executorId}-${run.runNumber}`;
return (
<ExecutorRunItem
key={`${runKey}-${index}`}
run={run}
isExpanded={expandedRuns.has(runKey)}
onToggle={() => {
setExpandedRuns((prev) => {
const next = new Set(prev);
if (next.has(runKey)) {
next.delete(runKey);
} else {
next.add(runKey);
}
return next;
});
}}
onClick={() => onExecutorClick?.(run.executorId)}
isSelected={selectedExecutorId === run.executorId}
/>
);
})
)}
{/* Workflow final output card */}
{workflowResult && workflowResult.trim().length > 0 && !isStreaming && (
<div className="border rounded-lg border-green-500/40 bg-green-500/5 dark:bg-green-500/10">
<div className="p-3 bg-green-500/10 border-b border-green-500/20">
<div className="flex items-center gap-2 mb-1">
<CheckCircle className="w-4 h-4 text-green-500 dark:text-green-400" />
<span className="font-medium text-sm">Workflow Complete</span>
</div>
</div>
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">
Final Output:
</div>
<pre className="text-xs bg-background border rounded p-2 overflow-y-auto overflow-x-hidden max-h-60 whitespace-pre-wrap break-all">
{workflowResult}
</pre>
</div>
</div>
</div>
)}
{/* Invisible element at the end for scroll target */}
<div ref={timelineEndRef} />
</div>
</ScrollArea>
</div>
);
}
@@ -3,10 +3,8 @@ import { Handle, Position, type NodeProps } from "@xyflow/react";
import {
Workflow,
Home,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { truncateText } from "@/utils/workflow-utils";
export type ExecutorState =
| "pending"
@@ -83,13 +81,11 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
const details = [];
if (nodeData.error && typeof nodeData.error === "string") {
// Truncate error to first 150 characters for node display
const truncatedError = truncateText(nodeData.error, 150);
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 break-words">
{truncatedError}
<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>
);
@@ -159,32 +155,34 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
isRunning ? config.glow : "shadow-sm",
)}
>
{/* Small circular handles - always render both to support any edge configuration */}
<Handle
type="target"
position={targetPosition}
id="target"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
{/* Small circular handles */}
{!nodeData.isStartNode && (
<Handle
type="target"
position={targetPosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<Handle
type="source"
position={sourcePosition}
id="source"
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
{!nodeData.isEndNode && (
<Handle
type="source"
position={sourcePosition}
className="!w-2 !h-2 !rounded-full !border !border-gray-600 dark:!border-gray-500 transition-colors !min-w-0 !min-h-0"
style={{
backgroundColor: nodeData.state === "running" ? "#643FB2" :
nodeData.state === "completed" ? "#10b981" :
nodeData.state === "failed" ? "#ef4444" :
nodeData.state === "cancelled" ? "#f97316" : "#4b5563"
}}
/>
)}
<div className="p-3">
{/* Header with icon and title */}
@@ -198,16 +196,18 @@ export const ExecutorNode = memo(({ data, selected }: NodeProps) => {
<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">
<div className="flex items-center gap-1.5">
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{isRunning && (
<Loader2 className="w-4 h-4 text-[#643FB2] dark:text-[#8B5CF6] animate-spin flex-shrink-0" />
)}
</div>
<h3 className="font-medium text-sm text-gray-900 dark:text-gray-100 truncate">
{nodeData.name || nodeData.executorId}
</h3>
{nodeData.executorType && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate mt-0.5">
{nodeData.executorType}
@@ -1,150 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MessageCircle, Send, Loader2 } from "lucide-react";
import { SchemaFormRenderer, validateSchemaForm } from "./schema-form-renderer";
import type { JSONSchemaProperty } from "@/types";
interface HilRequest {
request_id: string;
request_data: Record<string, unknown>;
request_schema: JSONSchemaProperty;
}
interface HilInputModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: HilRequest[];
responses: Record<string, Record<string, unknown>>;
onResponseChange: (requestId: string, values: Record<string, unknown>) => void;
onSubmit: () => void;
isSubmitting: boolean;
}
export function HilInputModal({
open,
onOpenChange,
requests,
responses,
onResponseChange,
onSubmit,
isSubmitting,
}: HilInputModalProps) {
// Check if all required fields are filled
const areAllRequiredFieldsFilled = () => {
return requests.every((req) => {
const response = responses[req.request_id] || {};
return validateSchemaForm(req.request_schema, response);
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<MessageCircle className="w-5 h-5" />
Workflow Requires Input ({requests.length} request
{requests.length > 1 ? "s" : ""})
</DialogTitle>
<DialogDescription>
The workflow is paused and needs your input to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{requests.map((req, index) => (
<Card key={req.request_id}>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
Request {index + 1}
<Badge variant="outline" className="ml-2 font-mono text-xs">
{req.request_id.slice(0, 8)}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{/* Show request data as readonly context */}
{Object.keys(req.request_data).length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-md max-h-48 overflow-y-auto">
<p className="text-xs font-medium text-muted-foreground mb-2">
Request Context:
</p>
<div className="space-y-1">
{Object.entries(req.request_data)
.filter(([key]) => !["request_id", "source_executor_id"].includes(key))
.map(([key, value]) => (
<div key={key} className="text-xs">
<span className="font-medium">{key}:</span>{" "}
<span className="text-muted-foreground break-all">
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</span>
</div>
))}
</div>
</div>
)}
{/* Show expected response hint if available */}
{req.request_schema?.description && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800 rounded-md">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100 mb-1">
Expected Response:
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
{req.request_schema.description}
</p>
</div>
)}
{/* Use schema-based form renderer for RESPONSE (not request) */}
<SchemaFormRenderer
schema={req.request_schema}
values={responses[req.request_id] || {}}
onChange={(values) => onResponseChange(req.request_id, values)}
disabled={isSubmitting}
/>
</CardContent>
</Card>
))}
</div>
<DialogFooter>
<div className="flex gap-2 w-full justify-end">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={onSubmit}
disabled={isSubmitting || !areAllRequiredFieldsFilled()}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin mr-2" />
Submitting...
</>
) : (
<>
<Send className="w-4 h-4 mr-2" />
Submit & Continue
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -7,5 +7,3 @@ export { WorkflowDetailsModal } from "./workflow-details-modal";
export { WorkflowFlow } from "./workflow-flow";
export { WorkflowInputForm } from "./workflow-input-form";
export { ExecutorNode } from "./executor-node";
export { SchemaFormRenderer, validateSchemaForm, filterEmptyOptionalFields } from "./schema-form-renderer";
export { HilInputModal } from "./hil-input-modal";
@@ -1,546 +0,0 @@
import { useState } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { JSONSchemaProperty } from "@/types";
// ============================================================================
// Field Type Detection (from WorkflowInputForm)
// ============================================================================
function isShortField(fieldName: string): boolean {
const shortFieldNames = [
"name",
"title",
"id",
"key",
"label",
"type",
"status",
"tag",
"category",
"code",
"username",
"password",
"email",
];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function shouldFieldBeTextarea(
fieldName: string,
schema: JSONSchemaProperty
): boolean {
return (
schema.format === "textarea" ||
(!!schema.description && schema.description.length > 100) ||
(schema.type === "string" && !schema.enum && !isShortField(fieldName))
);
}
function getFieldColumnSpan(
fieldName: string,
schema: JSONSchemaProperty
): string {
const isTextarea = shouldFieldBeTextarea(fieldName, schema);
const hasLongDescription =
!!schema.description && schema.description.length > 150;
if (isTextarea || hasLongDescription) {
return "md:col-span-2 lg:col-span-3 xl:col-span-4";
}
if (
schema.type === "array" ||
(!!schema.description && schema.description.length > 80)
) {
return "xl:col-span-2";
}
return "";
}
// ============================================================================
// ChatMessage Pattern Detection (from WorkflowInputForm)
// ============================================================================
function detectChatMessagePattern(
schema: JSONSchemaProperty,
requiredFields: string[]
): boolean {
if (schema.type !== "object" || !schema.properties) return false;
const properties = schema.properties;
const optionalFields = Object.keys(properties).filter(
(name) => !requiredFields.includes(name)
);
return (
requiredFields.includes("role") &&
optionalFields.some((f) => ["text", "message", "content"].includes(f)) &&
properties["role"]?.type === "string"
);
}
// ============================================================================
// Form Field Component (from WorkflowInputForm)
// ============================================================================
interface FormFieldProps {
name: string;
schema: JSONSchemaProperty;
value: unknown;
onChange: (value: unknown) => void;
isRequired?: boolean;
isReadOnly?: boolean; // NEW: for HIL display-only fields
}
function FormField({
name,
schema,
value,
onChange,
isRequired = false,
isReadOnly = false,
}: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
const isTextarea = shouldFieldBeTextarea(name, schema);
const renderInput = () => {
// Read-only display (for HIL request context)
if (isReadOnly) {
return (
<div className="space-y-2">
<Label htmlFor={name} className="text-muted-foreground">
{name}
</Label>
<div className="text-sm p-2 bg-muted rounded border">
{typeof value === "object"
? JSON.stringify(value, null, 2)
: String(value)}
</div>
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
</div>
);
}
switch (type) {
case "string":
if (enumValues) {
// Enum select
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Select
value={
typeof value === "string" && value
? value
: typeof defaultValue === "string"
? defaultValue
: enumValues[0]
}
onValueChange={(val) => onChange(val)}
>
<SelectTrigger>
<SelectValue placeholder={`Select ${name}`} />
</SelectTrigger>
<SelectContent>
{enumValues.map((option: string) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else if (isTextarea) {
// Multi-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
rows={4}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
} else {
// Single-line text
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="text"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
placeholder={
typeof defaultValue === "string"
? defaultValue
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
case "integer":
case "number":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Input
id={name}
type="number"
step={type === "integer" ? "1" : "any"}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const val =
type === "integer"
? parseInt(e.target.value)
: parseFloat(e.target.value);
onChange(isNaN(val) ? "" : val);
}}
placeholder={
typeof defaultValue === "number"
? defaultValue.toString()
: `Enter ${name}`
}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "boolean":
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Checkbox
id={name}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked)}
/>
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "array":
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
Array.isArray(value)
? value.join(", ")
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
const arrayValue = e.target.value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
onChange(arrayValue);
}}
placeholder="Enter items separated by commas"
rows={2}
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
case "object":
default:
return (
<div className="space-y-2">
<Label htmlFor={name}>
{name}
{isRequired && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id={name}
value={
typeof value === "object" && value !== null
? JSON.stringify(value, null, 2)
: typeof value === "string"
? value
: ""
}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
onChange(parsed);
} catch {
onChange(e.target.value);
}
}}
placeholder='{"key": "value"}'
rows={3}
className="font-mono text-xs"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
</div>
);
}
};
return <div className={getFieldColumnSpan(name, schema)}>{renderInput()}</div>;
}
// ============================================================================
// Main Schema Form Renderer Component
// ============================================================================
export interface SchemaFormRendererProps {
schema: JSONSchemaProperty;
values: Record<string, unknown>;
onChange: (values: Record<string, unknown>) => void;
disabled?: boolean;
readOnlyFields?: string[]; // NEW: Fields to display but not edit (for HIL)
hideFields?: string[]; // NEW: Fields to completely hide
showCollapsedByDefault?: boolean; // NEW: Control initial collapsed state
}
export function SchemaFormRenderer({
schema,
values,
onChange,
disabled = false,
readOnlyFields = [],
hideFields = [],
showCollapsedByDefault = false,
}: SchemaFormRendererProps) {
const [showAdvancedFields, setShowAdvancedFields] = useState(
showCollapsedByDefault
);
const properties = schema.properties || {};
const allFieldNames = Object.keys(properties).filter(
(name) => !hideFields.includes(name)
);
const requiredFields = (schema.required || []).filter(
(name) => !hideFields.includes(name)
);
// Detect ChatMessage pattern
const isChatMessageLike = detectChatMessagePattern(schema, requiredFields);
// Separate required and optional fields
const requiredFieldNames = allFieldNames.filter(
(name) =>
requiredFields.includes(name) && !(isChatMessageLike && name === "role")
);
const optionalFieldNames = allFieldNames.filter(
(name) => !requiredFields.includes(name)
);
// For ChatMessage: prioritize text/message/content
const sortedOptionalFields = isChatMessageLike
? [...optionalFieldNames].sort((a, b) => {
const priority = (name: string) =>
["text", "message", "content"].includes(name) ? 1 : 0;
return priority(b) - priority(a);
})
: optionalFieldNames;
// Show minimum visible fields
const MIN_VISIBLE_FIELDS = isChatMessageLike ? 1 : 6;
const visibleOptionalCount = Math.max(
0,
MIN_VISIBLE_FIELDS - requiredFieldNames.length
);
const visibleOptionalFields = sortedOptionalFields.slice(
0,
visibleOptionalCount
);
const collapsedOptionalFields = sortedOptionalFields.slice(
visibleOptionalCount
);
const hasCollapsedFields = collapsedOptionalFields.length > 0;
const hasRequiredFields = requiredFieldNames.length > 0;
const updateField = (fieldName: string, value: unknown) => {
onChange({
...values,
[fieldName]: value,
});
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 md:gap-6">
{/* Required fields section */}
{requiredFieldNames.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={true}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Separator between required and optional */}
{hasRequiredFields && optionalFieldNames.length > 0 && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<div className="border-t border-border"></div>
</div>
)}
{/* Visible optional fields */}
{visibleOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
{/* Collapsed optional fields toggle */}
{hasCollapsedFields && (
<div className="md:col-span-2 lg:col-span-3 xl:col-span-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowAdvancedFields(!showAdvancedFields)}
className="w-full justify-center gap-2"
disabled={disabled}
>
{showAdvancedFields ? (
<>
<ChevronUp className="h-4 w-4" />
Hide {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
) : (
<>
<ChevronDown className="h-4 w-4" />
Show {collapsedOptionalFields.length} optional field
{collapsedOptionalFields.length !== 1 ? "s" : ""}
</>
)}
</Button>
</div>
)}
{/* Collapsed optional fields */}
{showAdvancedFields &&
collapsedOptionalFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
schema={properties[fieldName] as JSONSchemaProperty}
value={values[fieldName]}
onChange={(value) => updateField(fieldName, value)}
isRequired={false}
isReadOnly={disabled || readOnlyFields.includes(fieldName)}
/>
))}
</div>
);
}
// ============================================================================
// Export helper functions for validation
// ============================================================================
export function validateSchemaForm(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): boolean {
const requiredFields = schema.required || [];
return requiredFields.every((fieldName) => {
const value = values[fieldName];
return value !== undefined && value !== "" && value !== null;
});
}
export function filterEmptyOptionalFields(
schema: JSONSchemaProperty,
values: Record<string, unknown>
): Record<string, unknown> {
const requiredFields = schema.required || [];
const filtered: Record<string, unknown> = {};
Object.keys(values).forEach((key) => {
const value = values[key];
// Include if: 1) required field, OR 2) has non-empty value
if (
requiredFields.includes(key) ||
(value !== undefined && value !== "" && value !== null)
) {
filtered[key] = value;
}
});
return filtered;
}
@@ -8,7 +8,6 @@ import {
Shuffle,
Zap,
ArrowDown,
ArrowLeftRight,
} from "lucide-react";
import {
DropdownMenu,
@@ -40,7 +39,6 @@ import {
processWorkflowEvents,
updateNodesWithEvents,
updateEdgesWithSequenceAnalysis,
consolidateBidirectionalEdges,
type NodeUpdate,
} from "@/utils/workflow-utils";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -61,7 +59,7 @@ function ViewOptionsPanel({
}: {
workflowDump?: Workflow;
onNodeSelect?: (executorId: string, data: ExecutorNodeData) => void;
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean; consolidateBidirectionalEdges: boolean };
viewOptions: { showMinimap: boolean; showGrid: boolean; animateRun: boolean };
onToggleViewOption?: (key: keyof typeof viewOptions) => void;
layoutDirection: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
@@ -136,16 +134,6 @@ function ViewOptionsPanel({
</div>
<Checkbox checked={viewOptions.animateRun} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuItem
className="flex items-center justify-between"
onClick={() => onToggleViewOption?.("consolidateBidirectionalEdges")}
>
<div className="flex items-center">
<ArrowLeftRight className="mr-2 h-4 w-4" />
Merge Bidirectional Edges
</div>
<Checkbox checked={viewOptions.consolidateBidirectionalEdges} onChange={() => {}} />
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center justify-between"
@@ -204,14 +192,12 @@ interface WorkflowFlowProps {
showMinimap: boolean;
showGrid: boolean;
animateRun: boolean;
consolidateBidirectionalEdges: boolean;
};
onToggleViewOption?: (
key: keyof NonNullable<WorkflowFlowProps["viewOptions"]>
) => void;
layoutDirection?: "LR" | "TB";
onLayoutDirectionChange?: (direction: "LR" | "TB") => void;
timelineVisible?: boolean;
}
// Animation handler component that runs inside ReactFlow context
@@ -262,35 +248,16 @@ function WorkflowAnimationHandler({
return null; // This component doesn't render anything
}
// Timeline resize handler component that runs inside ReactFlow context
const TimelineResizeHandler = memo(({ timelineVisible }: { timelineVisible: boolean }) => {
const { fitView } = useReactFlow();
// Trigger fitView when timeline visibility changes to adjust ReactFlow viewport
useEffect(() => {
// Delay fitView to let CSS transition complete (timeline animation is 300ms)
const timeoutId = setTimeout(() => {
fitView({ padding: 0.2, duration: 300 });
}, 350); // Slightly longer than timeline animation duration
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timelineVisible]); // Only trigger when timelineVisible changes, not fitView reference
return null; // This component doesn't render anything
});
export const WorkflowFlow = memo(function WorkflowFlow({
workflowDump,
events,
isStreaming,
onNodeSelect,
className = "",
viewOptions = { showMinimap: false, showGrid: true, animateRun: true, consolidateBidirectionalEdges: true },
viewOptions = { showMinimap: false, showGrid: true, animateRun: true },
onToggleViewOption,
layoutDirection = "LR",
onLayoutDirectionChange,
timelineVisible = false,
}: WorkflowFlowProps) {
// Create initial nodes and edges from workflow dump
const { initialNodes, initialEdges } = useMemo(() => {
@@ -305,22 +272,17 @@ export const WorkflowFlow = memo(function WorkflowFlow({
);
const edges = convertWorkflowDumpToEdges(workflowDump);
// Apply bidirectional edge consolidation if enabled
const finalEdges = viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(edges)
: edges;
// Apply auto-layout if we have nodes and edges
const layoutedNodes =
nodes.length > 0
? applyDagreLayout(nodes, finalEdges, layoutDirection)
? applyDagreLayout(nodes, edges, layoutDirection)
: nodes;
return {
initialNodes: layoutedNodes,
initialEdges: finalEdges,
initialEdges: edges,
};
}, [workflowDump, onNodeSelect, layoutDirection, viewOptions.consolidateBidirectionalEdges]);
}, [workflowDump, onNodeSelect, layoutDirection]);
const [nodes, setNodes, onNodesChange] =
useNodesState<Node<ExecutorNodeData>>(initialNodes);
@@ -361,38 +323,31 @@ export const WorkflowFlow = memo(function WorkflowFlow({
currentEdges,
events
);
// Apply consolidation if enabled (preserves updated styling from sequence analysis)
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(updatedEdges)
: updatedEdges;
return updatedEdges;
});
} else {
// Reset all edges to default state when events are cleared
setEdges((currentEdges) => {
const resetEdges = currentEdges.map((edge) => ({
setEdges((currentEdges) =>
currentEdges.map((edge) => ({
...edge,
animated: false,
style: {
stroke: "#6b7280", // Gray
strokeWidth: 2,
},
}));
// Apply consolidation if enabled
return viewOptions.consolidateBidirectionalEdges
? consolidateBidirectionalEdges(resetEdges)
: resetEdges;
});
}))
);
}
}, [events, setEdges, viewOptions.consolidateBidirectionalEdges]);
}, [events, setEdges]);
// Initialize nodes and edges when workflow structure OR consolidation setting changes
// 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, viewOptions.consolidateBidirectionalEdges]); // Re-initialize when workflow or consolidation toggle changes
}, [workflowDump]); // Only re-initialize when workflowDump changes
const onNodeClick = useCallback(
(event: React.MouseEvent, node: Node<ExecutorNodeData>) => {
@@ -512,7 +467,6 @@ export const WorkflowFlow = memo(function WorkflowFlow({
isStreaming={isStreaming}
animateRun={viewOptions.animateRun}
/>
<TimelineResizeHandler timelineVisible={timelineVisible} />
<ViewOptionsPanel
workflowDump={workflowDump}
onNodeSelect={onNodeSelect}
@@ -1,225 +0,0 @@
/**
* Workflow Conversation Manager Component
* Handles conversation selection, creation, and deletion for workflow executions
*/
import React, { useEffect, useState, useCallback } from "react";
import { useDevUIStore } from "@/stores/devuiStore";
import { apiClient } from "@/services/api";
import { Trash2, Plus, Clock } from "lucide-react";
import type { WorkflowSession } from "@/types";
interface WorkflowSessionManagerProps {
workflowId: string;
onSessionChange?: (session: WorkflowSession | undefined) => void;
}
export const WorkflowSessionManager: React.FC<WorkflowSessionManagerProps> = ({
workflowId,
onSessionChange,
}) => {
// Use individual selectors to avoid creating new objects on every render
const currentSession = useDevUIStore((state) => state.currentSession);
const availableSessions = useDevUIStore((state) => state.availableSessions);
const loadingSessions = useDevUIStore((state) => state.loadingSessions);
const setCurrentSession = useDevUIStore((state) => state.setCurrentSession);
const setAvailableSessions = useDevUIStore((state) => state.setAvailableSessions);
const setLoadingSessions = useDevUIStore((state) => state.setLoadingSessions);
const addSession = useDevUIStore((state) => state.addSession);
const removeSession = useDevUIStore((state) => state.removeSession);
const addToast = useDevUIStore((state) => state.addToast);
const runtime = useDevUIStore((state) => state.runtime);
const [creatingSession, setCreatingSession] = useState(false);
const [deletingSession, setDeletingSession] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoadingSessions(true);
try {
const response = await apiClient.listWorkflowSessions(workflowId);
// If no conversations exist, auto-create one (like agent conversations)
if (response.data.length === 0) {
console.log("No workflow conversations found, creating default conversation");
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
setAvailableSessions([newSession]);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "Default conversation created",
type: "success",
});
} else {
// Conversations exist - set available and auto-select the first one
setAvailableSessions(response.data);
// Auto-select first conversation if no current selection
if (!currentSession) {
const firstSession = response.data[0];
setCurrentSession(firstSession);
onSessionChange?.(firstSession);
}
}
} catch (error) {
console.error("Failed to load workflow conversations:", error);
// Silently handle for .NET backend (doesn't support conversations yet)
// Only show error for Python backend where this is unexpected
if (runtime !== "dotnet") {
addToast({
message: "Failed to load workflow conversations",
type: "error",
});
}
} finally {
setLoadingSessions(false);
}
}, [workflowId, currentSession, runtime, setLoadingSessions, setAvailableSessions, setCurrentSession, onSessionChange, addToast]);
// Load sessions on mount
useEffect(() => {
loadSessions();
}, [loadSessions]);
const handleCreateSession = async () => {
setCreatingSession(true);
try {
const newSession = await apiClient.createWorkflowSession(workflowId, {
name: `Conversation ${new Date().toLocaleString()}`,
});
addSession(newSession);
setCurrentSession(newSession);
onSessionChange?.(newSession);
addToast({
message: "New conversation created",
type: "success",
});
} catch (error) {
console.error("Failed to create conversation:", error);
addToast({
message: "Failed to create conversation",
type: "error",
});
} finally {
setCreatingSession(false);
}
};
const handleSelectSession = (session: WorkflowSession) => {
setCurrentSession(session);
onSessionChange?.(session);
};
const handleDeleteSession = async (
sessionId: string,
event: React.MouseEvent
) => {
event.stopPropagation(); // Prevent session selection when clicking delete
if (!confirm("Delete this conversation? All checkpoints will be lost.")) {
return;
}
setDeletingSession(sessionId);
try {
await apiClient.deleteWorkflowSession(workflowId, sessionId);
removeSession(sessionId);
addToast({
message: "Conversation deleted",
type: "success",
});
} catch (error) {
console.error("Failed to delete conversation:", error);
addToast({
message: "Failed to delete conversation",
type: "error",
});
} finally {
setDeletingSession(null);
}
};
const formatTimestamp = (timestamp: number) => {
const date = new Date(timestamp * 1000);
return date.toLocaleString();
};
if (loadingSessions) {
return (
<div className="flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full" />
<span className="ml-2 text-sm text-gray-600">Loading sessions...</span>
</div>
);
}
return (
<div className="workflow-session-manager space-y-3">
{/* Header with Create Button */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Conversations
</h3>
<button
onClick={handleCreateSession}
disabled={creatingSession}
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Create new conversation"
>
<Plus className="h-4 w-4" />
New Conversation
</button>
</div>
{/* Conversation List */}
{availableSessions.length === 0 ? (
<div className="text-center py-6 text-sm text-gray-500 dark:text-gray-400">
Loading conversations...
</div>
) : (
<div className="space-y-2 max-h-64 overflow-y-auto">
{availableSessions.map((session) => (
<div
key={session.conversation_id}
onClick={() => handleSelectSession(session)}
className={`
flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all
${
currentSession?.conversation_id === session.conversation_id
? "bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-400 flex-shrink-0" />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{session.metadata.name || "Unnamed Conversation"}
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{formatTimestamp(session.created_at)}
</div>
</div>
<button
onClick={(e) => handleDeleteSession(session.conversation_id, e)}
disabled={deletingSession === session.conversation_id}
className="ml-3 p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50"
title="Delete conversation"
>
{deletingSession === session.conversation_id ? (
<div className="animate-spin h-4 w-4 border-2 border-red-500 border-t-transparent rounded-full" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
</div>
))}
</div>
)}
</div>
);
};
File diff suppressed because it is too large Load Diff
@@ -4,17 +4,14 @@
*/
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EntitySelector } from "./entity-selector";
import { ModeToggle } from "@/components/mode-toggle";
import { Settings, Zap } from "lucide-react";
import { Settings } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
import { useDevUIStore } from "@/stores";
interface AppHeaderProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -25,15 +22,12 @@ interface AppHeaderProps {
export function AppHeader({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
isLoading = false,
onSettingsClick,
}: AppHeaderProps) {
const { oaiMode } = useDevUIStore();
return (
<header className="flex h-14 items-center gap-4 border-b px-4">
<div className="flex items-center gap-2 font-semibold">
@@ -64,29 +58,15 @@ export function AppHeader({
</defs>
</svg>
Dev UI
{/* Mode Badge */}
{oaiMode.enabled && (
<Badge variant="secondary" className="gap-1 ml-2">
<Zap className="h-3 w-3" />
OpenAI: {oaiMode.model}
</Badge>
)}
</div>
{/* Show entity selector only when NOT in OAI mode */}
{!oaiMode.enabled && (
<EntitySelector
agents={agents}
workflows={workflows}
entities={entities}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
)}
<div className="flex-1"></div>
<EntitySelector
agents={agents}
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
<div className="flex items-center gap-2 ml-auto">
<ModeToggle />
@@ -20,6 +20,7 @@ import {
ChevronRight,
ChevronDown,
Info,
PanelRightClose,
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
@@ -94,7 +95,7 @@ interface TraceEventData extends EventDataBase {
interface DebugPanelProps {
events: ExtendedResponseStreamEvent[];
isStreaming?: boolean;
onMinimize?: () => void;
onClose?: () => void;
}
// Helper: Extract function result from DevUI custom event
@@ -115,6 +116,39 @@ function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
return null;
}
// Helper to get a stable timestamp for an event
// Uses event's own timestamp fields if available
function getEventTimestamp(event: ExtendedResponseStreamEvent): string {
// Priority 1: Check for top-level timestamp (DevUI custom events like function_result.complete)
if ('timestamp' in event && typeof event.timestamp === 'string') {
return new Date(event.timestamp).toLocaleTimeString();
}
// Priority 2: Check for nested data.timestamp (workflow/trace events)
if ('data' in event && event.data && typeof event.data === 'object' && 'timestamp' in event.data) {
const dataTimestamp = (event.data as any).timestamp;
if (typeof dataTimestamp === 'string') {
return new Date(dataTimestamp).toLocaleTimeString();
}
}
// Priority 3: Check for created_at in response object (lifecycle events)
if ('response' in event && event.response && typeof event.response === 'object' && 'created_at' in event.response) {
const createdAt = (event.response as any).created_at;
if (typeof createdAt === 'number') {
return new Date(createdAt * 1000).toLocaleTimeString();
}
}
// Fallback: use sequence number as label (better than showing same time for all)
if ('sequence_number' in event && typeof event.sequence_number === 'number') {
return `#${event.sequence_number}`;
}
// Last resort: hide timestamp by returning empty string
return '';
}
// Helper function to accumulate OpenAI events into meaningful units
function processEventsForDisplay(
events: ExtendedResponseStreamEvent[]
@@ -136,8 +170,8 @@ function processEventsForDisplay(
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace.completed" ||
event.type === "response.trace.completed"
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
) {
continue;
}
@@ -178,9 +212,9 @@ function processEventsForDisplay(
event.type === "response.completed" ||
event.type === "response.done" ||
event.type === "error" ||
event.type === "response.workflow_event.completed" ||
event.type === "response.trace.completed" ||
event.type === "response.trace.completed" ||
event.type === "response.workflow_event.complete" ||
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete" ||
isFunctionResult
) {
// Flush any accumulated text before showing these events
@@ -194,8 +228,8 @@ function processEventsForDisplay(
// Extract function names from trace events
if (
(event.type === "response.trace.completed" ||
event.type === "response.trace.completed") &&
(event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete") &&
"data" in event
) {
const traceData = event.data as TraceEventData;
@@ -449,14 +483,15 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
return "Output item added";
}
case "response.workflow_event.completed":
case "response.workflow_event.complete":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return `Executor: ${data.executor_id || "unknown"}`;
}
return "Workflow event";
case "response.trace.completed":
case "response.trace_event.complete":
case "response.trace.complete":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return `Trace: ${data.operation_name || "unknown"}`;
@@ -501,9 +536,10 @@ function getEventIcon(type: string) {
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.completed":
case "response.workflow_event.complete":
return Activity;
case "response.trace.completed":
case "response.trace_event.complete":
case "response.trace.complete":
return Search;
case "response.completed":
return CheckCircle2;
@@ -528,9 +564,10 @@ function getEventColor(type: string) {
return "text-green-600 dark:text-green-400";
case "response.output_item.added":
return "text-green-600 dark:text-green-400";
case "response.workflow_event.completed":
case "response.workflow_event.complete":
return "text-purple-600 dark:text-purple-400";
case "response.trace.completed":
case "response.trace_event.complete":
case "response.trace.complete":
return "text-orange-600 dark:text-orange-400";
case "response.completed":
return "text-green-600 dark:text-green-400";
@@ -545,15 +582,9 @@ function getEventColor(type: string) {
function EventItem({ event }: EventItemProps) {
const [isExpanded, setIsExpanded] = useState(false);
const eventType = event.type || "unknown";
const Icon = getEventIcon(eventType);
const colorClass = getEventColor(eventType);
// Use stored UI timestamp if available, otherwise compute from event data
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
const Icon = getEventIcon(event.type);
const colorClass = getEventColor(event.type);
const timestamp = getEventTimestamp(event);
const summary = getEventSummary(event);
// Determine if this event has expandable content
@@ -564,13 +595,13 @@ function EventItem({ event }: EventItemProps) {
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.completed" &&
(event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data) ||
(event.type === "response.trace.completed" &&
(event.type === "response.trace_event.complete" &&
"data" in event &&
event.data) ||
(event.type === "response.trace.completed" &&
(event.type === "response.trace.complete" &&
"data" in event &&
event.data) ||
(event.type === "response.output_text.delta" &&
@@ -589,7 +620,7 @@ function EventItem({ event }: EventItemProps) {
<Icon className={`h-3 w-3 ${colorClass}`} />
<span className="font-mono">{timestamp}</span>
<Badge variant="outline" className="text-xs py-0">
{event.type ? event.type.replace("response.", "") : "unknown"}
{event.type.replace("response.", "")}
</Badge>
</div>
@@ -828,7 +859,7 @@ function EventExpandedContent({
break;
}
case "response.workflow_event.completed":
case "response.workflow_event.complete":
if ("data" in event && event.data) {
const data = event.data as WorkflowEventData;
return (
@@ -884,7 +915,8 @@ function EventExpandedContent({
}
break;
case "response.trace.completed":
case "response.trace_event.complete":
case "response.trace.complete":
if ("data" in event && event.data) {
const data = event.data as TraceEventData;
return (
@@ -1161,8 +1193,8 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
// ONLY show actual trace events - handle both event type formats
const traceEvents = events.filter(
(e) =>
e.type === "response.trace.completed" ||
e.type === "response.trace.completed"
e.type === "response.trace_event.complete" ||
e.type === "response.trace.complete"
);
// Add separators between message rounds
@@ -1221,8 +1253,8 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const [isExpanded, setIsExpanded] = useState(false);
if (
(event.type !== "response.trace.completed" &&
event.type !== "response.trace.completed") ||
(event.type !== "response.trace_event.complete" &&
event.type !== "response.trace.complete") ||
!("data" in event)
) {
return (
@@ -1234,19 +1266,14 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
const data = event.data as TraceEventData;
// Use stored UI timestamp first, then trace timestamps, then fallback to current time
let timestamp: string;
if ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number') {
// Use stored UI timestamp from when event was received
timestamp = new Date(event._uiTimestamp * 1000).toLocaleTimeString();
} else if (data.end_time) {
// Use actual trace timestamp if available, fallback to current time
let timestamp = new Date().toLocaleTimeString();
if (data.end_time) {
timestamp = new Date(data.end_time * 1000).toLocaleTimeString();
} else if (data.start_time) {
timestamp = new Date(data.start_time * 1000).toLocaleTimeString();
} else if (data.timestamp) {
timestamp = new Date(data.timestamp).toLocaleTimeString();
} else {
timestamp = new Date().toLocaleTimeString();
}
const operationName = data.operation_name || "Unknown Operation";
@@ -1493,10 +1520,7 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
// Use stored UI timestamp if available, otherwise compute from current time
const timestamp = ('_uiTimestamp' in event && typeof event._uiTimestamp === 'number')
? new Date(event._uiTimestamp * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
const timestamp = getEventTimestamp(event);
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
@@ -1597,7 +1621,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
export function DebugPanel({
events,
isStreaming = false,
onMinimize,
onClose,
}: DebugPanelProps) {
return (
<div className="flex-1 border-l flex flex-col min-h-0">
@@ -1614,15 +1638,15 @@ export function DebugPanel({
Tools
</TabsTrigger>
</TabsList>
{onMinimize && (
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={onMinimize}
onClick={onClose}
className="h-8 w-8 p-0 flex-shrink-0"
title="Minimize debug panel"
title="Hide debug panel"
>
<ChevronRight className="h-4 w-4" />
<PanelRightClose className="h-4 w-4" />
</Button>
)}
</div>
@@ -20,18 +20,12 @@ import {
Copy,
CheckCircle2,
ExternalLink,
Loader2,
AlertCircle,
} from "lucide-react";
import { useDevUIStore } from "@/stores";
import { apiClient } from "@/services/api";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
entity?: AgentInfo | WorkflowInfo;
}
type Tab = "docker" | "azure";
@@ -40,108 +34,10 @@ export function DeploymentModal({
open,
onClose,
agentName = "Agent",
entity,
}: DeploymentModalProps) {
// Get the Azure deployment feature flag from store
const azureDeploymentEnabled = useDevUIStore((state) => state.azureDeploymentEnabled);
// Check if deployment is truly supported (both feature flag and backend support)
const deploymentSupported = azureDeploymentEnabled && (entity?.deployment_supported ?? false);
// Context-aware tab ordering: Azure first if deployable, Docker first otherwise
const [activeTab, setActiveTab] = useState<Tab>(
deploymentSupported ? "azure" : "docker"
);
const [activeTab, setActiveTab] = useState<Tab>("docker");
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const logsContainerRef = useRef<HTMLDivElement | null>(null);
// Deployment state from Zustand
const isDeploying = useDevUIStore((state) => state.isDeploying);
const deploymentLogs = useDevUIStore((state) => state.deploymentLogs);
const lastDeployment = useDevUIStore((state) => state.lastDeployment);
const startDeployment = useDevUIStore((state) => state.startDeployment);
const addDeploymentLog = useDevUIStore((state) => state.addDeploymentLog);
const setDeploymentResult = useDevUIStore((state) => state.setDeploymentResult);
const stopDeployment = useDevUIStore((state) => state.stopDeployment);
const clearDeploymentState = useDevUIStore((state) => state.clearDeploymentState);
// Generate Azure-compliant default app name from entity name
const generateDefaultAppName = (entityName: string) => {
// Convert to lowercase, replace spaces and underscores with hyphens
// Remove any non-alphanumeric characters except hyphens
// Ensure it starts with a letter and is under 32 chars
const cleaned = entityName
.toLowerCase()
.replace(/[_\s]+/g, '-') // Replace underscores and spaces with hyphens
.replace(/[^a-z0-9-]/g, '') // Remove any other special characters
.replace(/--+/g, '-') // Replace multiple hyphens with single
.replace(/^[^a-z]+/, '') // Remove non-letter prefix
.replace(/-$/, ''); // Remove trailing hyphen
// Ensure it starts with a letter, add 'app-' prefix if needed
const withPrefix = cleaned.match(/^[a-z]/) ? cleaned : `app-${cleaned}`;
// Truncate to 31 chars max (32 limit)
return withPrefix.substring(0, 31);
};
// Form state for deployment with smart defaults
const defaultAppName = entity ? generateDefaultAppName(entity.id) : "";
const [resourceGroup, setResourceGroup] = useState("my-test-rg");
const [appName, setAppName] = useState(defaultAppName);
const [region, setRegion] = useState("eastus");
const [appNameError, setAppNameError] = useState<string | null>(null);
// Update app name when entity changes or modal opens
useEffect(() => {
if (entity) {
const newDefaultName = generateDefaultAppName(entity.id);
setAppName(newDefaultName);
// Validate the default name
const error = validateAppName(newDefaultName);
setAppNameError(error);
}
}, [entity?.id]); // Only re-run when entity ID changes
// Auto-scroll deployment logs to bottom when new logs are added
useEffect(() => {
if (logsContainerRef.current && deploymentLogs.length > 0) {
logsContainerRef.current.scrollTop = logsContainerRef.current.scrollHeight;
}
}, [deploymentLogs]);
// Validate Azure Container App name
const validateAppName = (name: string): string | null => {
if (!name) return null; // Don't show error for empty field
// Check length
if (name.length >= 32) {
return "App name must be less than 32 characters";
}
// Check for valid characters (lowercase alphanumeric and hyphens only)
if (!/^[a-z0-9-]+$/.test(name)) {
return "App name must contain only lowercase letters, numbers, and hyphens (no underscores or uppercase)";
}
// Must start with a letter
if (!/^[a-z]/.test(name)) {
return "App name must start with a lowercase letter";
}
// Must end with alphanumeric
if (!/[a-z0-9]$/.test(name)) {
return "App name must end with a letter or number";
}
// Cannot have double hyphens
if (name.includes("--")) {
return "App name cannot contain consecutive hyphens (--)";
}
return null;
};
// Cleanup timeout on unmount
useEffect(() => {
@@ -152,48 +48,6 @@ export function DeploymentModal({
};
}, []);
const handleDeploy = async () => {
if (!entity?.id || !resourceGroup || !appName) return;
// Trim whitespace from inputs
const trimmedResourceGroup = resourceGroup.trim();
const trimmedAppName = appName.trim();
// Validate trimmed app name before deployment
const nameError = validateAppName(trimmedAppName);
if (nameError) {
setAppNameError(nameError);
return;
}
try {
startDeployment();
for await (const event of apiClient.streamDeployment({
entity_id: entity.id,
resource_group: trimmedResourceGroup,
app_name: trimmedAppName,
region,
ui_mode: "user",
})) {
addDeploymentLog(event.message);
if (event.type === "deploy.completed" && event.url && event.auth_token) {
setDeploymentResult({
url: event.url,
authToken: event.auth_token,
});
} else if (event.type === "deploy.failed") {
// Stop deploying but keep logs visible
stopDeployment();
}
}
} catch (error) {
addDeploymentLog(`Error: ${error instanceof Error ? error.message : "Deployment failed"}`);
stopDeployment();
}
};
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
@@ -210,7 +64,8 @@ export function DeploymentModal({
timeoutRef.current = null;
}, 2000);
} catch (err) {
// Reset state on error - clipboard write failed
console.error("Failed to copy template:", err);
// Reset state on error
setCopiedTemplate(null);
}
};
@@ -294,22 +149,20 @@ openai>=1.0.0
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{deploymentSupported && (
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
<button
onClick={() => setActiveTab("azure")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "azure"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Cloud className="h-4 w-4 mr-2 inline" />
Azure
{activeTab === "azure" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content */}
@@ -507,230 +360,34 @@ openai>=1.0.0
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
{deploymentSupported
? "One-click deployment to Azure with automatic containerization and authentication."
: "Azure Container Apps provides serverless containers with auto-scaling and integrated monitoring."}
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
</p>
</div>
{/* Prerequisites Notice */}
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
Prerequisites for Azure Deployment
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-blue-800 dark:text-blue-200">
<li>Azure CLI installed and authenticated (<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">az login</code>)</li>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>Azure subscription with the following providers registered:
<ul className="ml-4 mt-1 space-y-0.5">
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.App</code> (Container Apps)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.ContainerRegistry</code> (ACR)</li>
<li className="list-none"> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded text-xs">Microsoft.OperationalInsights</code> (Logging)</li>
</ul>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
<details className="mt-2">
<summary className="text-xs cursor-pointer hover:underline text-blue-700 dark:text-blue-300">
How to register providers?
</summary>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900 rounded text-xs">
<p className="mb-1">Run these commands once per subscription:</p>
<code className="block font-mono">
az provider register -n Microsoft.App --wait<br/>
az provider register -n Microsoft.ContainerRegistry --wait<br/>
az provider register -n Microsoft.OperationalInsights --wait
</code>
</div>
</details>
</div>
{/* Functional Deployment Form (only if supported) */}
{deploymentSupported && entity && !lastDeployment && (
<div className="border rounded-lg p-4 space-y-4">
{!isDeploying ? (
<>
<div className="space-y-3">
<div>
<label className="text-sm font-medium">Resource Group</label>
<input
type="text"
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
placeholder="my-test-rg"
value={resourceGroup}
onChange={(e) => setResourceGroup(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium">App Name</label>
<input
type="text"
className={`w-full mt-1 px-3 py-2 border rounded-md text-sm ${
appNameError ? "border-red-500" : ""
}`}
placeholder="my-agent-app"
value={appName}
onChange={(e) => {
const newName = e.target.value;
setAppName(newName);
// Validate on change to provide immediate feedback
// Trim for validation to match what will be sent
const error = validateAppName(newName.trim());
setAppNameError(error);
}}
/>
{appNameError && (
<p className="mt-1 text-xs text-red-600">{appNameError}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Region</label>
<select
className="w-full mt-1 px-3 py-2 border rounded-md text-sm"
value={region}
onChange={(e) => setRegion(e.target.value)}
>
<option value="eastus">East US</option>
<option value="westus">West US</option>
<option value="westeurope">West Europe</option>
<option value="eastasia">East Asia</option>
</select>
</div>
</div>
<Button
onClick={handleDeploy}
disabled={!resourceGroup || !appName || !!appNameError}
className="w-full"
>
<Rocket className="h-4 w-4 mr-2" />
Deploy to Azure
</Button>
</>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin" />
Deploying...
</div>
<div
ref={logsContainerRef}
className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1"
>
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
</div>
)}
{/* Show logs after deployment stops (success or failure) */}
{!isDeploying && deploymentLogs.length > 0 && !lastDeployment && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
<AlertCircle className="h-4 w-4" />
Deployment Failed
</div>
<div className="bg-muted p-3 rounded-md text-xs font-mono max-h-60 overflow-y-auto space-y-1">
{deploymentLogs.map((log, i) => (
<div key={i} className={log.includes("failed") || log.includes("Error") ? "text-red-600" : ""}>{log}</div>
))}
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Try Again
</Button>
</div>
)}
</div>
)}
{/* Success Screen */}
{lastDeployment && (
<div className="border-2 border-green-200 bg-green-50 dark:bg-green-950/50 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<h4 className="font-semibold text-green-900 dark:text-green-100">
Deployment Successful!
</h4>
</div>
<div className="space-y-2">
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Deployment URL
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm">
{lastDeployment.url}
</code>
<Button
size="sm"
variant="outline"
onClick={() => window.open(lastDeployment.url, "_blank")}
>
<ExternalLink className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<label className="text-xs font-medium text-green-800 dark:text-green-200">
Auth Token (save this - shown only once)
</label>
<div className="flex gap-2 mt-1">
<code className="flex-1 bg-white dark:bg-gray-900 px-3 py-2 rounded border text-sm font-mono">
{lastDeployment.authToken}
</code>
<Button
size="sm"
variant="outline"
onClick={() => navigator.clipboard.writeText(lastDeployment.authToken)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<Button onClick={clearDeploymentState} variant="outline" className="w-full">
Deploy Another
</Button>
</div>
)}
{/* Deployment Not Supported Warning */}
{!deploymentSupported && entity?.deployment_reason && (
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 mt-0.5 text-amber-600 flex-shrink-0" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<strong>Deployment not available:</strong> {entity.deployment_reason}
</div>
</div>
</div>
)}
{/* CLI Instructions (only show when deployment not supported) */}
{!deploymentSupported && (
<>
{/* Prerequisites */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium text-sm">Prerequisites</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-muted-foreground">
<li>Azure subscription</li>
<li>
Azure CLI installed (
<code className="bg-muted px-1 rounded">
az --version
</code>
)
</li>
<li>Docker installed and running</li>
<li>
Logged in to Azure:{" "}
<code className="bg-muted px-1 rounded">az login</code>
</li>
</ul>
</div>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
{/* Step-by-step */}
<div className="space-y-3">
<h4 className="font-medium text-sm">Deployment Steps</h4>
<div className="space-y-3">
{/* Step 1 */}
@@ -851,8 +508,6 @@ az acr build --registry myregistry \\
</a>
</Button>
</div>
</>
)}
</div>
)}
</div>
@@ -20,7 +20,6 @@ import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities?: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onBrowseGallery?: () => void;
@@ -34,7 +33,6 @@ const getTypeIcon = (type: "agent" | "workflow") => {
export function EntitySelector({
agents,
workflows,
entities,
selectedItem,
onSelect,
onBrowseGallery,
@@ -42,8 +40,9 @@ export function EntitySelector({
}: EntitySelectorProps) {
const [open, setOpen] = useState(false);
// Use entities if provided (preserves backend order), otherwise combine agents and workflows
const allItems = entities || [...agents, ...workflows];
const allItems = [...agents, ...workflows].sort(
(a, b) => a.name?.localeCompare(b.name || a.id) || a.id.localeCompare(b.id)
);
const handleSelect = (item: AgentInfo | WorkflowInfo) => {
onSelect(item);
@@ -83,125 +82,80 @@ export function EntitySelector({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80 font-mono">
{/* Show items in backend order but with type grouping for clarity */}
{(() => {
// Group items by type while preserving order within each group
const workflowItems = allItems.filter(item => item.type === "workflow");
const agentItems = allItems.filter(item => item.type === "agent");
// Determine which type appears first in backend order
const firstItemType = allItems[0]?.type;
return (
<>
{/* Show workflows first if they appear first, otherwise agents */}
{firstItemType === "workflow" && workflowItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
{agents.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const isAgentLoaded = agent.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={agent.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<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 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Separator if both types exist */}
{workflowItems.length > 0 && agentItems.length > 0 && <DropdownMenuSeparator />}
{/* Agents section */}
{agentItems.length > 0 && (
<>
<DropdownMenuLabel className="flex items-center gap-2">
<Bot className="h-4 w-4" />
Agents ({agentItems.length})
</DropdownMenuLabel>
{agentItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Bot className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
{workflows.length > 0 && (
<>
{agents.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const isWorkflowLoaded = workflow.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={workflow.id}
className="cursor-pointer group"
>
<div className="flex items-center justify-between w-full gap-2">
<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 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{/* Show workflows last if agents appear first */}
{firstItemType === "agent" && workflowItems.length > 0 && (
<>
{agentItems.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="flex items-center gap-2">
<Workflow className="h-4 w-4" />
Workflows ({workflowItems.length})
</DropdownMenuLabel>
{workflowItems.map((item) => {
const isLoaded = item.metadata?.lazy_loaded !== false;
return (
<DropdownMenuItem
key={item.id}
className="cursor-pointer group"
onClick={() => handleSelect(item)}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<Workflow className="h-4 w-4 flex-shrink-0" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{item.name || item.id}
</span>
{isLoaded && item.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{item.description}
</div>
)}
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
</>
);
})()}
)}
</div>
</div>
</div>
</DropdownMenuItem>
);
})}
</>
)}
{allItems.length === 0 && (
<DropdownMenuItem disabled>
@@ -13,9 +13,7 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { ExternalLink, RotateCcw, Info, ChevronRight } from "lucide-react";
import { useDevUIStore } from "@/stores";
import { ExternalLink, RotateCcw } from "lucide-react";
interface SettingsModalProps {
open: boolean;
@@ -23,26 +21,10 @@ interface SettingsModalProps {
onBackendUrlChange?: (url: string) => void;
}
type Tab = "general" | "proxy" | "about";
type Tab = "about" | "settings";
// Preset OpenAI models for quick selection
const PRESET_MODELS = [
"gpt-4.1",
"gpt-4.1-mini",
"o1",
"o1-mini",
"o3-mini",
] as const;
export function SettingsModal({
open,
onOpenChange,
onBackendUrlChange,
}: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("general");
// OpenAI proxy mode, Azure deployment, auth status, and server capabilities from store
const { oaiMode, setOAIMode, azureDeploymentEnabled, setAzureDeploymentEnabled, authRequired, serverCapabilities } = useDevUIStore();
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL !== undefined ? import.meta.env.VITE_API_BASE_URL : "";
@@ -51,10 +33,6 @@ export function SettingsModal({
});
const [tempUrl, setTempUrl] = useState(backendUrl);
// Auth token state
const [authTokenStored, setAuthTokenStored] = useState(!!localStorage.getItem("devui_auth_token"));
const [newAuthToken, setNewAuthToken] = useState("");
const handleSave = () => {
// Validate URL format
try {
@@ -81,68 +59,33 @@ export function SettingsModal({
window.location.reload();
};
const handleAuthTokenSave = () => {
if (!newAuthToken.trim()) return;
localStorage.setItem("devui_auth_token", newAuthToken.trim());
setAuthTokenStored(true);
setNewAuthToken("");
// Reload to apply the auth token
window.location.reload();
};
const handleClearAuthToken = () => {
localStorage.removeItem("devui_auth_token");
setAuthTokenStored(false);
setNewAuthToken("");
// Reload to clear auth state
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] flex flex-col max-h-[85vh]">
<DialogHeader className="p-6 pb-2 flex-shrink-0">
<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 flex-shrink-0">
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("general")}
onClick={() => setActiveTab("settings")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "general"
activeTab === "settings"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
General
{activeTab === "general" && (
Settings
{activeTab === "settings" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
{serverCapabilities.openai_proxy && (
<button
onClick={() => setActiveTab("proxy")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "proxy"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
OpenAI Proxy
{activeTab === "proxy" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
)}
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
@@ -158,9 +101,9 @@ export function SettingsModal({
</button>
</div>
{/* Tab Content - Scrollable with min-height */}
<div className="px-6 pb-6 overflow-y-auto flex-1 min-h-[400px]">
{activeTab === "general" && (
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "settings" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
<div className="space-y-3">
@@ -199,7 +142,11 @@ export function SettingsModal({
<div className="flex gap-2 pt-2 min-h-[36px]">
{isModified && (
<>
<Button onClick={handleSave} size="sm" className="flex-1">
<Button
onClick={handleSave}
size="sm"
className="flex-1"
>
Apply & Reload
</Button>
<Button
@@ -214,373 +161,6 @@ export function SettingsModal({
)}
</div>
</div>
{/* Auth Token Setting - Only show if backend requires auth OR token is already stored */}
{(authRequired || authTokenStored) && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Authentication Token
</Label>
{!authRequired && authTokenStored && (
<span className="text-xs text-muted-foreground">
(Not required by current backend)
</span>
)}
</div>
{authTokenStored ? (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Input
type="password"
value="••••••••••••••••••••"
disabled
className="font-mono text-sm flex-1"
/>
<Button
variant="destructive"
size="sm"
onClick={handleClearAuthToken}
className="flex-shrink-0"
>
Clear
</Button>
</div>
<p className="text-xs text-green-600 dark:text-green-400">
Token configured and stored locally
</p>
</div>
) : (
<div className="space-y-3">
<Input
type="password"
value={newAuthToken}
onChange={(e) => setNewAuthToken(e.target.value)}
placeholder="Enter bearer token"
className="font-mono text-sm"
onKeyDown={(e) => {
if (e.key === "Enter" && newAuthToken.trim()) {
handleAuthTokenSave();
}
}}
/>
<Button
onClick={handleAuthTokenSave}
size="sm"
disabled={!newAuthToken.trim()}
className="w-full"
>
Save & Reload
</Button>
<p className="text-xs text-muted-foreground">
{authRequired
? "Required by backend (started with --auth flag)"
: "Not required by current backend"}
</p>
</div>
)}
</div>
)}
{/* Deployment Setting - Only show if backend supports deployment */}
{serverCapabilities.deployment && (
<div className="space-y-3 border-t pt-6">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-sm font-medium">
Azure Deployment
</Label>
<p className="text-xs text-muted-foreground">
Enable one-click deployment to Azure Container Apps
</p>
</div>
<Switch
checked={azureDeploymentEnabled}
onCheckedChange={setAzureDeploymentEnabled}
/>
</div>
{/* Expandable info section */}
<details className="group">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Learn more about Azure deployment
</summary>
<div className="mt-3 space-y-3 pl-4">
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, agents that support deployment will show a "Deploy to Azure"
button. This allows you to deploy your agent to Azure Container Apps directly
from DevUI.
</p>
<div className="space-y-1.5">
<p className="text-xs font-medium">When enabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deploy to Azure" for supported agents</li>
<li>Requires Azure CLI and proper authentication</li>
<li>Backend must have deployment capabilities enabled</li>
</ul>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium">When disabled:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>Shows "Deployment Guide" for all agents</li>
<li>Provides Docker templates and manual deployment instructions</li>
<li>No backend deployment capabilities required</li>
</ul>
</div>
</div>
</details>
</div>
)}
</div>
)}
{activeTab === "proxy" && serverCapabilities.openai_proxy && (
<div className="space-y-6 pt-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label className="text-base font-medium">
OpenAI Proxy Mode
</Label>
<p className="text-xs text-muted-foreground">
Route requests through DevUI backend to OpenAI API
</p>
</div>
<Switch
checked={oaiMode.enabled}
onCheckedChange={(checked: boolean) =>
setOAIMode({ ...oaiMode, enabled: checked })
}
/>
</div>
{/* Info box when disabled - prominent */}
{!oaiMode.enabled && (
<div className="bordder border-muted bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 flex-shrink-0 mt-0.5 text-blue-600 dark:text-blue-400" />
<div className="space-y-2">
<p className="text-sm font-medium">
About OpenAI Proxy Mode
</p>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, your chat requests are sent to your
DevUI backend{" "}
<span className="font-mono font-semibold">
({backendUrl})
</span>
, which then forwards them to OpenAI's API. This keeps
your{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
secure on the server instead of exposing it in the
browser.
</p>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Requirements:</p>
<ul className="text-xs text-muted-foreground space-y-0.5 list-disc list-inside">
<li>
Backend must have{" "}
<span className="font-mono">OPENAI_API_KEY</span>{" "}
configured
</li>
<li>
Backend must support OpenAI Responses API proxying
(DevUI does)
</li>
</ul>
</div>
<div className="space-y-1.5 pt-1">
<p className="text-xs font-medium">Why use this?</p>
<p className="text-xs text-muted-foreground">
Quickly test and compare OpenAI models directly
through the DevUI interface without creating custom
agents or exposing API keys in the browser.
</p>
</div>
</div>
</div>
</div>
)}
{oaiMode.enabled && (
<div className="space-y-4 pl-4 border-l-2 border-muted">
{/* Model ID Input - Primary control */}
<div className="space-y-2">
<Label className="text-sm font-medium">Model</Label>
<Input
type="text"
value={oaiMode.model}
onChange={(e) =>
setOAIMode({ ...oaiMode, model: e.target.value })
}
placeholder="gpt-4.1-mini"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Enter any OpenAI model ID (e.g., gpt-4.1, o1, o3-mini)
</p>
</div>
{/* Quick Preset Buttons */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Common presets
</Label>
<div className="flex flex-wrap gap-2">
{PRESET_MODELS.map((model) => (
<Button
key={model}
variant={
oaiMode.model === model ? "default" : "outline"
}
size="sm"
onClick={() => setOAIMode({ ...oaiMode, model })}
className="text-xs h-7"
>
{model}
</Button>
))}
</div>
</div>
{/* Advanced Parameters */}
<details className="group">
<summary className="cursor-pointer text-sm font-medium text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1">
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
Advanced Parameters (optional)
</summary>
<div className="space-y-3 mt-3 pl-4">
{/* Temperature */}
<div className="space-y-1">
<Label className="text-xs">Temperature</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={oaiMode.temperature ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
temperature: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Controls randomness (0-2)
</p>
</div>
{/* Max Output Tokens */}
<div className="space-y-1">
<Label className="text-xs">Max Output Tokens</Label>
<Input
type="number"
min="1"
value={oaiMode.max_output_tokens ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
max_output_tokens: e.target.value
? parseInt(e.target.value)
: undefined,
})
}
placeholder="Auto"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Maximum tokens in response
</p>
</div>
{/* Top P */}
<div className="space-y-1">
<Label className="text-xs">Top P</Label>
<Input
type="number"
step="0.1"
min="0"
max="1"
value={oaiMode.top_p ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
top_p: e.target.value
? parseFloat(e.target.value)
: undefined,
})
}
placeholder="1.0 (default)"
className="text-sm"
/>
<p className="text-xs text-muted-foreground">
Nucleus sampling (0-1)
</p>
</div>
{/* Reasoning Effort */}
<div className="space-y-1">
<Label className="text-xs">Reasoning Effort (o-series models)</Label>
<select
value={oaiMode.reasoning_effort ?? ""}
onChange={(e) =>
setOAIMode({
...oaiMode,
reasoning_effort: e.target.value
? (e.target.value as "minimal" | "low" | "medium" | "high")
: undefined,
})
}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="">Auto (default)</option>
<option value="minimal">Minimal</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<p className="text-xs text-muted-foreground">
Constrains reasoning effort (faster/cheaper vs thorough)
</p>
</div>
</div>
</details>
</div>
)}
</div>
{/* Collapsed info at bottom when enabled */}
{oaiMode.enabled && (
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-muted/50 p-3 rounded">
<Info className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
<div className="space-y-1">
<p>
Requests route through{" "}
<span className="font-mono font-semibold">
{backendUrl}
</span>{" "}
to OpenAI API. Server must have{" "}
<span className="font-mono font-semibold">
OPENAI_API_KEY
</span>{" "}
configured.
</p>
</div>
</div>
)}
</div>
)}
@@ -1,31 +0,0 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }
@@ -1,29 +0,0 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,126 @@
import { useMemo, useState, useCallback } from "react";
import type { ExtendedResponseStreamEvent } from "@/types";
// import type { ExecutorNodeData } from "@/components/workflow/executor-node";
// Type for executor input/output data - can be various types based on workflow events
export type ExecutorData =
| string
| number
| boolean
| Record<string, unknown>
| null;
// State tracking for a specific executor
interface ExecutorState {
executorId: string;
state: "pending" | "running" | "completed" | "failed" | "cancelled";
inputData?: ExecutorData;
outputData?: ExecutorData;
error?: string;
timestamp: string;
}
interface WorkflowEventCorrelationResult {
// State access
isWorkflowRunning: boolean;
selectedExecutorId: string | null;
recentlyActive: string[];
// Actions
selectExecutor: (executorId: string) => void;
getExecutorData: (executorId: string) => ExecutorState | null;
getExecutorEvents: (executorId: string) => ExtendedResponseStreamEvent[];
}
// Hook for correlating workflow events with executor states
export function useWorkflowEventCorrelation(
events: ExtendedResponseStreamEvent[],
isStreaming: boolean
): WorkflowEventCorrelationResult {
const [selectedExecutorId, setSelectedExecutorId] = useState<string | null>(null);
// Process events into executor states
const { executors, recentlyActive, isWorkflowRunning } = useMemo(() => {
const executorMap: Record<string, ExecutorState> = {};
const activeExecutors: string[] = [];
let workflowActive = isStreaming;
// Process workflow events
events.forEach((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
const executorId = data.executor_id;
if (!executorId) return;
// Initialize executor if not exists
if (!executorMap[executorId]) {
executorMap[executorId] = {
executorId,
state: "pending",
timestamp: new Date().toISOString(),
};
}
const executor = executorMap[executorId];
const eventType = data.event_type;
// Update state based on event type
if (eventType === "ExecutorInvokedEvent") {
executor.state = "running";
executor.inputData = data.data;
if (!activeExecutors.includes(executorId)) {
activeExecutors.push(executorId);
}
} else if (eventType === "ExecutorCompletedEvent") {
executor.state = "completed";
executor.outputData = data.data;
} else if (eventType?.includes("Error") || eventType?.includes("Failed")) {
executor.state = "failed";
executor.error = typeof data.data === "string" ? data.data : "Execution failed";
} else if (eventType?.includes("Cancel")) {
executor.state = "cancelled";
}
executor.timestamp = new Date().toISOString();
}
});
return {
executors: executorMap,
recentlyActive: activeExecutors.slice(-3), // Keep last 3 active executors
isWorkflowRunning: workflowActive,
};
}, [events, isStreaming]);
const selectExecutor = useCallback((executorId: string) => {
setSelectedExecutorId(executorId);
}, []);
const getExecutorData = useCallback((executorId: string): ExecutorState | null => {
return executors[executorId] || null;
}, [executors]);
const getExecutorEvents = useCallback(
(executorId: string): ExtendedResponseStreamEvent[] => {
return events.filter((event) => {
if (event.type === "response.workflow_event.complete" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === executorId;
}
return false;
});
},
[events]
);
return {
isWorkflowRunning,
selectedExecutorId,
recentlyActive,
selectExecutor,
getExecutorData,
getExecutorEvents,
};
}
@@ -8,7 +8,6 @@ import type {
AgentSource,
Conversation,
HealthResponse,
MetaResponse,
RunAgentRequest,
RunWorkflowRequest,
WorkflowInfo,
@@ -33,9 +32,6 @@ interface BackendEntityInfo {
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
@@ -68,8 +64,8 @@ const DEFAULT_API_BASE_URL =
: ""; // Default to relative URLs (same host as frontend)
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
const RETRY_INTERVAL_MS = 1000; // Retry every second
const MAX_RETRY_ATTEMPTS = 600; // Max 600 retries (10 minutes total)
// Get backend URL from localStorage or default
function getBackendUrl(): string {
@@ -86,12 +82,9 @@ function sleep(ms: number): Promise<void> {
class ApiClient {
private baseUrl: string;
private authToken: string | null = null;
constructor(baseUrl?: string) {
this.baseUrl = baseUrl || getBackendUrl();
// Load auth token from localStorage on initialization
this.authToken = localStorage.getItem("devui_auth_token");
}
// Allow updating the base URL at runtime
@@ -103,68 +96,27 @@ class ApiClient {
return this.baseUrl;
}
// Set auth token and persist to localStorage
setAuthToken(token: string | null): void {
this.authToken = token;
if (token) {
localStorage.setItem("devui_auth_token", token);
} else {
localStorage.removeItem("devui_auth_token");
}
}
// Get current auth token
getAuthToken(): string | null {
return this.authToken;
}
// Clear auth token
clearAuthToken(): void {
this.setAuthToken(null);
}
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
// Build headers with auth token if available
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...options.headers,
},
...options,
headers,
});
if (!response.ok) {
// Handle 401 Unauthorized - clear invalid token
if (response.status === 401) {
this.clearAuthToken();
throw new Error("UNAUTHORIZED");
}
// Try to extract error message from response body
let errorMessage = `API request failed: ${response.status} ${response.statusText}`;
try {
const errorData = await response.json();
// Handle detail as string or object
if (errorData.detail) {
if (typeof errorData.detail === "string") {
errorMessage = errorData.detail;
} else if (typeof errorData.detail === "object" && errorData.detail.error?.message) {
// Backend returns detail: { error: { message: "...", type: "...", code: "..." } }
errorMessage = errorData.detail.error.message;
}
} else if (errorData.error?.message) {
errorMessage = errorData.error.message;
errorMessage = errorData.detail;
}
} catch {
// If parsing fails, use default message
@@ -180,11 +132,6 @@ class ApiClient {
return this.request<HealthResponse>("/health");
}
// Server metadata
async getMeta(): Promise<MetaResponse> {
return this.request<MetaResponse>("/meta");
}
// Entity discovery using new unified endpoint
async getEntities(): Promise<{
entities: (AgentInfo | WorkflowInfo)[];
@@ -193,14 +140,17 @@ class ApiClient {
}> {
const response = await this.request<DiscoveryResponse>("/v1/entities");
// Transform entities while preserving backend order
const entities: (AgentInfo | WorkflowInfo)[] = response.entities.map((entity) => {
// Separate agents and workflows
const agents: AgentInfo[] = [];
const workflows: WorkflowInfo[] = [];
response.entities.forEach((entity) => {
if (entity.type === "agent") {
return {
agents.push({
id: entity.id,
name: entity.name,
description: entity.description,
type: "agent" as const,
type: "agent",
source: (entity.source as AgentSource) || "directory",
tools: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -211,26 +161,22 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
// Agent-specific fields
instructions: entity.instructions,
model: entity.model,
chat_client_type: entity.chat_client_type,
context_providers: entity.context_providers,
middleware: entity.middleware,
};
} else {
// Workflow
});
} else if (entity.type === "workflow") {
const firstTool = entity.tools?.[0];
const startExecutorId = typeof firstTool === "string" ? firstTool : "";
return {
workflows.push({
id: entity.id,
name: entity.name,
description: entity.description,
type: "workflow" as const,
type: "workflow",
source: (entity.source as AgentSource) || "directory",
executors: (entity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
@@ -241,24 +187,17 @@ class ApiClient {
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Deployment support
deployment_supported: entity.deployment_supported,
deployment_reason: entity.deployment_reason,
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
}, // Default schema
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
};
});
}
});
// Create filtered arrays for backward compatibility
const agents = entities.filter((e): e is AgentInfo => e.type === "agent");
const workflows = entities.filter((e): e is WorkflowInfo => e.type === "workflow");
return { entities, agents, workflows };
return { entities: [...agents, ...workflows], agents, workflows };
}
// Legacy methods for compatibility
@@ -274,7 +213,7 @@ class ApiClient {
async getAgentInfo(agentId: string): Promise<AgentInfo> {
// Get detailed entity info from unified endpoint
return this.request<AgentInfo>(`/v1/entities/${agentId}/info?type=agent`);
return this.request<AgentInfo>(`/v1/entities/${agentId}/info`);
}
async getWorkflowInfo(
@@ -282,17 +221,7 @@ class ApiClient {
): Promise<import("@/types").WorkflowInfo> {
// Get detailed entity info from unified endpoint
return this.request<import("@/types").WorkflowInfo>(
`/v1/entities/${workflowId}/info?type=workflow`
);
}
async reloadEntity(entityId: string): Promise<{ success: boolean; message: string }> {
// Hot reload entity - clears cache and forces reimport on next access
return this.request<{ success: boolean; message: string }>(
`/v1/entities/${entityId}/reload`,
{
method: "POST",
}
`/v1/entities/${workflowId}/info`
);
}
@@ -303,23 +232,10 @@ class ApiClient {
async createConversation(
metadata?: Record<string, string>
): Promise<Conversation> {
// Check if OAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
const headers: Record<string, string> = {};
// Add proxy mode header if enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
const response = await this.request<ConversationApiResponse>(
"/v1/conversations",
{
method: "POST",
headers,
body: JSON.stringify({ metadata }),
}
);
@@ -399,19 +315,6 @@ class ApiClient {
return this.request<{ data: unknown[]; has_more: boolean }>(url);
}
async deleteConversationItem(
conversationId: string,
itemId: string
): Promise<void> {
const response = await fetch(
`${this.baseUrl}/v1/conversations/${conversationId}/items/${itemId}`,
{ method: "DELETE" }
);
if (!response.ok) {
throw new Error(`Failed to delete item: ${response.statusText}`);
}
}
// OpenAI-compatible streaming methods using /v1/responses endpoint
// Private helper method that handles the actual streaming with retry logic
@@ -420,35 +323,6 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Check if OpenAI proxy mode is enabled
const { oaiMode } = await import("@/stores").then((m) => ({
oaiMode: m.useDevUIStore.getState().oaiMode,
}));
// Modify request if OAI mode is enabled
if (oaiMode.enabled) {
// Override model with OAI model
openAIRequest.model = oaiMode.model;
// Merge optional OpenAI parameters
if (oaiMode.temperature !== undefined) {
openAIRequest.temperature = oaiMode.temperature;
}
if (oaiMode.max_output_tokens !== undefined) {
openAIRequest.max_output_tokens = oaiMode.max_output_tokens;
}
if (oaiMode.top_p !== undefined) {
openAIRequest.top_p = oaiMode.top_p;
}
if (oaiMode.instructions !== undefined) {
openAIRequest.instructions = oaiMode.instructions;
}
// Reasoning parameters (for o-series models)
if (oaiMode.reasoning_effort !== undefined) {
openAIRequest.reasoning = { effort: oaiMode.reasoning_effort };
}
}
let lastSequenceNumber = -1;
let retryCount = 0;
let hasYieldedAnyEvent = false;
@@ -493,68 +367,26 @@ class ApiClient {
params.set("starting_after", lastSequenceNumber.toString());
}
const url = `${this.baseUrl}/v1/responses/${currentResponseId}?${params.toString()}`;
const headers: Record<string, string> = {
Accept: "text/event-stream",
};
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "GET",
headers,
headers: {
Accept: "text/event-stream",
},
});
} else {
const url = `${this.baseUrl}/v1/responses`;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Accept: "text/event-stream",
};
// Add proxy header if OAI mode is enabled
if (oaiMode.enabled) {
headers["X-Proxy-Backend"] = "openai";
}
// Add auth token if available
if (this.authToken) {
headers["Authorization"] = `Bearer ${this.authToken}`;
}
response = await fetch(url, {
method: "POST",
headers,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(openAIRequest),
});
}
if (!response.ok) {
// Handle authentication errors - don't retry these
if (response.status === 401) {
this.clearAuthToken(); // Clear invalid token
throw new Error("UNAUTHORIZED"); // Special error that won't be retried
}
// Handle other client errors (400-499) - don't retry these either
if (response.status >= 400 && response.status < 500) {
let errorMessage = `Client error ${response.status}`;
try {
const errorBody = await response.json();
if (errorBody.error && errorBody.error.message) {
errorMessage = errorBody.error.message;
} else if (errorBody.detail) {
errorMessage = errorBody.detail;
}
} catch {
// Fallback to generic message
}
throw new Error(`CLIENT_ERROR: ${errorMessage}`);
}
// Server errors (500-599) - these can be retried
// Try to extract detailed error message from response body
let errorMessage = `Request failed with status ${response.status}`;
try {
const errorBody = await response.json();
@@ -687,26 +519,18 @@ class ApiClient {
reader.releaseLock();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Don't retry on auth errors or client errors
if (errorMessage === "UNAUTHORIZED" || errorMessage.startsWith("CLIENT_ERROR:")) {
throw error; // Re-throw without retrying
}
// Network error or server error occurred - prepare to retry
// Network error occurred - prepare to retry
retryCount++;
if (retryCount > MAX_RETRY_ATTEMPTS) {
// Max retries exceeded - give up
throw new Error(
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${errorMessage}`
`Connection failed after ${MAX_RETRY_ATTEMPTS} retry attempts: ${error instanceof Error ? error.message : String(error)}`
);
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, max 30s
const retryDelay = Math.min(RETRY_INTERVAL_MS * Math.pow(2, retryCount - 1), 30000);
await sleep(retryDelay);
// Wait before retrying
await sleep(RETRY_INTERVAL_MS);
// Loop will retry with GET if we have response_id, otherwise POST
}
}
@@ -719,7 +543,7 @@ class ApiClient {
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
const openAIRequest: AgentFrameworkRequest = {
metadata: { entity_id: agentId }, // Entity ID in metadata for routing
model: agentId, // Model IS the entity_id (simplified routing!)
input: request.input, // Direct OpenAI ResponseInputParam
stream: true,
conversation: request.conversation_id, // OpenAI standard conversation param
@@ -735,7 +559,6 @@ class ApiClient {
conversationId?: string,
resumeResponseId?: string
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Proxy mode handling is now inside streamOpenAIResponse
yield* this.streamOpenAIResponse(openAIRequest, conversationId, resumeResponseId);
}
@@ -744,15 +567,12 @@ class ApiClient {
workflowId: string,
request: RunWorkflowRequest
): AsyncGenerator<ExtendedResponseStreamEvent, void, unknown> {
// Convert to OpenAI format - use metadata.entity_id for routing
// Convert to OpenAI format - use model field for entity_id (same as agents)
const openAIRequest: AgentFrameworkRequest = {
metadata: { entity_id: workflowId }, // Entity ID in metadata for routing
input: JSON.stringify(request.input_data || {}), // Serialize workflow input as JSON string
model: workflowId, // Use workflow ID in model field (matches agent pattern)
input: request.input_data || "", // Send dict directly, no stringification needed
stream: true,
conversation: request.conversation_id, // Include conversation if present
extra_body: request.checkpoint_id
? { entity_id: workflowId, checkpoint_id: request.checkpoint_id }
: undefined, // Pass checkpoint_id if provided
};
yield* this.streamOpenAIResponse(openAIRequest, request.conversation_id);
@@ -793,139 +613,6 @@ class ApiClient {
clearStreamingState(conversationId: string): void {
clearStreamingState(conversationId);
}
// Deployment methods
async* streamDeployment(config: {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
}): AsyncGenerator<{
type: string;
message: string;
url?: string;
auth_token?: string;
}> {
const response = await fetch(`${this.baseUrl}/v1/deployments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...config, stream: true }),
});
if (!response.ok) {
throw new Error(`Deployment failed: ${response.statusText}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") return;
try {
yield JSON.parse(data);
} catch (e) {
// Emit error event for parsing failures
yield {
type: "deploy.error",
message: `Failed to parse deployment event: ${e instanceof Error ? e.message : "Unknown error"}`,
};
}
}
}
}
} catch (error) {
// Emit error event before throwing
yield {
type: "deploy.failed",
message: `Stream interrupted: ${error instanceof Error ? error.message : "Unknown error"}`,
};
throw error;
} finally {
reader.releaseLock();
}
}
// ============================================================================
// Workflow Session Management (uses /conversations API)
// ============================================================================
async listWorkflowSessions(entityId: string): Promise<{ data: import("@/types").WorkflowSession[] }> {
// Workflow sessions are conversations with entity_id and type metadata
const url = `/v1/conversations?entity_id=${encodeURIComponent(entityId)}&type=workflow_session`;
const response = await this.request<{
object: "list";
data: ConversationApiResponse[];
has_more: boolean;
}>(url);
// Transform conversations to WorkflowSession format (no checkpoint counting)
const sessions = response.data.map((conv) => ({
conversation_id: conv.id,
entity_id: conv.metadata?.entity_id || entityId,
created_at: conv.created_at,
metadata: {
name: conv.metadata?.name || `Session ${new Date(conv.created_at * 1000).toLocaleString()}`,
description: conv.metadata?.description,
type: "workflow_session" as const,
},
}));
return { data: sessions };
}
async createWorkflowSession(
entityId: string,
params?: { name?: string; description?: string }
): Promise<import("@/types").WorkflowSession> {
// Create conversation with workflow session metadata
const metadata = {
entity_id: entityId,
type: "workflow_session" as const,
name: params?.name || `Session ${new Date().toLocaleString()}`,
...(params?.description && { description: params.description }),
};
const conversation = await this.createConversation(metadata);
return {
conversation_id: conversation.id,
entity_id: entityId,
created_at: conversation.created_at,
metadata: {
name: metadata.name,
description: metadata.description,
type: "workflow_session" as const,
},
};
}
async deleteWorkflowSession(_entityId: string, conversationId: string): Promise<void> {
// Delete conversation (this also deletes all associated items/checkpoints)
const success = await this.deleteConversation(conversationId);
if (!success) {
throw new Error("Failed to delete workflow session");
}
}
// Checkpoint operations now handled through standard conversation items API
// Checkpoints are conversation items with type="checkpoint"
}
// Export singleton instance
@@ -11,9 +11,6 @@ import type {
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
OAIProxyMode,
WorkflowSession,
CheckpointInfo,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
@@ -26,7 +23,6 @@ interface DevUIState {
// Entity Management Slice
agents: AgentInfo[];
workflows: WorkflowInfo[];
entities: (AgentInfo | WorkflowInfo)[]; // Full list in backend order
selectedAgent: AgentInfo | WorkflowInfo | undefined;
isLoadingEntities: boolean;
entityError: string | null;
@@ -46,16 +42,8 @@ interface DevUIState {
};
pendingApprovals: PendingApproval[];
// Workflow Session Slice (workflow-specific session management)
currentSession: WorkflowSession | undefined;
availableSessions: WorkflowSession[];
sessionCheckpoints: CheckpointInfo[];
loadingSessions: boolean;
loadingCheckpoints: boolean;
// UI Slice
showDebugPanel: boolean;
debugPanelMinimized: boolean;
debugPanelWidth: number;
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
@@ -65,36 +53,6 @@ interface DevUIState {
showGallery: boolean;
showDeployModal: boolean;
showEntityNotFoundToast: boolean;
// Toast Slice
toasts: Array<{
id: string;
message: string;
type: "info" | "success" | "warning" | "error";
duration?: number;
}>;
// OpenAI Proxy Mode Slice
oaiMode: OAIProxyMode;
// Server Meta Slice
uiMode: "developer" | "user";
runtime: "python" | "dotnet";
serverCapabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
authRequired: boolean;
// Deployment Slice
isDeploying: boolean;
deploymentLogs: string[];
lastDeployment: {
url: string;
authToken: string;
} | null;
azureDeploymentEnabled: boolean; // Feature flag for Azure deployment
}
// ========================================
@@ -105,7 +63,6 @@ interface DevUIActions {
// Entity Actions
setAgents: (agents: AgentInfo[]) => void;
setWorkflows: (workflows: WorkflowInfo[]) => void;
setEntities: (entities: (AgentInfo | WorkflowInfo)[]) => void;
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
addAgent: (agent: AgentInfo) => void;
addWorkflow: (workflow: WorkflowInfo) => void;
@@ -127,18 +84,8 @@ interface DevUIActions {
updateConversationUsage: (tokens: number) => void;
setPendingApprovals: (approvals: PendingApproval[]) => void;
// Workflow Session Actions
setCurrentSession: (session: WorkflowSession | undefined) => void;
setAvailableSessions: (sessions: WorkflowSession[]) => void;
setSessionCheckpoints: (checkpoints: CheckpointInfo[]) => void;
setLoadingSessions: (loading: boolean) => void;
setLoadingCheckpoints: (loading: boolean) => void;
addSession: (session: WorkflowSession) => void;
removeSession: (conversationId: string) => void;
// UI Actions
setShowDebugPanel: (show: boolean) => void;
setDebugPanelMinimized: (minimized: boolean) => void;
setDebugPanelWidth: (width: number) => void;
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
clearDebugEvents: () => void;
@@ -150,29 +97,6 @@ interface DevUIActions {
setShowDeployModal: (show: boolean) => void;
setShowEntityNotFoundToast: (show: boolean) => void;
// Toast Actions
addToast: (toast: {
message: string;
type?: "info" | "success" | "warning" | "error";
duration?: number;
}) => void;
removeToast: (id: string) => void;
// OpenAI Proxy Mode Actions
setOAIMode: (config: OAIProxyMode) => void;
toggleOAIMode: () => void;
// Server Meta Actions
setServerMeta: (meta: { uiMode: "developer" | "user"; runtime: "python" | "dotnet"; capabilities: { tracing: boolean; openai_proxy: boolean; deployment: boolean }; authRequired: boolean }) => void;
// Deployment Actions
startDeployment: () => void;
addDeploymentLog: (log: string) => void;
setDeploymentResult: (result: { url: string; authToken: string }) => void;
stopDeployment: () => void;
clearDeploymentState: () => void;
setAzureDeploymentEnabled: (enabled: boolean) => void;
// Combined Actions (handle multiple state updates + side effects)
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
}
@@ -194,7 +118,6 @@ export const useDevUIStore = create<DevUIStore>()(
// Entity State
agents: [],
workflows: [],
entities: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
@@ -211,16 +134,8 @@ export const useDevUIStore = create<DevUIStore>()(
conversationUsage: { total_tokens: 0, message_count: 0 },
pendingApprovals: [],
// Workflow Session State
currentSession: undefined,
availableSessions: [],
sessionCheckpoints: [],
loadingSessions: false,
loadingCheckpoints: false,
// UI State
showDebugPanel: true,
debugPanelMinimized: false,
debugPanelWidth: 320,
debugEvents: [],
isResizing: false,
@@ -231,38 +146,12 @@ export const useDevUIStore = create<DevUIStore>()(
showDeployModal: false,
showEntityNotFoundToast: false,
// Toast State
toasts: [],
// OpenAI Proxy Mode State
oaiMode: {
enabled: false,
model: "gpt-4o-mini", // Default to cheaper model
},
// Server Meta State
uiMode: "developer", // Default to developer mode
runtime: "python", // Default to Python runtime
serverCapabilities: {
tracing: false,
openai_proxy: false,
deployment: false,
},
authRequired: false,
// Deployment State
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
azureDeploymentEnabled: false, // Default to disabled for safety
// ========================================
// Entity Actions
// ========================================
setAgents: (agents) => set({ agents }),
setWorkflows: (workflows) => set({ workflows }),
setEntities: (entities) => set({ entities }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
addAgent: (agent) =>
set((state) => ({ agents: [...state.agents, agent] })),
@@ -327,69 +216,14 @@ export const useDevUIStore = create<DevUIStore>()(
})),
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
// ========================================
// Workflow Session Actions
// ========================================
setCurrentSession: (session) => set({ currentSession: session }),
setAvailableSessions: (sessions) => set({ availableSessions: sessions }),
setSessionCheckpoints: (checkpoints) =>
set({ sessionCheckpoints: checkpoints }),
setLoadingSessions: (loading) => set({ loadingSessions: loading }),
setLoadingCheckpoints: (loading) => set({ loadingCheckpoints: loading }),
addSession: (session) =>
set((state) => ({
availableSessions: [session, ...state.availableSessions],
})),
removeSession: (conversationId) =>
set((state) => ({
availableSessions: state.availableSessions.filter(
(s) => s.conversation_id !== conversationId
),
// Clear current session if it's the one being deleted
currentSession:
state.currentSession?.conversation_id === conversationId
? undefined
: state.currentSession,
// Clear checkpoints if they belong to deleted session
sessionCheckpoints:
state.currentSession?.conversation_id === conversationId
? []
: state.sessionCheckpoints,
})),
// ========================================
// UI Actions
// ========================================
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setDebugPanelMinimized: (minimized) => set({ debugPanelMinimized: minimized }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
addDebugEvent: (event) =>
set((state) => {
// Generate unique timestamp for each event
// Use current time + small increment to ensure uniqueness even for rapid events
const baseTimestamp = Math.floor(Date.now() / 1000);
const lastTimestamp = state.debugEvents.length > 0
? (state.debugEvents[state.debugEvents.length - 1] as any)._uiTimestamp || 0
: 0;
// Ensure new timestamp is always greater than the last one
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
return {
debugEvents: [
...state.debugEvents,
{
...event,
// Add UI display timestamp when event is received (Unix seconds)
// Each event gets a unique timestamp to preserve chronological order
_uiTimestamp: ('created_at' in event && event.created_at)
? event.created_at
: uniqueTimestamp,
} as ExtendedResponseStreamEvent & { _uiTimestamp: number },
],
};
}),
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
@@ -403,154 +237,6 @@ export const useDevUIStore = create<DevUIStore>()(
setShowEntityNotFoundToast: (show) =>
set({ showEntityNotFoundToast: show }),
// ========================================
// Toast Actions
// ========================================
addToast: (toast) =>
set((state) => ({
toasts: [
...state.toasts,
{
id: `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
type: toast.type || "info",
duration: toast.duration || 4000,
...toast,
},
],
})),
removeToast: (id) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== id),
})),
// ========================================
// OpenAI Proxy Mode Actions
// ========================================
setOAIMode: (config) =>
set((state) => {
// If enabling OAI mode, clear conversation state
if (config.enabled && !state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching to OAI mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// If disabling OAI mode, also clear state
if (!config.enabled && state.oaiMode.enabled) {
// Clear ALL conversation localStorage caches
Object.keys(localStorage).forEach(key => {
if (key.startsWith('devui_convs_')) {
localStorage.removeItem(key);
}
});
return {
oaiMode: config,
// Clear conversation state when switching back to local mode
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}
// Just update config (model, temperature, etc.) without clearing state
return { oaiMode: config };
}),
toggleOAIMode: () =>
set((state) => {
const newEnabled = !state.oaiMode.enabled;
return {
oaiMode: { ...state.oaiMode, enabled: newEnabled },
// Clear conversation state when toggling
currentConversation: undefined,
availableConversations: [],
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
debugEvents: [],
};
}),
// ========================================
// Server Meta Actions
// ========================================
setServerMeta: (meta) =>
set({
uiMode: meta.uiMode,
runtime: meta.runtime,
serverCapabilities: meta.capabilities,
authRequired: meta.authRequired,
}),
// ========================================
// Deployment Actions
// ========================================
startDeployment: () =>
set({
isDeploying: true,
deploymentLogs: [],
lastDeployment: null,
}),
addDeploymentLog: (log) =>
set((state) => ({
deploymentLogs: [...state.deploymentLogs, log],
})),
setDeploymentResult: (result) =>
set({
isDeploying: false,
lastDeployment: result,
}),
stopDeployment: () =>
set({
isDeploying: false,
}),
clearDeploymentState: () =>
set({
isDeploying: false,
deploymentLogs: [],
lastDeployment: null,
}),
setAzureDeploymentEnabled: (enabled) =>
set({ azureDeploymentEnabled: enabled }),
// ========================================
// Combined Actions
// ========================================
@@ -559,7 +245,6 @@ export const useDevUIStore = create<DevUIStore>()(
* Select an entity (agent/workflow) and handle all side effects:
* - Update selected entity
* - Clear conversation state (FIXES THE BUG!)
* - Clear session state (for workflows)
* - Clear debug events
* - Update URL
*/
@@ -576,10 +261,6 @@ export const useDevUIStore = create<DevUIStore>()(
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
// Clear workflow session state when switching entities
currentSession: undefined,
availableSessions: [], // Let WorkflowView reload sessions
sessionCheckpoints: [],
// Clear debug events when switching
debugEvents: [],
});
@@ -595,10 +276,7 @@ export const useDevUIStore = create<DevUIStore>()(
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelMinimized: state.debugPanelMinimized,
debugPanelWidth: state.debugPanelWidth,
oaiMode: state.oaiMode, // Persist OpenAI proxy mode settings
azureDeploymentEnabled: state.azureDeploymentEnabled, // Persist Azure deployment preference
}),
}
),
@@ -68,13 +68,12 @@ export type ResponseInputParam = ResponseInputItem[];
// Agent Framework extension fields (matches backend AgentFrameworkExtraBody)
export interface AgentFrameworkExtraBody {
entity_id: string;
checkpoint_id?: string; // Optional checkpoint ID for workflow resume
// input_data removed - now using standard input field for all data
}
// Agent Framework Request - OpenAI ResponseCreateParams with extensions
export interface AgentFrameworkRequest {
model?: string;
model: string;
input: string | ResponseInputParam | Record<string, unknown>; // Union type matching OpenAI + dict for workflows
stream?: boolean;
@@ -86,15 +85,8 @@ export interface AgentFrameworkRequest {
metadata?: Record<string, unknown>;
temperature?: number;
max_output_tokens?: number;
top_p?: number;
tools?: Record<string, unknown>[];
// Reasoning parameters (for o-series models)
reasoning?: {
effort?: "minimal" | "low" | "medium" | "high";
summary?: "auto" | "concise" | "detailed";
};
// Agent Framework extension - strongly typed
extra_body?: AgentFrameworkExtraBody;
entity_id?: string; // Allow entity_id as top-level field too
@@ -32,9 +32,6 @@ export interface AgentInfo {
module_path?: string;
required_env_vars?: EnvVarRequirement[];
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
// Deployment support
deployment_supported?: boolean;
deployment_reason?: string;
// Agent-specific fields
instructions?: string;
model?: string;
@@ -74,7 +71,6 @@ export interface WorkflowInfo extends Omit<AgentInfo, "tools"> {
input_schema: JSONSchema; // JSON Schema for workflow input
input_type_name: string; // Human-readable input type name
start_executor_id: string; // Entry point executor ID
// Note: DevUI provides runtime checkpoint storage for ALL workflows via conversations
}
// OpenAI Conversations API (standard)
@@ -93,22 +89,6 @@ export interface RunAgentRequest {
export interface RunWorkflowRequest {
input_data: Record<string, unknown>;
conversation_id?: string;
checkpoint_id?: string;
}
// OpenAI Proxy Mode Configuration
export interface OAIProxyMode {
enabled: boolean;
model: string; // Model ID like "gpt-4o", "gpt-4o-mini", or custom
// Optional OpenAI Responses API parameters
temperature?: number;
max_output_tokens?: number;
top_p?: number;
instructions?: string;
// Reasoning parameters (for o-series models)
reasoning_effort?: "minimal" | "low" | "medium" | "high";
}
// Legacy types - DEPRECATED - use new structured events from openai.ts instead
@@ -153,19 +133,6 @@ export interface HealthResponse {
version: string;
}
export interface MetaResponse {
ui_mode: "developer" | "user";
version: string;
framework: string;
runtime: "python" | "dotnet";
capabilities: {
tracing: boolean;
openai_proxy: boolean;
deployment: boolean;
};
auth_required: boolean;
}
// Chat message types matching Agent Framework
export interface ChatMessage {
id: string;
@@ -208,54 +175,3 @@ export interface PendingApproval {
arguments: Record<string, unknown>;
};
}
// Deployment types
export interface DeploymentConfig {
entity_id: string;
resource_group: string;
app_name: string;
region?: string;
ui_mode?: string;
ui_enabled?: boolean;
stream?: boolean;
}
export interface DeploymentEvent {
type: string;
message: string;
url?: string;
auth_token?: string;
}
export interface Deployment {
id: string;
entity_id: string;
resource_group: string;
app_name: string;
region: string;
url: string;
status: string;
created_at: string;
error?: string;
}
// Workflow Session Management Types
export interface WorkflowSession {
conversation_id: string;
entity_id: string;
created_at: number;
metadata: {
name?: string;
description?: string;
type: "workflow_session";
[key: string]: unknown;
};
}
export interface CheckpointInfo {
checkpoint_id: string;
workflow_id: string;
timestamp: number;
iteration_count: number;
metadata?: Record<string, unknown>;
}
@@ -65,7 +65,7 @@ export interface ResponseFailedEvent {
// Custom Agent Framework OpenAI event types with structured data
export interface ResponseWorkflowEventComplete {
type: "response.workflow_event.completed";
type: "response.workflow_event.complete";
data: {
event_type: string;
data?: Record<string, unknown>;
@@ -125,32 +125,6 @@ export interface ResponseFunctionToolCall {
status?: "in_progress" | "completed" | "incomplete";
}
// DevUI Extension: Output item types for response.output_item.added events
export interface ResponseOutputImageItem {
id: string;
type: "output_image";
image_url: string;
alt_text?: string;
mime_type: string;
}
export interface ResponseOutputFileItem {
id: string;
type: "output_file";
filename: string;
file_url?: string;
file_data?: string;
mime_type: string;
}
export interface ResponseOutputDataItem {
id: string;
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
// Workflow Item Types - flexible interface for any workflow item
export interface WorkflowItem {
type: string; // "executor_action", "workflow_action", "message", or any future type
@@ -173,34 +147,24 @@ export function isExecutorAction(item: WorkflowItem): item is ExecutorActionItem
return item.type === "executor_action" && "executor_id" in item;
}
// Union of all possible output items
export type ResponseOutputItem =
| ResponseFunctionToolCall
| ResponseOutputImageItem
| ResponseOutputFileItem
| ResponseOutputDataItem
| ExecutorActionItem
| WorkflowItem;
// OpenAI Responses API - Output Item Added Event
// OpenAI standard: Output item added event (extended to support our output types)
// OpenAI Responses API - Output Item Events
export interface ResponseOutputItemAddedEvent {
type: "response.output_item.added";
item: ResponseOutputItem;
item: WorkflowItem | ResponseFunctionToolCall | any; // Flexible to support various item types
output_index: number;
sequence_number?: number;
}
export interface ResponseOutputItemDoneEvent {
type: "response.output_item.done";
item: ResponseOutputItem;
item: WorkflowItem | ResponseFunctionToolCall | any;
output_index: number;
sequence_number?: number;
}
// Trace event - matching actual backend output
export interface ResponseTraceEventComplete {
type: "response.trace.completed";
type: "response.trace_event.complete";
data: {
operation_name?: string;
duration_ms?: number;
@@ -215,7 +179,7 @@ export interface ResponseTraceEventComplete {
// New trace event format from backend
export interface ResponseTraceComplete {
type: "response.trace.completed";
type: "response.trace.complete";
data: {
type?: string;
span_id?: string;
@@ -280,20 +244,6 @@ export interface ResponseFunctionResultComplete {
timestamp?: string; // Optional ISO timestamp for UI display
}
// DevUI Extension: Workflow Requests Human Input (HIL)
export interface ResponseRequestInfoEvent {
type: "response.request_info.requested";
request_id: string;
source_executor_id: string;
request_type: string;
request_data: Record<string, unknown>;
request_schema: Record<string, unknown>;
item_id: string;
output_index: number;
sequence_number: number;
timestamp: string;
}
// DevUI Extension: Turn Separator (UI-only event for grouping)
export interface TurnSeparatorEvent {
type: "debug.turn_separator";
@@ -316,7 +266,6 @@ export type StructuredEvent =
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseRequestInfoEvent
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent
@@ -425,18 +374,6 @@ export interface MessageInputFile {
filename?: string;
}
// DevUI Extension: Function approval request content (shown in chat)
export interface MessageFunctionApprovalRequestContent {
type: "function_approval_request";
request_id: string;
status: "pending" | "approved" | "rejected";
function_call: {
id: string;
name: string;
arguments: Record<string, unknown>;
};
}
// DevUI Extension: Function approval response content
export interface MessageFunctionApprovalResponseContent {
type: "function_approval_response";
@@ -449,45 +386,12 @@ export interface MessageFunctionApprovalResponseContent {
};
}
// ============================================================================
// DevUI Extension: Output Content Types (Agent-Generated Media/Data)
// ============================================================================
// These extend the OpenAI Responses API to support rich content outputs
// that aren't natively supported (images, files, data). They mirror the
// input types but for agent outputs.
export interface MessageOutputImage {
type: "output_image";
image_url: string; // URL or data URI (data:image/png;base64,...)
alt_text?: string;
mime_type: string;
}
export interface MessageOutputFile {
type: "output_file";
filename: string;
file_url?: string;
file_data?: string; // base64
mime_type: string;
}
export interface MessageOutputData {
type: "output_data";
data: string;
mime_type: string;
description?: string;
}
export type MessageContent =
| MessageTextContent
| MessageInputTextContent
| MessageOutputTextContent
| MessageInputImage
| MessageInputFile
| MessageOutputImage
| MessageOutputFile
| MessageOutputData
| MessageFunctionApprovalRequestContent
| MessageFunctionApprovalResponseContent;
// Message item (user/assistant messages with content)
@@ -497,7 +401,6 @@ export interface ConversationMessage {
role: "user" | "assistant" | "system" | "tool";
content: MessageContent[];
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this message was created
usage?: {
input_tokens: number;
output_tokens: number;
@@ -513,7 +416,6 @@ export interface ConversationFunctionCall {
name: string;
arguments: string;
status: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function call was made
}
// Function call output item
@@ -523,7 +425,6 @@ export interface ConversationFunctionCallOutput {
call_id: string;
output: string;
status?: "in_progress" | "completed" | "incomplete";
created_at?: number; // Unix timestamp in seconds - when this function result was received
}
// Union of all conversation item types
@@ -11,23 +11,6 @@ import type {
import type { Workflow } from "@/types/workflow";
import { getTypedWorkflow } from "@/types/workflow";
/**
* Truncates text that exceeds the maximum length and appends ellipsis
* @param text - The text to truncate
* @param maxLength - Maximum length before truncation (default: 50)
* @param ellipsis - String to append when truncated (default: '...')
* @returns Truncated text with ellipsis if it exceeds maxLength, otherwise original text
*
* @example
* truncateText('Hello World', 5) // 'Hello...'
* truncateText('Short', 10) // 'Short'
* truncateText('workflow_assistant_43ca50a006aa425e96e8fcf54206a7e3', 35) // 'workflow_assistant_43ca50a006aa4...'
*/
export function truncateText(text: string, maxLength: number = 50, ellipsis: string = '...'): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + ellipsis;
}
export interface WorkflowDumpExecutor {
id: string;
type: string;
@@ -181,8 +164,6 @@ export function convertWorkflowDumpToEdges(
id: `${connection.source}-${connection.target}`,
source: connection.source,
target: connection.target,
sourceHandle: "source",
targetHandle: "target",
type: "default",
animated: false,
style: {
@@ -326,7 +307,7 @@ export function applyDagreLayout(
/**
* Process workflow events and extract node updates
* Handles both standard OpenAI events and fallback workflow_event format
* Handles both new standard OpenAI events and legacy workflow events
*/
export function processWorkflowEvents(
events: ExtendedResponseStreamEvent[],
@@ -335,29 +316,12 @@ export function processWorkflowEvents(
const nodeUpdates: Record<string, NodeUpdate> = {};
let hasWorkflowStarted = false;
// Track the latest item ID for each executor to handle multiple runs
const latestItemIds: Record<string, string> = {};
events.forEach((event) => {
// Handle new standard OpenAI events
if (event.type === "response.output_item.added" || event.type === "response.output_item.done") {
const item = (event as any).item;
if (item && item.type === "executor_action" && item.executor_id) {
const executorId = item.executor_id;
const itemId = item.id;
// Track the latest item ID for this executor
if (event.type === "response.output_item.added") {
latestItemIds[executorId] = itemId;
}
// Only process this event if it's for the latest item ID of this executor
// This prevents older "done" events from overwriting newer "added" events
const isLatestItem = latestItemIds[executorId] === itemId;
if (!isLatestItem && event.type === "response.output_item.done") {
return; // Skip this old completion event
}
let state: ExecutorState = "pending";
let error: string | undefined;
@@ -388,9 +352,9 @@ export function processWorkflowEvents(
else if (event.type === "response.created" || event.type === "response.in_progress") {
hasWorkflowStarted = true;
}
// Handle workflow event format
// Legacy support for older backends
else if (
event.type === "response.workflow_event.completed" &&
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
@@ -436,38 +400,16 @@ export function processWorkflowEvents(
}
});
// FALLBACK LOGIC: If workflow has started and we have a start executor, set it to running
// ONLY if it hasn't received any explicit executor events
// This prevents overwriting the actual state after the executor has run
// If workflow has started and we have a start executor, set it to running
// (unless it already has a specific state from an ExecutorInvokedEvent)
if (hasWorkflowStarted && startExecutorId && !nodeUpdates[startExecutorId]) {
// Additional check: only set to running if we don't have completion/failure events for this executor
// This prevents setting to "running" after the executor has already completed
const hasCompletionEvent = events.some((event) => {
if (event.type === "response.output_item.done") {
const item = (event as any).item;
return item && item.type === "executor_action" && item.executor_id === startExecutorId;
}
if (event.type === "response.workflow_event.completed" && "data" in event && event.data) {
const data = event.data as any;
return data.executor_id === startExecutorId &&
(data.event_type === "ExecutorCompletedEvent" ||
data.event_type === "ExecutorFailedEvent" ||
data.event_type?.includes("Error") ||
data.event_type?.includes("Failed"));
}
return false;
});
// Only set to running if the executor hasn't completed yet
if (!hasCompletionEvent) {
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
nodeUpdates[startExecutorId] = {
nodeId: startExecutorId,
state: "running",
data: undefined,
error: undefined,
timestamp: new Date().toISOString(),
};
}
return nodeUpdates;
@@ -524,9 +466,9 @@ export function getCurrentlyExecutingExecutors(
};
}
}
// Handle workflow event format
// Legacy support for older backends
else if (
event.type === "response.workflow_event.completed" &&
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
@@ -573,7 +515,7 @@ export function updateEdgesWithSequenceAnalysis(
events.forEach((event) => {
if (
event.type === "response.workflow_event.completed" &&
event.type === "response.workflow_event.complete" &&
"data" in event &&
event.data
) {
@@ -642,67 +584,3 @@ export function updateEdgesWithSequenceAnalysis(
};
});
}
/**
* Consolidate bidirectional edges into single edges with arrows on both ends
* This reduces visual clutter when edges go in both directions between nodes
*
* Smart handle selection algorithm:
* The current implementation keeps whichever edge was encountered first in the array.
* Since edges are typically created in workflow definition order (following the primary flow),
* this naturally keeps the "forward" edge and discards the "backward" one.
*
* For example, if the workflow defines:
* 1. coordinator → planner (primary flow)
* 2. planner → coordinator (feedback loop)
*
* We keep edge #1 and add bidirectional arrows. This ensures the edge follows
* the natural output→input handle connection of the primary flow direction.
*
* React Flow will automatically route the edge to avoid overlaps, and the
* bidirectional arrows indicate that communication flows both ways.
*/
export function consolidateBidirectionalEdges(edges: Edge[]): Edge[] {
const edgeMap = new Map<string, Edge>();
const bidirectionalKeys = new Set<string>();
edges.forEach(edge => {
const forwardKey = `${edge.source}-${edge.target}`;
const reverseKey = `${edge.target}-${edge.source}`;
// Check if we already have the reverse edge
if (edgeMap.has(reverseKey)) {
// Mark both keys as bidirectional
bidirectionalKeys.add(reverseKey);
bidirectionalKeys.add(forwardKey);
// Update the existing reverse edge to be bidirectional
const existingEdge = edgeMap.get(reverseKey)!;
// Keep the existing edge's handles (they follow the primary workflow direction)
// Add bidirectional arrows to show two-way communication
edgeMap.set(reverseKey, {
...existingEdge,
markerStart: {
type: 'arrow' as const,
width: 20,
height: 20,
},
markerEnd: {
type: 'arrow' as const,
width: 20,
height: 20,
},
data: {
...existingEdge.data,
isBidirectional: true,
},
});
} else if (!bidirectionalKeys.has(forwardKey)) {
// Only add if this isn't the reverse of a bidirectional pair
edgeMap.set(forwardKey, edge);
}
});
return Array.from(edgeMap.values());
}
+1 -21
View File
@@ -584,33 +584,13 @@
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-separator@^1.1.7":
version "1.1.7"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.7.tgz#a18bd7fd07c10fda1bba14f2a3032e7b1a2b3470"
integrity sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==
dependencies:
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-slot@1.2.3", "@radix-ui/react-slot@^1.2.3":
"@radix-ui/react-slot@^1.2.3", "@radix-ui/react-slot@1.2.3":
version "1.2.3"
resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz"
integrity sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==
dependencies:
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-switch@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.2.6.tgz#ff79acb831f0d5ea9216cfcc5b939912571358e3"
integrity sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==
dependencies:
"@radix-ui/primitive" "1.1.3"
"@radix-ui/react-compose-refs" "1.1.2"
"@radix-ui/react-context" "1.1.2"
"@radix-ui/react-primitive" "2.1.3"
"@radix-ui/react-use-controllable-state" "1.2.2"
"@radix-ui/react-use-previous" "1.1.1"
"@radix-ui/react-use-size" "1.1.1"
"@radix-ui/react-tabs@^1.1.13":
version "1.1.13"
resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz"