Python: Add Function Approval UI to DevUI (#1401)

* ensure function aproval is parsed correctly

* udpate ui, add deployment guide button, other debug panel fixes

* feat(devui): Implement lazy loading architecture with enhanced security and state management

Major architectural improvements to DevUI for better performance, security, and developer experience:

Performance & Architecture:
- Implement lazy loading for entity discovery - entities loaded on-demand instead of at startup
- Add hot reload capability for development workflow via new reload endpoint
- Reduce startup time and memory footprint by deferring module imports

Security Enhancements:
- Remove remote entity loading capabilities (POST /v1/entities/add, DELETE endpoints)
- DevUI now strictly local development tool - no remote code execution
- Add explicit security documentation and best practices in README

Frontend Improvements:
- Migrate to Zustand for centralized state management (replacing prop drilling)
- Add lightweight zero-dependency markdown renderer with code block copy support
- Improve gallery UX with setup instructions modal instead of direct URL loading
- Enhanced message UI with copy functionality and better token usage display

Testing & Quality:
- Expand test coverage for lazy loading, type detection, and cache invalidation
- Add comprehensive tests for new behaviors (+231 lines of test code)
- Improve type safety and documentation throughout

Breaking Changes:
- Remote entity loading via URLs is no longer supported
- Entities must be loaded from local filesystem only

* update ui issues, uupdate test descripion
This commit is contained in:
Victor Dibia
2025-10-15 14:36:29 -07:00
committed by GitHub
Unverified
parent 331c750515
commit b64358df7e
38 changed files with 3726 additions and 1814 deletions
+2 -1
View File
@@ -26,7 +26,8 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.12"
"tailwindcss": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
+161 -245
View File
@@ -3,90 +3,105 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useState, useEffect, useCallback } from "react";
import { AppHeader, DebugPanel, SettingsModal } from "@/components/layout";
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 { LoadingState } from "@/components/ui/loading-state";
import { Toast } from "@/components/ui/toast";
import { apiClient } from "@/services/api";
import { PanelRightOpen, ChevronDown, ServerOff } from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
import { PanelRightOpen, ChevronDown, ServerOff, Rocket } from "lucide-react";
import type {
AgentInfo,
WorkflowInfo,
AppState,
ExtendedResponseStreamEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { useDevUIStore } from "@/stores";
export default function App() {
const [appState, setAppState] = useState<AppState>({
agents: [],
workflows: [],
isLoading: true,
});
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
const workflows = useDevUIStore((state) => state.workflows);
const selectedAgent = useDevUIStore((state) => state.selectedAgent);
const isLoadingEntities = useDevUIStore((state) => state.isLoadingEntities);
const entityError = useDevUIStore((state) => state.entityError);
const [debugEvents, setDebugEvents] = useState<ExtendedResponseStreamEvent[]>(
[]
);
const [showDebugPanel, setShowDebugPanel] = useState(() => {
const saved = localStorage.getItem("showDebugPanel");
return saved !== null ? saved === "true" : true;
});
const [debugPanelWidth, setDebugPanelWidth] = useState(() => {
const savedWidth = localStorage.getItem("debugPanelWidth");
return savedWidth ? parseInt(savedWidth, 10) : 320;
});
const [isResizing, setIsResizing] = useState(false);
const [showAboutModal, setShowAboutModal] = useState(false);
const [showGallery, setShowGallery] = useState(false);
const [addingEntityId, setAddingEntityId] = useState<string | null>(null);
const [errorEntityId, setErrorEntityId] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showEntityNotFoundToast, setShowEntityNotFoundToast] = useState(false);
// Entity actions
const setAgents = useDevUIStore((state) => state.setAgents);
const setWorkflows = useDevUIStore((state) => state.setWorkflows);
const selectEntity = useDevUIStore((state) => state.selectEntity);
const updateAgent = useDevUIStore((state) => state.updateAgent);
const updateWorkflow = useDevUIStore((state) => state.updateWorkflow);
const setIsLoadingEntities = useDevUIStore((state) => state.setIsLoadingEntities);
const setEntityError = useDevUIStore((state) => state.setEntityError);
// UI state from Zustand
const showDebugPanel = useDevUIStore((state) => state.showDebugPanel);
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 setDebugPanelWidth = useDevUIStore((state) => state.setDebugPanelWidth);
const addDebugEvent = useDevUIStore((state) => state.addDebugEvent);
const clearDebugEvents = useDevUIStore((state) => state.clearDebugEvents);
const setIsResizing = useDevUIStore((state) => state.setIsResizing);
// Modal state
const showAboutModal = useDevUIStore((state) => state.showAboutModal);
const showGallery = useDevUIStore((state) => state.showGallery);
const showDeployModal = useDevUIStore((state) => state.showDeployModal);
const showEntityNotFoundToast = useDevUIStore((state) => state.showEntityNotFoundToast);
// Modal actions
const setShowAboutModal = useDevUIStore((state) => state.setShowAboutModal);
const setShowGallery = useDevUIStore((state) => state.setShowGallery);
const setShowDeployModal = useDevUIStore((state) => state.setShowDeployModal);
const setShowEntityNotFoundToast = useDevUIStore((state) => state.setShowEntityNotFoundToast);
// Initialize app - load agents and workflows
useEffect(() => {
const loadData = async () => {
try {
const [agents, workflows] = await Promise.all([
apiClient.getAgents(),
apiClient.getWorkflows(),
]);
// Single API call instead of two parallel calls to same endpoint
const { agents: agentList, workflows: workflowList } = await apiClient.getEntities();
setAgents(agentList);
setWorkflows(workflowList);
// Check if there's an entity_id in the URL
const urlParams = new URLSearchParams(window.location.search);
const entityId = urlParams.get("entity_id");
let selectedAgent: AgentInfo | WorkflowInfo | undefined;
let selectedEntity: AgentInfo | WorkflowInfo | undefined;
// Try to find entity from URL parameter first
if (entityId) {
selectedAgent =
agents.find((a) => a.id === entityId) ||
workflows.find((w) => w.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 (!selectedAgent) {
if (!selectedEntity) {
setShowEntityNotFoundToast(true);
}
}
// Fallback to first available entity if URL entity not found
if (!selectedAgent) {
selectedAgent =
agents.length > 0
? agents[0]
: workflows.length > 0
? workflows[0]
if (!selectedEntity) {
selectedEntity =
agentList.length > 0
? agentList[0]
: workflowList.length > 0
? workflowList[0]
: undefined;
// Update URL to match actual selected entity (or clear if none)
if (selectedAgent) {
if (selectedEntity) {
const url = new URL(window.location.href);
url.searchParams.set("entity_id", selectedAgent.id);
url.searchParams.set("entity_id", selectedEntity.id);
window.history.replaceState({}, "", url);
} else {
// Clear entity_id if no entities available
@@ -96,34 +111,44 @@ export default function App() {
}
}
setAppState((prev) => ({
...prev,
agents,
workflows,
selectedAgent,
isLoading: false,
}));
if (selectedEntity) {
selectEntity(selectedEntity);
// Load full info for the first entity immediately
if (selectedEntity.metadata?.lazy_loaded === false) {
try {
if (selectedEntity.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(
selectedEntity.id
);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(
selectedEntity.id
);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(
`Failed to load full info for first entity ${selectedEntity.id}:`,
error
);
}
}
}
setIsLoadingEntities(false);
} catch (error) {
console.error("Failed to load agents/workflows:", error);
setAppState((prev) => ({
...prev,
error: error instanceof Error ? error.message : "Failed to load data",
isLoading: false,
}));
setEntityError(
error instanceof Error ? error.message : "Failed to load data"
);
setIsLoadingEntities(false);
}
};
loadData();
}, []);
// Save debug panel state to localStorage
useEffect(() => {
localStorage.setItem("showDebugPanel", showDebugPanel.toString());
}, [showDebugPanel]);
useEffect(() => {
localStorage.setItem("debugPanelWidth", debugPanelWidth.toString());
}, [debugPanelWidth]);
}, [setAgents, setWorkflows, selectEntity, updateAgent, updateWorkflow, setIsLoadingEntities, setEntityError, setShowEntityNotFoundToast]);
// Handle resize drag
const handleMouseDown = useCallback(
@@ -155,161 +180,43 @@ export default function App() {
[debugPanelWidth]
);
// Handle entity selection
const handleEntitySelect = useCallback((item: AgentInfo | WorkflowInfo) => {
setAppState((prev) => ({
...prev,
selectedAgent: item,
currentThread: undefined,
}));
// Handle entity selection - uses Zustand's selectEntity which handles ALL side effects
const handleEntitySelect = useCallback(
async (item: AgentInfo | WorkflowInfo) => {
selectEntity(item); // This clears conversation state, debug events, and updates URL!
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", item.id);
window.history.pushState({}, "", url);
// Clear debug events when switching entities
setDebugEvents([]);
}, []);
// If entity is sparse (not fully loaded), load full details
if (item.metadata?.lazy_loaded === false) {
try {
if (item.type === "agent") {
const fullAgent = await apiClient.getAgentInfo(item.id);
updateAgent(fullAgent);
} else {
const fullWorkflow = await apiClient.getWorkflowInfo(item.id);
updateWorkflow(fullWorkflow);
}
} catch (error) {
console.error(`Failed to load full info for ${item.id}:`, error);
}
}
},
[selectEntity, updateAgent, updateWorkflow]
);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
setDebugEvents([]);
clearDebugEvents();
} else {
setDebugEvents((prev) => [...prev, event]);
addDebugEvent(event);
}
},
[]
);
// Handle adding sample entity
const handleAddSample = useCallback(async (sample: SampleEntity) => {
setAddingEntityId(sample.id);
setErrorEntityId(null);
setErrorMessage(null);
try {
// Call backend to fetch and add entity
const newEntity = await apiClient.addEntity(sample.url, {
source: "remote_gallery",
originalUrl: sample.url,
sampleId: sample.id,
});
// Convert backend entity to frontend format
const convertedEntity = {
id: newEntity.id,
name: newEntity.name,
description: newEntity.description,
type: newEntity.type,
source:
(newEntity.source as "directory" | "in_memory" | "remote_gallery") ||
"remote_gallery",
has_env: false,
module_path: undefined,
};
// Update app state
if (newEntity.type === "agent") {
const agentEntity = {
...convertedEntity,
tools: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
} as AgentInfo;
setAppState((prev) => ({
...prev,
agents: [...prev.agents, agentEntity],
selectedAgent: agentEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", agentEntity.id);
window.history.pushState({}, "", url);
} else {
const workflowEntity = {
...convertedEntity,
executors: (newEntity.tools || []).map((tool) =>
typeof tool === "string" ? tool : JSON.stringify(tool)
),
input_schema: { type: "string" },
input_type_name: "Input",
start_executor_id:
newEntity.tools && newEntity.tools.length > 0
? typeof newEntity.tools[0] === "string"
? newEntity.tools[0]
: JSON.stringify(newEntity.tools[0])
: "unknown",
} as WorkflowInfo;
setAppState((prev) => ({
...prev,
workflows: [...prev.workflows, workflowEntity],
selectedAgent: workflowEntity,
}));
// Update URL with new entity
const url = new URL(window.location.href);
url.searchParams.set("entity_id", workflowEntity.id);
window.history.pushState({}, "", url);
}
// Close gallery and clear debug events
setShowGallery(false);
setDebugEvents([]);
} catch (error) {
const errMsg =
error instanceof Error ? error.message : "Failed to add sample entity";
console.error("Failed to add sample entity:", errMsg);
setErrorEntityId(sample.id);
setErrorMessage(errMsg);
} finally {
setAddingEntityId(null);
}
}, []);
const handleClearError = useCallback(() => {
setErrorEntityId(null);
setErrorMessage(null);
}, []);
// Handle removing entity
const handleRemoveEntity = useCallback(
async (entityId: string) => {
try {
await apiClient.removeEntity(entityId);
// Update app state
setAppState((prev) => ({
...prev,
agents: prev.agents.filter((a) => a.id !== entityId),
workflows: prev.workflows.filter((w) => w.id !== entityId),
selectedAgent:
prev.selectedAgent?.id === entityId
? undefined
: prev.selectedAgent,
}));
// Update URL - clear entity_id if we removed the selected entity
if (appState.selectedAgent?.id === entityId) {
const url = new URL(window.location.href);
url.searchParams.delete("entity_id");
window.history.pushState({}, "", url);
setDebugEvents([]);
}
} catch (error) {
console.error("Failed to remove entity:", error);
}
},
[appState.selectedAgent?.id]
[addDebugEvent, clearDebugEvents]
);
// Show loading state while initializing
if (appState.isLoading) {
if (isLoadingEntities) {
return (
<div className="h-screen flex flex-col bg-background">
{/* Top Bar - Skeleton */}
@@ -322,17 +229,18 @@ export default function App() {
</header>
{/* Loading Content */}
<LoadingState
message="Initializing DevUI..."
description="Loading agents and workflows from your configuration"
fullPage={true}
/>
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="text-lg font-medium">Initializing DevUI...</div>
<div className="text-sm text-muted-foreground mt-2">Loading agents and workflows from your configuration</div>
</div>
</div>
</div>
);
}
// Show error state if loading failed
if (appState.error) {
if (entityError) {
return (
<div className="h-screen flex flex-col bg-background">
<AppHeader
@@ -340,7 +248,6 @@ export default function App() {
workflows={[]}
selectedItem={undefined}
onSelect={() => {}}
onRemove={handleRemoveEntity}
isLoading={false}
onSettingsClick={() => setShowAboutModal(true)}
/>
@@ -388,14 +295,14 @@ export default function App() {
</div>
{/* Error Details (Collapsible) */}
{appState.error && (
{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">
{appState.error}
{entityError}
</p>
</details>
)}
@@ -420,13 +327,12 @@ export default function App() {
return (
<div className="h-screen flex flex-col bg-background max-h-screen">
<AppHeader
agents={appState.agents}
workflows={appState.workflows}
selectedItem={appState.selectedAgent}
agents={agents}
workflows={workflows}
selectedItem={selectedAgent}
onSelect={handleEntitySelect}
onRemove={handleRemoveEntity}
onBrowseGallery={() => setShowGallery(true)}
isLoading={appState.isLoading}
isLoading={isLoadingEntities}
onSettingsClick={() => setShowAboutModal(true)}
/>
@@ -437,40 +343,28 @@ export default function App() {
<div className="flex-1 w-full">
<GalleryView
variant="route"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
onClose={() => setShowGallery(false)}
hasExistingEntities={
appState.agents.length > 0 || appState.workflows.length > 0
agents.length > 0 || workflows.length > 0
}
/>
</div>
) : appState.agents.length === 0 && appState.workflows.length === 0 ? (
) : agents.length === 0 && workflows.length === 0 ? (
// Empty state - show gallery inline (full width, no debug panel)
<GalleryView
variant="inline"
onAdd={handleAddSample}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={handleClearError}
/>
<GalleryView variant="inline" />
) : (
<>
{/* Left Panel - Main View */}
<div className="flex-1 min-w-0">
{appState.selectedAgent ? (
appState.selectedAgent.type === "agent" ? (
{selectedAgent ? (
selectedAgent.type === "agent" ? (
<AgentView
selectedAgent={appState.selectedAgent as AgentInfo}
selectedAgent={selectedAgent as AgentInfo}
onDebugEvent={handleDebugEvent}
/>
) : (
<WorkflowView
selectedWorkflow={appState.selectedAgent as WorkflowInfo}
selectedWorkflow={selectedAgent as WorkflowInfo}
onDebugEvent={handleDebugEvent}
/>
)
@@ -503,7 +397,7 @@ export default function App() {
{/* Right Panel - Debug */}
<div
className="flex-shrink-0"
className="flex-shrink-0 flex flex-col h-[calc(100vh-3.7rem)]"
style={{ width: `${debugPanelWidth}px` }}
>
<DebugPanel
@@ -511,6 +405,21 @@ export default function App() {
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"
>
<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>
</>
) : (
@@ -534,6 +443,13 @@ export default function App() {
{/* Settings Modal */}
<SettingsModal open={showAboutModal} onOpenChange={setShowAboutModal} />
{/* Deployment Modal */}
<DeploymentModal
open={showDeployModal}
onClose={() => setShowDeployModal(false)}
agentName={selectedAgent?.name}
/>
{/* Toast Notification */}
{showEntityNotFoundToast && (
<Toast
@@ -34,6 +34,8 @@ import {
FileText,
Check,
X,
Copy,
CheckCheck,
} from "lucide-react";
import { apiClient } from "@/services/api";
import type {
@@ -41,13 +43,8 @@ import type {
RunAgentRequest,
Conversation,
ExtendedResponseStreamEvent,
PendingApproval,
} from "@/types";
interface ChatState {
items: import("@/types/openai").ConversationItem[]; // Pure OpenAI types - no legacy ChatMessage
isStreaming: boolean;
}
import { useDevUIStore } from "@/stores";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
@@ -61,14 +58,46 @@ interface ConversationItemBubbleProps {
}
function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
const [isHovered, setIsHovered] = useState(false);
const [copied, setCopied] = useState(false);
// Extract text content from message for copying
const getMessageText = () => {
if (item.type === "message") {
return item.content
.filter((c) => c.type === "text")
.map((c) => (c as import("@/types/openai").MessageTextContent).text)
.join("\n");
}
return "";
};
const handleCopy = async () => {
const text = getMessageText();
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
// Handle different item types
if (item.type === "message") {
const isUser = item.role === "user";
const isError = item.status === "incomplete";
const Icon = isUser ? User : isError ? AlertCircle : Bot;
const messageText = getMessageText();
return (
<div className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}>
<div
className={`flex gap-3 ${isUser ? "flex-row-reverse" : ""}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={`flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border ${
isUser
@@ -86,26 +115,48 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
isUser ? "items-end" : "items-start"
} max-w-[80%]`}
>
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
<div className="relative group">
<div
className={`rounded px-3 py-2 text-sm break-all ${
isUser
? "bg-primary text-primary-foreground"
: isError
? "bg-orange-50 dark:bg-orange-950/50 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800"
: "bg-muted"
}`}
>
{isError && (
<div className="flex items-start gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="font-medium text-sm">
Unable to process request
</span>
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<OpenAIMessageRenderer item={item} />
</div>
)}
<div className={isError ? "text-xs leading-relaxed break-all" : ""}>
<OpenAIMessageRenderer item={item} />
</div>
{/* Copy button - appears on hover, always top-right inside */}
{messageText && isHovered && (
<button
onClick={handleCopy}
className="absolute top-1 right-1
p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200 ease-in-out
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy message"}
>
{copied ? (
<CheckCheck className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground font-mono">
@@ -115,10 +166,10 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
<span></span>
<span className="flex items-center gap-1">
<span className="text-blue-600 dark:text-blue-400">
{item.usage.input_tokens}
{item.usage.input_tokens}
</span>
<span className="text-green-600 dark:text-green-400">
{item.usage.output_tokens}
{item.usage.output_tokens}
</span>
<span>({item.usage.total_tokens} tokens)</span>
</span>
@@ -146,33 +197,35 @@ function ConversationItemBubble({ item }: ConversationItemBubbleProps) {
}
export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const [chatState, setChatState] = useState<ChatState>({
items: [],
isStreaming: false,
});
const [currentConversation, setCurrentConversation] = useState<
Conversation | undefined
>(undefined);
const [availableConversations, setAvailableConversations] = useState<
Conversation[]
>([]);
const [inputValue, setInputValue] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [attachments, setAttachments] = useState<AttachmentItem[]>([]);
const [loadingConversations, setLoadingConversations] = useState(false);
// Get conversation state from Zustand
const currentConversation = useDevUIStore((state) => state.currentConversation);
const availableConversations = useDevUIStore((state) => state.availableConversations);
const chatItems = useDevUIStore((state) => state.chatItems);
const isStreaming = useDevUIStore((state) => state.isStreaming);
const isSubmitting = useDevUIStore((state) => state.isSubmitting);
const loadingConversations = useDevUIStore((state) => state.loadingConversations);
const inputValue = useDevUIStore((state) => state.inputValue);
const attachments = useDevUIStore((state) => state.attachments);
const conversationUsage = useDevUIStore((state) => state.conversationUsage);
const pendingApprovals = useDevUIStore((state) => state.pendingApprovals);
// Get conversation actions from Zustand (only the ones we actually use)
const setCurrentConversation = useDevUIStore((state) => state.setCurrentConversation);
const setAvailableConversations = useDevUIStore((state) => state.setAvailableConversations);
const setChatItems = useDevUIStore((state) => state.setChatItems);
const setIsStreaming = useDevUIStore((state) => state.setIsStreaming);
const setIsSubmitting = useDevUIStore((state) => state.setIsSubmitting);
const setLoadingConversations = useDevUIStore((state) => state.setLoadingConversations);
const setInputValue = useDevUIStore((state) => state.setInputValue);
const setAttachments = useDevUIStore((state) => state.setAttachments);
const updateConversationUsage = useDevUIStore((state) => state.updateConversationUsage);
const setPendingApprovals = useDevUIStore((state) => state.setPendingApprovals);
// Local UI state (not in Zustand - component-specific)
const [isDragOver, setIsDragOver] = useState(false);
const [dragCounter, setDragCounter] = useState(0);
const [pasteNotification, setPasteNotification] = useState<string | null>(
null
);
const [pasteNotification, setPasteNotification] = useState<string | null>(null);
const [detailsModalOpen, setDetailsModalOpen] = useState(false);
const [conversationUsage, setConversationUsage] = useState<{
total_tokens: number;
message_count: number;
}>({ total_tokens: 0, message_count: 0 });
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>(
[]
);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -183,18 +236,48 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
input_tokens: number;
output_tokens: number;
} | null>(null);
const userJustSentMessage = useRef<boolean>(false);
// Auto-scroll to bottom when new items arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatState.items, chatState.isStreaming]);
if (!messagesEndRef.current) return;
// Check if user is near bottom (within 100px)
const scrollContainer = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]');
let shouldScroll = false;
if (scrollContainer) {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100;
// Always scroll if user just sent a message, otherwise only if near bottom
shouldScroll = userJustSentMessage.current || isNearBottom;
} else {
// Fallback if scroll container not found - always scroll
shouldScroll = true;
}
if (shouldScroll) {
// Use instant scroll during streaming for smooth chunk additions
// Use smooth scroll when not streaming (new messages)
messagesEndRef.current.scrollIntoView({
behavior: isStreaming ? "instant" : "smooth"
});
}
// Reset the flag after first scroll
if (userJustSentMessage.current && !isStreaming) {
userJustSentMessage.current = false;
}
}, [chatItems, isStreaming]);
// Return focus to input after streaming completes
useEffect(() => {
if (!chatState.isStreaming && !isSubmitting) {
if (!isStreaming && !isSubmitting) {
textareaRef.current?.focus();
}
}, [chatState.isStreaming, isSubmitting]);
}, [isStreaming, isSubmitting]);
// Load conversations when agent changes
useEffect(() => {
@@ -223,12 +306,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
);
// Use OpenAI ConversationItems directly (no conversion!)
setChatState({
items: items as import("@/types/openai").ConversationItem[],
isStreaming: false
});
setChatItems(items as import("@/types/openai").ConversationItem[]);
setIsStreaming(false);
} catch {
setChatState({ items: [], isStreaming: false });
// 404 means conversation exists but has no items yet (newly created)
// This is normal - just start with empty chat
console.debug(`No items found for conversation ${mostRecent.id}, starting fresh`);
setChatItems([]);
setIsStreaming(false);
}
// Cache to localStorage for faster future loads
@@ -252,11 +337,24 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const convs = JSON.parse(cached) as Conversation[];
if (convs.length > 0) {
// Use most recent cached conversation
setAvailableConversations(convs);
setCurrentConversation(convs[0]);
setChatState({ items: [], isStreaming: false });
return;
// Validate that cached conversations still exist in backend
// Try to load items for the most recent one to verify it exists
try {
await apiClient.listConversationItems(convs[0].id);
// Success! Conversation exists in backend
setAvailableConversations(convs);
setCurrentConversation(convs[0]);
setChatItems([]);
setIsStreaming(false);
return;
} catch {
// Cached conversation doesn't exist anymore (server restarted)
// Clear stale cache and create new conversation
console.debug(`Cached conversation ${convs[0].id} no longer exists, clearing cache`);
localStorage.removeItem(cachedKey);
// Fall through to Step 3
}
}
} catch {
// Invalid cache - clear it
@@ -271,28 +369,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setCurrentConversation(newConversation);
setAvailableConversations([newConversation]);
setChatState({ items: [], isStreaming: false });
setChatItems([]);
setIsStreaming(false);
// Save to localStorage
localStorage.setItem(cachedKey, JSON.stringify([newConversation]));
} catch {
setAvailableConversations([]);
setChatState({ items: [], isStreaming: false });
setChatItems([]);
setIsStreaming(false);
} finally {
setLoadingConversations(false);
}
};
// Clear chat when agent changes
setChatState({
items: [],
isStreaming: false,
});
setChatItems([]);
setIsStreaming(false);
setCurrentConversation(undefined);
accumulatedText.current = "";
loadConversations();
}, [selectedAgent]);
}, [selectedAgent, setLoadingConversations, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]);
// Handle file uploads
const handleFilesSelected = async (files: File[]) => {
@@ -315,11 +413,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
});
}
setAttachments((prev) => [...prev, ...newAttachments]);
setAttachments([...useDevUIStore.getState().attachments, ...newAttachments]);
};
const handleRemoveAttachment = (id: string) => {
setAttachments((prev) => prev.filter((att) => att.id !== id));
setAttachments(useDevUIStore.getState().attachments.filter((att) => att.id !== id));
};
// Drag and drop handlers
@@ -353,7 +451,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setIsDragOver(false);
setDragCounter(0);
if (isSubmitting || chatState.isStreaming) return;
if (isSubmitting || isStreaming) return;
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
@@ -523,12 +621,11 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent_id: selectedAgent.id,
});
setCurrentConversation(newConversation);
setAvailableConversations((prev) => [newConversation, ...prev]);
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
setAvailableConversations([newConversation, ...useDevUIStore.getState().availableConversations]);
setChatItems([]);
setIsStreaming(false);
// Reset conversation usage by setting it to initial state
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedText.current = "";
// Update localStorage cache with new conversation
@@ -538,7 +635,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} catch {
// Failed to create conversation
}
}, [selectedAgent, availableConversations]);
}, [selectedAgent, availableConversations, setCurrentConversation, setAvailableConversations, setChatItems, setIsStreaming]);
// Handle conversation deletion
const handleDeleteConversation = useCallback(
@@ -578,18 +675,14 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Select the most recent remaining conversation
const nextConversation = updatedConversations[0];
setCurrentConversation(nextConversation);
setChatState({
items: [],
isStreaming: false,
});
setChatItems([]);
setIsStreaming(false);
} else {
// No conversations left, clear everything
setCurrentConversation(undefined);
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
setChatItems([]);
setIsStreaming(false);
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
accumulatedText.current = "";
}
}
@@ -601,7 +694,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
alert("Failed to delete conversation. Please try again.");
}
},
[availableConversations, currentConversation, selectedAgent, onDebugEvent]
[availableConversations, currentConversation, selectedAgent, onDebugEvent, setAvailableConversations, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle conversation selection
@@ -624,28 +717,28 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Use OpenAI ConversationItems directly (no conversion!)
const items = result.data as import("@/types/openai").ConversationItem[];
setChatState({
items,
isStreaming: false,
});
setChatItems(items);
setIsStreaming(false);
// Calculate usage from loaded items
setConversationUsage({
total_tokens: 0, // We don't have usage info in stored items
message_count: items.length,
useDevUIStore.setState({
conversationUsage: {
total_tokens: 0, // We don't have usage info in stored items
message_count: items.length,
}
});
} catch {
// Fallback to clearing items
setChatState({
items: [],
isStreaming: false,
});
setConversationUsage({ total_tokens: 0, message_count: 0 });
// 404 means conversation doesn't exist or has no items yet
// This can happen if server restarted (in-memory store cleared)
console.debug(`No items found for conversation ${conversationId}, starting with empty chat`);
setChatItems([]);
setIsStreaming(false);
useDevUIStore.setState({ conversationUsage: { total_tokens: 0, message_count: 0 } });
}
accumulatedText.current = "";
},
[availableConversations, onDebugEvent]
[availableConversations, onDebugEvent, setCurrentConversation, setChatItems, setIsStreaming]
);
// Handle function approval responses
@@ -677,8 +770,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
};
// Remove from pending immediately (will be confirmed by backend event)
setPendingApprovals((prev) =>
prev.filter((a) => a.request_id !== request_id)
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== request_id)
);
// Trigger send (we'll call this from the UI button handler)
@@ -729,11 +822,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
status: "completed",
};
setChatState((prev) => ({
...prev,
items: [...prev.items, userMessage],
isStreaming: true,
}));
setChatItems([...useDevUIStore.getState().chatItems, userMessage]);
setIsStreaming(true);
// Create assistant message placeholder
const assistantMessage: import("@/types/openai").ConversationMessage = {
@@ -744,10 +834,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
status: "in_progress",
};
setChatState((prev) => ({
...prev,
items: [...prev.items, assistantMessage],
}));
setChatItems([...useDevUIStore.getState().chatItems, assistantMessage]);
try {
// If no conversation selected, create one automatically
@@ -758,7 +845,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
agent_id: selectedAgent.id,
});
setCurrentConversation(conversationToUse);
setAvailableConversations((prev) => [conversationToUse!, ...prev]);
setAvailableConversations([conversationToUse, ...useDevUIStore.getState().availableConversations]);
} catch {
// Failed to create conversation
}
@@ -772,9 +859,6 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedText.current = "";
// Clear debug panel events for new agent run
onDebugEvent("clear");
// Use OpenAI-compatible API streaming - direct event handling
const streamGenerator = apiClient.streamAgentExecutionOpenAI(
selectedAgent.id,
@@ -805,8 +889,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
// Add to pending approvals
setPendingApprovals((prev) => [
...prev,
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
request_id: approvalEvent.request_id,
function_call: approvalEvent.function_call,
@@ -820,8 +904,8 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const responseEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRespondedEvent;
// Remove from pending approvals
setPendingApprovals((prev) =>
prev.filter((a) => a.request_id !== responseEvent.request_id)
setPendingApprovals(
useDevUIStore.getState().pendingApprovals.filter((a) => a.request_id !== responseEvent.request_id)
);
continue;
}
@@ -834,24 +918,22 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorMessage = errorEvent.message || "An error occurred";
// Update assistant message with error and stop streaming
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: errorMessage,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: errorMessage,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
setIsStreaming(false);
return; // Exit stream processing early on error
}
@@ -864,23 +946,21 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
accumulatedText.current += openAIEvent.delta;
// Update assistant message with accumulated content
setChatState((prev) => ({
...prev,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedText.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedText.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
}
// Handle completion/error by detecting when streaming stops
@@ -891,56 +971,49 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Usage is provided via response.completed event (OpenAI standard)
const finalUsage = currentMessageUsage.current;
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: "completed" as const,
usage: finalUsage || undefined,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: "completed" as const,
usage: finalUsage || undefined,
}
: item
));
setIsStreaming(false);
// Update conversation-level usage stats
if (finalUsage) {
setConversationUsage((prev) => ({
total_tokens: prev.total_tokens + finalUsage.total_tokens,
message_count: prev.message_count + 1,
}));
updateConversationUsage(finalUsage.total_tokens);
}
// Reset usage for next message
currentMessageUsage.current = null;
} catch (error) {
setChatState((prev) => ({
...prev,
isStreaming: false,
items: prev.items.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
),
}));
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
setIsStreaming(false);
}
},
[selectedAgent, currentConversation, onDebugEvent]
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage]
);
const handleSubmit = async (e: React.FormEvent) => {
@@ -953,6 +1026,9 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
)
return;
// Set flag to force scroll when user sends message
userJustSentMessage.current = true;
setIsSubmitting(true);
const messageText = inputValue.trim();
setInputValue("");
@@ -1056,7 +1132,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const canSendMessage =
selectedAgent &&
!isSubmitting &&
!chatState.isStreaming &&
!isStreaming &&
(inputValue.trim() || attachments.length > 0);
return (
@@ -1183,7 +1259,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
{/* Messages */}
<ScrollArea className="flex-1 p-4 h-0" ref={scrollAreaRef}>
<div className="space-y-4">
{chatState.items.length === 0 ? (
{chatItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<div className="text-muted-foreground text-sm">
Start a conversation with{" "}
@@ -1194,7 +1270,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
</div>
</div>
) : (
chatState.items.map((item) => (
chatItems.map((item) => (
<ConversationItemBubble key={item.id} item={item} />
))
)}
@@ -1339,13 +1415,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
placeholder={`Message ${
selectedAgent.name || selectedAgent.id
}... (Shift+Enter for new line)`}
disabled={isSubmitting || chatState.isStreaming}
disabled={isSubmitting || isStreaming}
className="flex-1 min-h-[40px] max-h-[200px] resize-none"
style={{ fieldSizing: "content" } as React.CSSProperties}
/>
<FileUpload
onFilesSelected={handleFilesSelected}
disabled={isSubmitting || chatState.isStreaming}
disabled={isSubmitting || isStreaming}
/>
<Button
type="submit"
@@ -9,10 +9,11 @@ import {
FileText,
Code,
ChevronDown,
ChevronUp,
ChevronRight,
Music,
} from "lucide-react";
import type { MessageContent } from "@/types/openai";
import { MarkdownRenderer } from "@/components/ui/markdown-renderer";
interface ContentRendererProps {
content: MessageContent;
@@ -22,51 +23,15 @@ interface ContentRendererProps {
// Text content renderer
function TextContentRenderer({ content, className, isStreaming }: ContentRendererProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (content.type !== "text") return null;
const text = content.text;
const TRUNCATE_LENGTH = 1600;
const shouldTruncate = text.length > TRUNCATE_LENGTH;
const displayText =
shouldTruncate && !isExpanded
? text.slice(0, TRUNCATE_LENGTH) + "..."
: text;
return (
<div className={`whitespace-pre-wrap break-words ${className || ""}`}>
<div
className={
isExpanded && shouldTruncate ? "max-h-96 overflow-y-auto" : ""
}
>
{displayText}
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
{shouldTruncate && (
<div className="flex justify-end mt-1">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="inline-flex items-center gap-1 text-xs
bg-background/80 hover:bg-background border border-border/50 hover:border-border
text-muted-foreground hover:text-foreground
transition-colors cursor-pointer px-2 py-1 rounded"
>
{isExpanded ? (
<>
less <ChevronUp className="h-3 w-3" />
</>
) : (
<>
{(text.length - TRUNCATE_LENGTH).toLocaleString()} more{" "}
<ChevronDown className="h-3 w-3" />
</>
)}
</button>
</div>
<div className={`break-words ${className || ""}`}>
<MarkdownRenderer content={text} />
{isStreaming && text.length > 0 && (
<span className="ml-1 inline-block h-2 w-2 animate-pulse rounded-full bg-current" />
)}
</div>
);
@@ -223,7 +188,7 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
}
return (
<div className={`my-2 p-3 border rounded-lg bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div className={`my-2 p-3 border rounded bg-blue-50 dark:bg-blue-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -232,7 +197,11 @@ export function FunctionCallRenderer({ name, arguments: args, className }: Funct
<span className="text-sm font-medium text-blue-800 dark:text-blue-300">
Function Call: {name}
</span>
<span className="text-xs text-blue-600 dark:text-blue-400">{isExpanded ? "▼" : "▶"}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-blue-600 dark:text-blue-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
@@ -264,7 +233,7 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
}
return (
<div className={`my-2 p-3 border rounded-lg bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div className={`my-2 p-3 border rounded bg-green-50 dark:bg-green-950/20 ${className || ""}`}>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
@@ -273,7 +242,11 @@ export function FunctionResultRenderer({ output, call_id, className }: FunctionR
<span className="text-sm font-medium text-green-800 dark:text-green-300">
Function Result
</span>
<span className="text-xs text-green-600 dark:text-green-400">{isExpanded ? "▼" : "▶"}</span>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
) : (
<ChevronRight className="h-4 w-4 text-green-600 dark:text-green-400 ml-auto" />
)}
</div>
{isExpanded && (
<div className="mt-2 text-xs font-mono bg-white dark:bg-gray-900 p-2 rounded border">
@@ -17,15 +17,13 @@ import {
import {
Bot,
Workflow,
Plus,
Loader2,
User,
TriangleAlert,
AlertCircle,
X,
Key,
ChevronDown,
ArrowLeft,
Download,
BookOpen,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -33,13 +31,9 @@ import {
type SampleEntity,
getDifficultyColor,
} from "@/data/gallery";
import { SetupInstructionsModal } from "./setup-instructions-modal";
interface GalleryViewProps {
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
onClose?: () => void;
variant?: "inline" | "route" | "modal";
hasExistingEntities?: boolean;
@@ -48,91 +42,41 @@ interface GalleryViewProps {
// Internal: Sample Entity Card Component
function SampleEntityCard({
sample,
onAdd,
isAdding = false,
hasError = false,
errorMessage,
onClearError,
}: {
sample: SampleEntity;
onAdd: (sample: SampleEntity) => Promise<void>;
isAdding?: boolean;
hasError?: boolean;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
const [isLoading, setIsLoading] = useState(false);
const handleAdd = async () => {
if (isLoading || isAdding) return;
setIsLoading(true);
try {
await onAdd(sample);
} finally {
setIsLoading(false);
}
};
const [showInstructions, setShowInstructions] = useState(false);
const TypeIcon = sample.type === "workflow" ? Workflow : Bot;
const isDisabled = isLoading || isAdding;
return (
<Card
className={cn(
"hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full",
hasError && "border-destructive"
)}
>
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
<>
<Card className="hover:shadow-md transition-shadow duration-200 h-full flex flex-col overflow-hidden w-full">
<CardHeader className="pb-3 min-w-0">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<TypeIcon className="h-5 w-5" />
<Badge variant="secondary" className="text-xs">
{sample.type}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<Badge
variant="outline"
className={cn(
"text-xs border",
getDifficultyColor(sample.difficulty)
)}
>
{sample.difficulty}
</Badge>
</div>
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
<CardDescription className="text-sm line-clamp-3">
{sample.description}
</CardDescription>
</CardHeader>
<CardTitle className="text-lg leading-tight">{sample.name}</CardTitle>
<CardDescription className="text-sm line-clamp-3">
{sample.description}
</CardDescription>
</CardHeader>
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
{/* Error Banner */}
{hasError && errorMessage && (
<div className="mb-3 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<div className="flex items-start gap-2">
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs text-destructive font-medium mb-1">
Failed to add
</p>
<p className="text-xs text-muted-foreground">{errorMessage}</p>
</div>
{onClearError && (
<button
onClick={() => onClearError(sample.id)}
className="text-muted-foreground hover:text-foreground"
aria-label="Dismiss error"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
)}
<CardContent className="pt-0 flex-1 min-w-0 overflow-hidden">
<div className="space-y-3 min-w-0">
{/* Tags */}
@@ -199,73 +143,57 @@ function SampleEntityCard({
</div>
</CardContent>
<CardFooter className="pt-3 flex-col gap-3">
{/* Metadata */}
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{sample.author}</span>
<CardFooter className="pt-3 flex-col gap-3">
{/* Metadata */}
<div className="w-full flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<User className="h-3 w-3" />
<span>{sample.author}</span>
</div>
</div>
</div>
{/* Add Button - Full width on its own line */}
<Button
onClick={handleAdd}
disabled={isDisabled}
className="w-full"
size="sm"
variant={hasError ? "outline" : "default"}
>
{isDisabled ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Adding...
</>
) : hasError ? (
<>
<Plus className="h-4 w-4 mr-2" />
Retry
</>
) : (
<>
<Plus className="h-4 w-4 mr-2" />
Add Sample
</>
)}
</Button>
</CardFooter>
</Card>
{/* Action Buttons */}
<div className="w-full flex gap-2">
<Button asChild className="flex-1" size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download
</a>
</Button>
<Button
variant="outline"
className="flex-1"
size="sm"
onClick={() => setShowInstructions(true)}
>
<BookOpen className="h-4 w-4 mr-2" />
Setup Guide
</Button>
</div>
</CardFooter>
</Card>
<SetupInstructionsModal
sample={sample}
open={showInstructions}
onOpenChange={setShowInstructions}
/>
</>
);
}
// Internal: Sample Entity Grid Component
function SampleEntityGrid({
samples,
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
}: {
samples: SampleEntity[];
onAdd: (sample: SampleEntity) => Promise<void>;
addingEntityId?: string | null;
errorEntityId?: string | null;
errorMessage?: string | null;
onClearError?: (sampleId: string) => void;
}) {
function SampleEntityGrid({ samples }: { samples: SampleEntity[] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{samples.map((sample) => (
<div key={sample.id} className="min-w-0">
<SampleEntityCard
sample={sample}
onAdd={onAdd}
isAdding={addingEntityId === sample.id}
hasError={errorEntityId === sample.id}
errorMessage={errorMessage}
onClearError={onClearError}
/>
<SampleEntityCard sample={sample} />
</div>
))}
</div>
@@ -274,11 +202,6 @@ function SampleEntityGrid({
// Main: Gallery View Component
export function GalleryView({
onAdd,
addingEntityId,
errorEntityId,
errorMessage,
onClearError,
onClose,
variant = "inline",
hasExistingEntities = false,
@@ -304,8 +227,8 @@ export function GalleryView({
in a directory containing them.
</p>
<p className="text-sm text-muted-foreground">
You can also import any of the sample agents and workflows
below to get started quickly.
Browse the sample agents and workflows below. Download them,
review the code, and run them locally to get started quickly.
</p>
</div>
</div>
@@ -314,14 +237,7 @@ export function GalleryView({
{/* Sample Gallery */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4">Sample Gallery</h3>
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
</div>
{/* Footer */}
@@ -362,22 +278,15 @@ export function GalleryView({
<div className="text-center">
<h2 className="text-2xl font-semibold mb-2">Sample Gallery</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Browse and add sample agents and workflows to learn the Agent
Framework. These are curated examples ranging from beginner to
advanced.
Browse sample agents and workflows to learn the Agent
Framework. Download these curated examples and run them locally.
Examples range from beginner to advanced.
</p>
</div>
</div>
{/* Sample Gallery */}
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
<SampleEntityGrid samples={SAMPLE_ENTITIES} />
{/* Footer */}
<div className="text-center mt-12 pt-8 border-t">
@@ -399,14 +308,5 @@ export function GalleryView({
}
// Modal variant - for dropdown trigger (simplified, just the grid)
return (
<SampleEntityGrid
samples={SAMPLE_ENTITIES}
onAdd={onAdd}
addingEntityId={addingEntityId}
errorEntityId={errorEntityId}
errorMessage={errorMessage}
onClearError={onClearError}
/>
);
return <SampleEntityGrid samples={SAMPLE_ENTITIES} />;
}
@@ -0,0 +1,207 @@
/**
* SetupInstructionsModal - Shows step-by-step instructions for running a sample entity
*/
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Download,
ExternalLink,
Copy,
Check,
Lightbulb,
BookOpen,
} from "lucide-react";
import type { SampleEntity } from "@/data/gallery";
interface SetupInstructionsModalProps {
sample: SampleEntity;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function CodeBlock({ children, copyable = false }: { children: string; copyable?: boolean }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(children);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="relative">
<pre className="bg-muted p-3 rounded-md text-sm overflow-x-auto font-mono">
<code>{children}</code>
</pre>
{copyable && (
<Button
variant="ghost"
size="sm"
className="absolute top-2 right-2 h-6 w-6 p-0"
onClick={handleCopy}
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</Button>
)}
</div>
);
}
function SetupStep({
number,
title,
description,
code,
action,
copyable = false,
}: {
number: number;
title: string;
description?: string;
code?: string;
action?: React.ReactNode;
copyable?: boolean;
}) {
return (
<div className="flex gap-4">
<div className="flex-shrink-0">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground font-semibold">
{number}
</div>
</div>
<div className="flex-1 space-y-2">
<h4 className="font-semibold">{title}</h4>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{code && <CodeBlock copyable={copyable}>{code}</CodeBlock>}
{action && <div>{action}</div>}
</div>
</div>
);
}
export function SetupInstructionsModal({
sample,
open,
onOpenChange,
}: SetupInstructionsModalProps) {
const hasEnvVars = sample.requiredEnvVars && sample.requiredEnvVars.length > 0;
const stepOffset = hasEnvVars ? 0 : -1;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 pt-6 pb-2">
<DialogTitle>Setup: {sample.name}</DialogTitle>
<DialogDescription>
Follow these steps to run this sample {sample.type} locally
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
<ScrollArea className="h-[500px]">
<div className="space-y-6 pr-4">
{/* Step 1: Download */}
<SetupStep
number={1}
title="Download the sample file"
action={
<Button asChild size="sm">
<a
href={sample.url}
download={`${sample.id}.py`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4 mr-2" />
Download {sample.id}.py
</a>
</Button>
}
/>
{/* Step 2: Create folder */}
<SetupStep
number={2}
title="Create a project folder"
description="Create a dedicated folder for this sample and move the downloaded file there:"
code={`mkdir -p ~/my-agents/${sample.id}\nmv ~/Downloads/${sample.id}.py ~/my-agents/${sample.id}/`}
copyable
/>
{/* Step 3: Environment variables (conditional) */}
{hasEnvVars && (
<SetupStep
number={3}
title="Set up environment variables"
description="Create a .env file in the project folder with these required variables:"
code={sample.requiredEnvVars!
.map((v) => `${v.name}=${v.example || "your-value-here"}\n# ${v.description}`)
.join("\n\n")}
copyable
/>
)}
{/* Step 4: Run DevUI */}
<SetupStep
number={4 + stepOffset}
title="Run with DevUI"
description="Navigate to the folder and start DevUI:"
code={`cd ~/my-agents/${sample.id}\ndevui .`}
copyable
/>
{/* Alternative: Direct run */}
<Alert>
<Lightbulb className="h-4 w-4" />
<AlertTitle>Alternative: Run Programmatically</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">You can also run the {sample.type} directly in Python:</p>
<CodeBlock copyable>
{`from ${sample.id} import ${sample.type}
import asyncio
async def main():
response = await ${sample.type}.run("Hello!")
print(response)
asyncio.run(main())`}
</CodeBlock>
</AlertDescription>
</Alert>
{/* Help links */}
<div className="flex gap-2 pt-4 border-t">
<Button variant="outline" size="sm" asChild>
<a href={sample.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-2" />
View Source
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/microsoft/agent-framework#readme"
target="_blank"
rel="noopener noreferrer"
>
<BookOpen className="h-4 w-4 mr-2" />
Documentation
</a>
</Button>
</div>
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -32,22 +32,29 @@ interface FormFieldProps {
isRequired?: boolean;
}
// Helper: Determine if field is likely a short metadata field (not multiline text)
function isShortField(fieldName: string): boolean {
const shortFieldNames = ['name', 'title', 'id', 'key', 'label', 'type', 'status', 'tag', 'category', 'code', 'username', 'password'];
return shortFieldNames.includes(fieldName.toLowerCase());
}
function FormField({ name, schema, value, onChange, isRequired = false }: FormFieldProps) {
const { type, description, enum: enumValues, default: defaultValue } = schema;
// For text/message/content fields, treat as textarea for better UX
const isTextContentField = ['text', 'message', 'content', 'query', 'prompt'].includes(name.toLowerCase());
// Determine if this should be a textarea based on JSON Schema format field
// or heuristics (long descriptions, specific field types)
const shouldBeTextarea =
schema.format === "textarea" || // Explicit format from backend
(description && description.length > 100) || // Long description suggests multiline
(type === "string" && !enumValues && !isShortField(name)); // Default strings to textarea unless they're short metadata fields
// Determine if this field should span full width
// Only span full if it's a textarea or has very long description
const shouldSpanFullWidth =
schema.format === "textarea" ||
isTextContentField || // text/message fields span full width
shouldBeTextarea ||
(description && description.length > 150);
const shouldSpanTwoColumns =
schema.format === "textarea" ||
isTextContentField ||
shouldBeTextarea ||
(description && description.length > 80) ||
type === "array"; // Arrays might need more space for comma-separated values
@@ -90,8 +97,7 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
</div>
);
} else if (
schema.format === "textarea" ||
isTextContentField ||
shouldBeTextarea ||
(description && description.length > 100)
) {
// Multi-line text (including text/message/content fields)
@@ -110,7 +116,8 @@ function FormField({ name, schema, value, onChange, isRequired = false }: FormFi
? defaultValue
: `Enter ${name}`
}
rows={isTextContentField ? 4 : 2}
rows={shouldBeTextarea ? 4 : 2}
className="min-w-[300px] w-full"
/>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
@@ -201,7 +201,7 @@ function RunWorkflowButton({
{/* Modal with proper Dialog component - matching WorkflowInputForm structure */}
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="w-full max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogContent className="w-full min-w-[400px] max-w-md sm:max-w-lg md:max-w-2xl lg:max-w-4xl xl:max-w-5xl max-h-[90vh] flex flex-col">
<DialogHeader className="px-8 pt-6">
<DialogTitle>Configure Workflow Inputs</DialogTitle>
<DialogClose onClose={() => setShowModal(false)} />
@@ -14,7 +14,6 @@ interface AppHeaderProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
onSettingsClick?: () => void;
@@ -25,7 +24,6 @@ export function AppHeader({
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
onSettingsClick,
@@ -66,7 +64,6 @@ export function AppHeader({
workflows={workflows}
selectedItem={selectedItem}
onSelect={onSelect}
onRemove={onRemove}
onBrowseGallery={onBrowseGallery}
isLoading={isLoading}
/>
@@ -24,6 +24,40 @@ import {
} from "lucide-react";
import type { ExtendedResponseStreamEvent } from "@/types";
// Simple visual separator component
function MessageSeparator() {
return (
<div className="flex items-center gap-2 py-3 px-2">
<div className="flex-1 border-t border-border/50" />
</div>
);
}
// Helper to add separators between message rounds
function addSeparatorsToEvents(events: ExtendedResponseStreamEvent[]): (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] {
const result: (ExtendedResponseStreamEvent | { type: "separator"; id: string })[] = [];
let lastWasResponseDone = false;
for (let i = 0; i < events.length; i++) {
const event = events[i];
// Add separator before first event after response.done
if (lastWasResponseDone && event.type !== "response.done") {
result.push({ type: "separator", id: `sep-${i}` });
lastWasResponseDone = false;
}
result.push(event);
// Track when we see response.done
if (event.type === "response.done" || event.type === "response.completed") {
lastWasResponseDone = true;
}
}
return result;
}
// Type definitions for event data structures
interface EventDataBase {
call_id?: string;
@@ -64,7 +98,7 @@ interface DebugPanelProps {
onClose?: () => void;
}
// Helper: Extract function result from DevUI custom format
// Helper: Extract function result from DevUI custom event
function getFunctionResultFromEvent(event: ExtendedResponseStreamEvent): {
call_id: string;
output: string;
@@ -101,9 +135,18 @@ function processEventsForDisplay(
let accumulatedText = "";
for (const event of events) {
// Skip trace events - they belong in the Traces tab only
if (
event.type === "response.trace_event.complete" ||
event.type === "response.trace.complete"
) {
continue;
}
// Handle response.output_item.added - NEW! Extract function call metadata
if (event.type === "response.output_item.added") {
const outputEvent = event as import("@/types").ResponseOutputItemAddedEvent;
const outputEvent =
event as import("@/types").ResponseOutputItemAddedEvent;
const item = outputEvent.item;
// If it's a function call item, extract metadata
@@ -388,15 +431,17 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
}
return "Function arguments...";
case "response.function_result.complete": {
const resultEvent =
event as import("@/types").ResponseFunctionResultComplete;
const truncated = resultEvent.output.slice(0, 40);
return `Function result: ${truncated}${
truncated.length >= 40 ? "..." : ""
}`;
}
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
const truncated = result.output.slice(0, 40);
return `Tool result: ${truncated}${
truncated.length >= 40 ? "..." : ""
}`;
}
// Could also be a function call
// Could be a function call
const addedEvent =
event as import("@/types").ResponseOutputItemAddedEvent;
if (addedEvent.item.type === "function_call") {
@@ -422,7 +467,8 @@ function getEventSummary(event: ExtendedResponseStreamEvent): string {
case "response.completed":
if ("response" in event && event.response && "usage" in event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const completedEvent =
event as import("@/types").ResponseCompletedEvent;
const usage = completedEvent.response.usage;
if (usage) {
return `Response complete (${usage.total_tokens} tokens)`;
@@ -453,6 +499,8 @@ function getEventIcon(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return Wrench;
case "response.function_result.complete":
return CheckCircle2;
case "response.output_item.added":
return CheckCircle2;
case "response.workflow_event.complete":
@@ -479,6 +527,8 @@ function getEventColor(type: string) {
case "response.function_call.delta":
case "response.function_call_arguments.delta":
return "text-blue-600 dark:text-blue-400";
case "response.function_result.complete":
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.complete":
@@ -509,6 +559,7 @@ function EventItem({ event }: EventItemProps) {
(event.type === "response.function_call.complete" &&
"data" in event &&
event.data) ||
event.type === "response.function_result.complete" ||
(event.type === "response.output_item.added" &&
getFunctionResultFromEvent(event) !== null) ||
(event.type === "response.workflow_event.complete" &&
@@ -681,6 +732,53 @@ function EventExpandedContent({
}
break;
case "response.function_result.complete": {
const resultEvent =
event as import("@/types").ResponseFunctionResultComplete;
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="font-semibold text-sm">Function Result</span>
</div>
<div className="grid grid-cols-1 gap-2 text-xs">
<div>
<span className="font-medium text-muted-foreground">
Call ID:
</span>
<span className="ml-2 font-mono text-xs">
{resultEvent.call_id}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Status:
</span>
<span
className={`ml-2 px-2 py-1 rounded text-xs font-medium ${
resultEvent.status === "completed"
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200"
: "bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200"
}`}
>
{resultEvent.status}
</span>
</div>
<div>
<span className="font-medium text-muted-foreground">
Output:
</span>
<div className="mt-1 max-h-32 overflow-auto">
<pre className="text-xs bg-background border rounded p-2 whitespace-pre-wrap max-w-full break-all">
{resultEvent.output}
</pre>
</div>
</div>
</div>
</div>
);
}
case "response.output_item.added": {
const result = getFunctionResultFromEvent(event);
if (result) {
@@ -915,7 +1013,8 @@ function EventExpandedContent({
case "response.completed":
if ("response" in event && event.response) {
const completedEvent = event as import("@/types").ResponseCompletedEvent;
const completedEvent =
event as import("@/types").ResponseCompletedEvent;
const response = completedEvent.response;
return (
<div className="space-y-2">
@@ -1006,11 +1105,14 @@ function EventsTab({
// Process events to accumulate tool calls and reduce noise
const processedEvents = processEventsForDisplay(events);
// Add separators between message rounds
const eventsWithSeparators = addSeparatorsToEvents(processedEvents);
// Reverse events so latest appears at top
const reversedEvents = [...processedEvents].reverse();
const reversedEvents = [...eventsWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center justify-between p-3 border-b">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4" />
@@ -1030,7 +1132,7 @@ function EventsTab({
)}
</div>
<ScrollArea ref={scrollRef}>
<ScrollArea ref={scrollRef} className="flex-1">
<div className="p-3">
{processedEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1040,9 +1142,12 @@ function EventsTab({
</div>
) : (
<div className="space-y-2">
{reversedEvents.map((event, index) => (
<EventItem key={`${event.type}-${index}`} event={event} />
))}
{reversedEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <EventItem key={`${event.type}-${index}`} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1059,18 +1164,21 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
e.type === "response.trace.complete"
);
// Add separators between message rounds
const tracesWithSeparators = addSeparatorsToEvents(traceEvents);
// Reverse to show latest traces at the top
const reversedTraceEvents = [...traceEvents].reverse();
const reversedTraceEvents = [...tracesWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 p-3 border-b">
<Search className="h-4 w-4" />
<span className="font-medium">Traces</span>
<Badge variant="outline">{traceEvents.length}</Badge>
</div>
<ScrollArea className="">
<ScrollArea className="flex-1">
<div className="p-3">
{traceEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1094,9 +1202,12 @@ function TracesTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
</div>
) : (
<div className="space-y-3">
{reversedTraceEvents.map((event, index) => (
<TraceEventItem key={index} event={event} />
))}
{reversedTraceEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <TraceEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1165,7 +1276,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<ChevronRight className="h-3 w-3" />
)}
</div>
<div className="text-muted-foreground flex-1">
<div className="text-muted-foreground flex-1 break-all">
<span className="font-medium">{operationName}</span>
{entityId && <span className="ml-2 text-xs">({entityId})</span>}
</div>
@@ -1193,7 +1304,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Span ID:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.span_id}
</span>
</div>
@@ -1203,7 +1314,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Trace ID:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.trace_id}
</span>
</div>
@@ -1213,7 +1324,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Parent Span:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.parent_span_id}
</span>
</div>
@@ -1250,7 +1361,7 @@ function TraceEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-muted-foreground">
Entity:
</span>
<span className="ml-2 font-mono text-xs">
<span className="ml-2 font-mono text-xs break-all">
{data.entity_id}
</span>
</div>
@@ -1338,18 +1449,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
toolEvents.push(result);
});
// Add separators between message rounds
const toolsWithSeparators = addSeparatorsToEvents(toolEvents);
// Reverse to show latest tools at the top
const reversedToolEvents = [...toolEvents].reverse();
const reversedToolEvents = [...toolsWithSeparators].reverse();
return (
<div className="h-full">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 p-3 border-b">
<Wrench className="h-4 w-4" />
<span className="font-medium">Tools</span>
<Badge variant="outline">{toolEvents.length}</Badge>
</div>
<ScrollArea>
<ScrollArea className="flex-1">
<div className="p-3">
{toolEvents.length === 0 ? (
<div className="text-center text-muted-foreground text-sm py-8">
@@ -1358,9 +1472,12 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
</div>
) : (
<div className="space-y-3">
{reversedToolEvents.map((event, index) => (
<ToolEventItem key={index} event={event} />
))}
{reversedToolEvents.map((event, index) => {
if ('type' in event && event.type === "separator") {
return <MessageSeparator key={(event as { type: "separator"; id: string }).id} />;
}
return <ToolEventItem key={index} event={event as ExtendedResponseStreamEvent} />;
})}
</div>
)}
</div>
@@ -1370,21 +1487,21 @@ function ToolsTab({ events }: { events: ExtendedResponseStreamEvent[] }) {
}
function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
if (!("data" in event)) {
return null;
}
const data = event.data as EventDataBase;
const timestamp = new Date().toLocaleTimeString();
// Check if this is a function call event
// Check if this is a function call or result event
const isFunctionCall = event.type === "response.function_call.complete";
const isFunctionResult = getFunctionResultFromEvent(event) !== null;
const resultData = getFunctionResultFromEvent(event);
const isFunctionResult = resultData !== null;
if (!isFunctionCall && !isFunctionResult) {
return null;
}
// For function calls: extract data field
const callData =
isFunctionCall && "data" in event ? (event.data as EventDataBase) : null;
return (
<div className="border rounded p-3">
<div className="flex items-center justify-between mb-2">
@@ -1393,9 +1510,9 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
<span className="font-medium text-sm">
{isFunctionCall ? "Tool Call" : "Tool Result"}
</span>
{isFunctionCall && data.name !== undefined && (
{isFunctionCall && callData && callData.name !== undefined && (
<span className="text-xs text-muted-foreground">
({String(data.name)})
({String(callData.name)})
</span>
)}
</div>
@@ -1405,7 +1522,7 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
</div>
{/* Function Calls */}
{isFunctionCall && (
{isFunctionCall && callData && (
<div className="p-2 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded">
<div className="flex items-center gap-2 mb-2">
<Wrench className="h-3 w-3 text-blue-600 dark:text-blue-400" />
@@ -1413,19 +1530,19 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
CALL
</span>
<span className="font-medium text-sm">
{String(data.name || "unknown")}
{String(callData.name || "unknown")}
</span>
</div>
{data.arguments !== undefined && (
{callData.arguments !== undefined && (
<div className="text-xs">
<span className="text-muted-foreground mb-1 block">
Arguments:
</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
{typeof data.arguments === "string"
? data.arguments
: JSON.stringify(data.arguments, null, 1)}
{typeof callData.arguments === "string"
? callData.arguments
: JSON.stringify(callData.arguments, null, 1)}
</pre>
</div>
)}
@@ -1433,31 +1550,35 @@ function ToolEventItem({ event }: { event: ExtendedResponseStreamEvent }) {
)}
{/* Function Results */}
{isFunctionResult && (
{isFunctionResult && resultData && (
<div className="p-2 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded">
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 className="h-3 w-3 text-green-600 dark:text-green-400" />
<span className="text-xs font-mono bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 px-2 py-1 rounded">
RESULT
</span>
{/* Only show status badge for non-completed states (errors/incomplete) */}
{resultData.status !== "completed" && (
<span className="ml-auto px-2 py-1 rounded text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
{resultData.status}
</span>
)}
</div>
<div className="text-xs">
<span className="text-muted-foreground mb-1 block">Result:</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 max-w-full break-all whitespace-pre-wrap">
{typeof data.result === "string"
? data.result
: JSON.stringify(data.result, null, 1)}
</pre>
</div>
{data.exception !== null && data.exception !== undefined && (
<div className="mt-2 p-2 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded">
<span className="text-xs text-red-600 dark:text-red-400">
Error: {String(data.exception)}
<div className="text-xs space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Call ID:</span>
<span className="font-mono text-xs break-all">
{resultData.call_id}
</span>
</div>
)}
<div>
<span className="text-muted-foreground block mb-1">Output:</span>
<pre className="p-2 bg-background border rounded text-xs overflow-auto max-h-32 break-all whitespace-pre-wrap">
{resultData.output}
</pre>
</div>
</div>
</div>
)}
</div>
@@ -1470,9 +1591,9 @@ export function DebugPanel({
onClose,
}: DebugPanelProps) {
return (
<div className=" overflow-auto h-[calc(100vh-3.7rem)] border-l">
<Tabs defaultValue="events" className="h-full flex flex-col">
<div className="px-3 pt-3 flex items-center gap-2">
<div className="flex-1 border-l flex flex-col min-h-0">
<Tabs defaultValue="events" className="flex-1 flex flex-col min-h-0">
<div className="px-3 pt-3 flex items-center gap-2 flex-shrink-0">
<TabsList className="flex-1">
<TabsTrigger value="events" className="flex-1">
Events
@@ -1497,15 +1618,15 @@ export function DebugPanel({
)}
</div>
<TabsContent value="events" className="flex-1 mt-0">
<TabsContent value="events" className="flex-1 mt-0 overflow-hidden">
<EventsTab events={events} isStreaming={isStreaming} />
</TabsContent>
<TabsContent value="traces" className="flex-1 mt-0">
<TabsContent value="traces" className="flex-1 mt-0 overflow-hidden">
<TracesTab events={events} />
</TabsContent>
<TabsContent value="tools" className="flex-1 mt-0">
<TabsContent value="tools" className="flex-1 mt-0 overflow-hidden">
<ToolsTab events={events} />
</TabsContent>
</Tabs>
@@ -0,0 +1,519 @@
/**
* DeploymentModal - Shows Azure deployment instructions and Docker templates
* Features: Docker setup files, Azure Container Apps deployment guide
*/
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Rocket,
Container,
Cloud,
Copy,
CheckCircle2,
ExternalLink,
} from "lucide-react";
interface DeploymentModalProps {
open: boolean;
onClose: () => void;
agentName?: string;
}
type Tab = "docker" | "azure";
export function DeploymentModal({
open,
onClose,
agentName = "Agent",
}: DeploymentModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("docker");
const [copiedTemplate, setCopiedTemplate] = useState<string | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async (template: string, templateName: string) => {
try {
await navigator.clipboard.writeText(template);
setCopiedTemplate(templateName);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout with cleanup
timeoutRef.current = setTimeout(() => {
setCopiedTemplate(null);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy template:", err);
// Reset state on error
setCopiedTemplate(null);
}
};
const dockerfileTemplate = `# Dockerfile for ${agentName}
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy agent/workflow directories
COPY . .
# Expose DevUI default port
EXPOSE 8080
# Run DevUI server
CMD ["devui", ".", "--port", "8080", "--host", "0.0.0.0"]
`;
const dockerComposeTemplate = `# docker-compose.yml
version: '3.8'
services:
${agentName.toLowerCase().replace(/\s+/g, "-")}:
build: .
environment:
# OpenAI
- OPENAI_API_KEY=\${OPENAI_API_KEY}
- OPENAI_CHAT_MODEL_ID=\${OPENAI_CHAT_MODEL_ID:-gpt-4o-mini}
# Or Azure OpenAI
- AZURE_OPENAI_API_KEY=\${AZURE_OPENAI_API_KEY}
- AZURE_OPENAI_ENDPOINT=\${AZURE_OPENAI_ENDPOINT}
- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=\${AZURE_OPENAI_CHAT_DEPLOYMENT_NAME}
# Optional: Enable tracing
- ENABLE_OTEL=\${ENABLE_OTEL:-false}
ports:
- "8080:8080"
restart: unless-stopped
`;
const requirementsTemplate = `# requirements.txt
agent-framework-devui>=0.1.0
agent-framework>=0.1.0
# Chat clients (install what you need)
openai>=1.0.0
# azure-openai
# anthropic
`;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="w-[800px] max-w-[90vw]">
<DialogClose onClose={onClose} />
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Deploy {agentName}
</DialogTitle>
<p className="text-sm text-muted-foreground pt-1">
Get started with containerizing your agent for deployment.
</p>
</DialogHeader>
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("docker")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "docker"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Container className="h-4 w-4 mr-2 inline" />
Docker
{activeTab === "docker" && (
<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 */}
<div className="px-6 pb-6 min-h-[400px]">
<ScrollArea className="h-[500px]">
<div className="pr-4">
{activeTab === "docker" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Containerize with Docker
</h3>
<p className="text-sm text-muted-foreground">
Package your agent as a Docker container for consistent
deployment anywhere.
</p>
</div>
{/* Dockerfile */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Dockerfile</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerfileTemplate, "dockerfile")
}
>
{copiedTemplate === "dockerfile" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerfileTemplate}
</pre>
</div>
{/* docker-compose.yml */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
docker-compose.yml
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(dockerComposeTemplate, "compose")
}
>
{copiedTemplate === "compose" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{dockerComposeTemplate}
</pre>
</div>
{/* requirements.txt */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">
requirements.txt
</span>
<Button
size="sm"
variant="ghost"
onClick={() =>
handleCopy(requirementsTemplate, "requirements")
}
>
{copiedTemplate === "requirements" ? (
<>
<CheckCircle2 className="h-4 w-4 mr-1 text-green-500" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-1" />
Copy
</>
)}
</Button>
</div>
<pre className="bg-muted p-3 rounded-md text-xs overflow-x-auto border">
{requirementsTemplate}
</pre>
</div>
{/* Quick Start */}
<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">Quick Start</h4>
<ol className="text-xs space-y-1 list-decimal list-inside text-muted-foreground">
<li>Save the files above to your project directory</li>
<li>
Build:{" "}
<code className="bg-muted px-1 rounded">
docker build -t {agentName.toLowerCase()}-agent .
</code>
</li>
<li>
Run:{" "}
<code className="bg-muted px-1 rounded">
docker-compose up
</code>
</li>
<li>Your agent is now running in a container!</li>
</ol>
</div>
{/* Production Warnings */}
<div className="bg-amber-50 dark:bg-amber-950/50 border border-amber-200 dark:border-amber-800 rounded-md p-3">
<h4 className="text-sm font-semibold mb-2 text-amber-900 dark:text-amber-100">
Production Considerations
</h4>
<ul className="text-xs space-y-1 list-disc list-inside text-amber-800 dark:text-amber-200">
<li>
<strong>In-memory state:</strong> Conversations are lost
when container restarts
</li>
<li>
<strong>No authentication:</strong> Add reverse proxy
(nginx, Caddy) with auth for production
</li>
<li>
<strong>Security:</strong> Use Azure Key Vault for
secrets management
</li>
<li>
<strong>Scaling:</strong> Single instance only due to
in-memory conversation store
</li>
</ul>
</div>
{/* Deployment Checklist */}
<div className="border-t pt-4">
<h4 className="font-semibold text-sm mb-3">
Pre-Deployment Checklist
</h4>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set environment variables (API keys, secrets)
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Test agent locally in container
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Configure logging and monitoring
</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
<span className="text-muted-foreground">
Set up error handling and retries
</span>
</div>
</div>
</div>
</div>
)}
{activeTab === "azure" && (
<div className="space-y-4 pt-4">
<div>
<h3 className="font-semibold mb-2">
Deploy to Azure Container Apps
</h3>
<p className="text-sm text-muted-foreground">
Azure Container Apps provides serverless containers with
auto-scaling and integrated monitoring.
</p>
</div>
{/* 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>
<div className="space-y-3">
{/* Step 1 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
<h5 className="font-medium text-sm">
Create Azure Container Registry
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Create resource group
az group create --name myResourceGroup --location eastus
# Create container registry
az acr create --resource-group myResourceGroup \\
--name myregistry --sku Basic`}
</pre>
</div>
{/* Step 2 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
2
</div>
<h5 className="font-medium text-sm">
Build and Push Docker Image
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`# Build and push in one command
az acr build --registry myregistry \\
--image ${agentName.toLowerCase()}-agent:latest .`}
</pre>
</div>
{/* Step 3 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
3
</div>
<h5 className="font-medium text-sm">
Create Container Apps Environment
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp env create --name myEnvironment \\
--resource-group myResourceGroup \\
--location eastus`}
</pre>
</div>
{/* Step 4 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
4
</div>
<h5 className="font-medium text-sm">
Deploy Container App
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp create --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--environment myEnvironment \\
--image myregistry.azurecr.io/${agentName.toLowerCase()}-agent:latest \\
--target-port 8080 \\
--ingress 'external' \\
--registry-server myregistry.azurecr.io \\
--env-vars OPENAI_API_KEY=secretref:openai-key OPENAI_CHAT_MODEL_ID=gpt-4o-mini`}
</pre>
</div>
{/* Step 5 */}
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2 mb-1">
<div className="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
5
</div>
<h5 className="font-medium text-sm">
Get Application URL
</h5>
</div>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto border mt-2">
{`az containerapp show --name ${agentName.toLowerCase()}-app \\
--resource-group myResourceGroup \\
--query properties.configuration.ingress.fqdn`}
</pre>
</div>
</div>
</div>
{/* Learn More */}
<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">Learn More</h4>
<p className="text-xs text-muted-foreground mb-3">
Explore Azure Container Apps documentation for advanced
features like scaling, monitoring, and CI/CD integration.
</p>
<Button
size="sm"
variant="outline"
className="w-full"
asChild
>
<a
href="https://learn.microsoft.com/azure/container-apps/"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-3 w-3 mr-1" />
View Azure Container Apps Documentation
</a>
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
/**
* EntitySelector - High-quality dropdown for selecting agents/workflows
* Features: Type indicators, tool counts, keyboard navigation, search
* EntitySelector - Dropdown for selecting agents/workflows
* Features: Loading states, descriptions, lazy loading indicators
*/
import { useState } from "react";
@@ -13,9 +13,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { ChevronDown, Bot, Workflow, FolderOpen, Database, Globe, X, Plus } from "lucide-react";
import { ChevronDown, Bot, Workflow, Plus, Loader2 } from "lucide-react";
import type { AgentInfo, WorkflowInfo } from "@/types";
interface EntitySelectorProps {
@@ -23,7 +22,6 @@ interface EntitySelectorProps {
workflows: WorkflowInfo[];
selectedItem?: AgentInfo | WorkflowInfo;
onSelect: (item: AgentInfo | WorkflowInfo) => void;
onRemove?: (entityId: string) => void;
onBrowseGallery?: () => void;
isLoading?: boolean;
}
@@ -32,30 +30,11 @@ const getTypeIcon = (type: "agent" | "workflow") => {
return type === "workflow" ? Workflow : Bot;
};
const getSourceIcon = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return FolderOpen;
case "in_memory": return Database;
case "remote_gallery": return Globe;
default: return Database;
}
};
const getSourceLabel = (source: "directory" | "in_memory" | "remote_gallery") => {
switch (source) {
case "directory": return "Local";
case "in_memory": return "Memory";
case "remote_gallery": return "Gallery";
default: return "Unknown";
}
};
export function EntitySelector({
agents,
workflows,
selectedItem,
onSelect,
onRemove,
onBrowseGallery,
isLoading = false,
}: EntitySelectorProps) {
@@ -72,11 +51,7 @@ export function EntitySelector({
const TypeIcon = selectedItem ? getTypeIcon(selectedItem.type) : Bot;
const displayName = selectedItem?.name || selectedItem?.id || "Select Agent or Workflow";
const itemCount =
selectedItem?.type === "workflow"
? (selectedItem as WorkflowInfo).executors?.length || 0
: (selectedItem as AgentInfo)?.tools?.length || 0;
const itemLabel = selectedItem?.type === "workflow" ? "executors" : "tools";
const isLoaded = selectedItem?.metadata?.lazy_loaded !== false;
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
@@ -96,10 +71,8 @@ export function EntitySelector({
<div className="flex items-center gap-2 min-w-0">
<TypeIcon className="h-4 w-4 flex-shrink-0" />
<span className="truncate">{displayName}</span>
{selectedItem && (
<Badge variant="secondary" className="ml-auto flex-shrink-0">
{itemCount} {itemLabel}
</Badge>
{selectedItem && !isLoaded && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin ml-auto flex-shrink-0" />
)}
</div>
<ChevronDown className="h-4 w-4 opacity-50" />
@@ -116,53 +89,29 @@ export function EntitySelector({
Agents ({agents.length})
</DropdownMenuLabel>
{agents.map((agent) => {
const SourceIcon = getSourceIcon(agent.source);
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">
<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">
<div className="truncate font-medium">
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{agent.name || agent.id}
</div>
{agent.description && (
<div className="text-xs text-muted-foreground truncate">
</span>
{isAgentLoaded && agent.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{agent.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(agent.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{agent.tools.length}
</Badge>
{/* Remove button for gallery entities */}
{agent.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(agent.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
);
@@ -178,53 +127,29 @@ export function EntitySelector({
Workflows ({workflows.length})
</DropdownMenuLabel>
{workflows.map((workflow) => {
const SourceIcon = getSourceIcon(workflow.source);
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">
<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">
<div className="truncate font-medium">
<div className="min-w-0 flex-1">
<span className="truncate font-medium block">
{workflow.name || workflow.id}
</div>
{workflow.description && (
<div className="text-xs text-muted-foreground truncate">
</span>
{isWorkflowLoaded && workflow.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{workflow.description}
</div>
)}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<SourceIcon className="h-3 w-3 opacity-60" />
<Badge variant="outline" className="text-xs">
{getSourceLabel(workflow.source)}
</Badge>
<Badge variant="outline" className="text-xs ml-1">
{workflow.executors.length}
</Badge>
{/* Remove button for gallery entities */}
{workflow.source === 'remote_gallery' && onRemove && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 ml-1"
onClick={(e) => {
e.stopPropagation();
onRemove(workflow.id);
}}
>
<X className="h-3 w-3 text-destructive" />
</Button>
)}
</div>
</div>
</DropdownMenuItem>
);
@@ -7,3 +7,4 @@ export { EntitySelector } from "./entity-selector";
export { DebugPanel } from "./debug-panel";
export { SettingsModal } from "./settings-modal";
export { AboutModal } from "./about-modal";
export { DeploymentModal } from "./deployment-modal";
@@ -24,7 +24,7 @@ interface SettingsModalProps {
type Tab = "about" | "settings";
export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: SettingsModalProps) {
const [activeTab, setActiveTab] = useState<Tab>("about");
const [activeTab, setActiveTab] = useState<Tab>("settings");
// Get current backend URL from localStorage or default
const defaultUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8080";
@@ -73,19 +73,6 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
{/* Tabs */}
<div className="flex border-b px-6">
<button
onClick={() => setActiveTab("about")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
<button
onClick={() => setActiveTab("settings")}
className={`px-4 py-2 text-sm font-medium transition-colors relative ${
@@ -99,35 +86,23 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
<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 ${
activeTab === "about"
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
About
{activeTab === "about" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />
)}
</button>
</div>
{/* Tab Content */}
<div className="px-6 pb-6 min-h-[240px]">
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
{activeTab === "settings" && (
<div className="space-y-6 pt-4">
{/* Backend URL Setting */}
@@ -188,6 +163,31 @@ export function SettingsModal({ open, onOpenChange, onBackendUrlChange }: Settin
</div>
</div>
)}
{activeTab === "about" && (
<div className="space-y-4 pt-4">
<p className="text-sm text-muted-foreground">
DevUI is a sample app for getting started with Agent Framework.
</p>
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
"https://github.com/microsoft/agent-framework",
"_blank"
)
}
className="text-xs"
>
<ExternalLink className="h-3 w-3 mr-1" />
Learn More about Agent Framework
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,48 @@
/**
* Alert component - Simple alert/callout component
*/
import * as React from "react";
import { cn } from "@/lib/utils";
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
className
)}
{...props}
/>
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
));
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
));
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };
@@ -0,0 +1,565 @@
/**
* Lightweight Markdown Renderer
*
* A minimal markdown renderer with zero dependencies for rendering LLM responses.
* Handles the most common markdown patterns without bloating bundle size.
*
* Supported syntax:
* - **bold** and __bold__
* - *italic* and _italic_
* - `inline code`
* - ```code blocks``` (with copy button on hover)
* - [links](url)
* - **[bold links](url)** and *[italic links](url)*
* - # Headers (H1-H6)
* - Lists (ordered and unordered)
* - > Blockquotes
* - Tables (| col1 | col2 |)
* - Horizontal rules (---)
*/
import React, { useState } from "react";
interface MarkdownRendererProps {
content: string;
className?: string;
}
interface CodeBlockProps {
code: string;
language?: string;
}
/**
* Code block component with copy button
*/
function CodeBlock({ code, language }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Cleanup timeout on unmount
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout and store reference
timeoutRef.current = setTimeout(() => {
setCopied(false);
timeoutRef.current = null;
}, 2000);
} catch (err) {
console.error("Failed to copy code:", err);
}
};
return (
<div className="relative group">
<pre className="my-3 p-3 bg-foreground/5 dark:bg-foreground/10 rounded overflow-x-auto border border-foreground/10">
<code className="text-xs font-mono block whitespace-pre-wrap break-words">
{language && (
<span className="opacity-60 text-[10px] mb-1 block uppercase">
{language}
</span>
)}
{code}
</code>
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1.5 rounded-md border shadow-sm
bg-background hover:bg-accent
text-muted-foreground hover:text-foreground
transition-all duration-200
opacity-0 group-hover:opacity-100"
title={copied ? "Copied!" : "Copy code"}
>
{copied ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-green-600 dark:text-green-400"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
)}
</button>
</div>
);
}
/**
* Parse markdown text into React elements
*/
export function MarkdownRenderer({
content,
className = "",
}: MarkdownRendererProps) {
const lines = content.split("\n");
const elements: React.ReactNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Code blocks (multiline)
if (line.trim().startsWith("```")) {
const codeLines: string[] = [];
const langMatch = line.trim().match(/^```(\w+)?/);
const language = langMatch?.[1] || "";
i++; // Skip opening ```
while (i < lines.length && !lines[i].trim().startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // Skip closing ```
elements.push(
<CodeBlock
key={elements.length}
code={codeLines.join("\n")}
language={language}
/>
);
continue;
}
// Headers
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2];
const sizes = [
"text-2xl",
"text-xl",
"text-lg",
"text-base",
"text-sm",
"text-sm",
];
const className = `${
sizes[level - 1]
} font-semibold mt-4 mb-2 first:mt-0 break-words`;
// Render appropriate header level
const header =
level === 1 ? (
<h1 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h1>
) : level === 2 ? (
<h2 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h2>
) : level === 3 ? (
<h3 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h3>
) : level === 4 ? (
<h4 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h4>
) : level === 5 ? (
<h5 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h5>
) : (
<h6 key={elements.length} className={className}>
{parseInlineMarkdown(text)}
</h6>
);
elements.push(header);
i++;
continue;
}
// Unordered lists
if (line.match(/^[\s]*[-*+]\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*[-*+]\s+/)) {
const itemText = lines[i].replace(/^[\s]*[-*+]\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ul
key={elements.length}
className="my-2 ml-4 list-disc space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ul>
);
continue;
}
// Ordered lists
if (line.match(/^[\s]*\d+\.\s+/)) {
const listItems: string[] = [];
while (i < lines.length && lines[i].match(/^[\s]*\d+\.\s+/)) {
const itemText = lines[i].replace(/^[\s]*\d+\.\s+/, "");
listItems.push(itemText);
i++;
}
elements.push(
<ol
key={elements.length}
className="my-2 ml-4 list-decimal space-y-1 break-words"
>
{listItems.map((item, idx) => (
<li key={idx} className="text-sm break-words">
{parseInlineMarkdown(item)}
</li>
))}
</ol>
);
continue;
}
// Tables
if (line.trim().startsWith("|") && line.trim().endsWith("|")) {
const tableLines: string[] = [];
// Collect all table lines
while (
i < lines.length &&
lines[i].trim().startsWith("|") &&
lines[i].trim().endsWith("|")
) {
tableLines.push(lines[i].trim());
i++;
}
// Parse table (need at least 2 lines: header + separator)
if (tableLines.length >= 2) {
const headerCells = tableLines[0]
.split("|")
.slice(1, -1)
.map((cell) => cell.trim());
// Check if second line is a separator (contains dashes)
const isSeparator = tableLines[1].match(/^\|[\s\-:|]+\|$/);
if (isSeparator) {
const bodyRows = tableLines.slice(2).map((row) =>
row
.split("|")
.slice(1, -1)
.map((cell) => cell.trim())
);
elements.push(
<div key={elements.length} className="my-3 overflow-x-auto">
<table className="min-w-full border border-foreground/10 text-sm">
<thead className="bg-foreground/5">
<tr>
{headerCells.map((header, idx) => (
<th
key={idx}
className="border-b border-foreground/10 px-3 py-2 text-left font-semibold break-words"
>
{parseInlineMarkdown(header)}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIdx) => (
<tr
key={rowIdx}
className="border-b border-foreground/5 last:border-b-0"
>
{row.map((cell, cellIdx) => (
<td
key={cellIdx}
className="px-3 py-2 border-r border-foreground/5 last:border-r-0 break-words"
>
{parseInlineMarkdown(cell)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
continue;
}
}
// Not a valid table, render as regular paragraphs
for (const tableLine of tableLines) {
elements.push(
<p key={elements.length} className="my-1">
{parseInlineMarkdown(tableLine)}
</p>
);
}
continue;
}
// Blockquotes
if (line.trim().startsWith(">")) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].trim().startsWith(">")) {
quoteLines.push(lines[i].replace(/^>\s?/, ""));
i++;
}
elements.push(
<blockquote
key={elements.length}
className="my-2 pl-4 border-l-4 border-current/30 opacity-80 italic break-words"
>
{quoteLines.map((quoteLine, idx) => (
<div key={idx} className="break-words">
{parseInlineMarkdown(quoteLine)}
</div>
))}
</blockquote>
);
continue;
}
// Horizontal rule
if (line.match(/^[\s]*[-*_]{3,}[\s]*$/)) {
elements.push(
<hr key={elements.length} className="my-4 border-t border-border" />
);
i++;
continue;
}
// Empty line
if (line.trim() === "") {
elements.push(<div key={elements.length} className="h-2" />);
i++;
continue;
}
// Regular paragraph
elements.push(
<p key={elements.length} className="my-1 break-words">
{parseInlineMarkdown(line)}
</p>
);
i++;
}
return (
<div className={`markdown-content break-words ${className}`}>
{elements}
</div>
);
}
/**
* Parse inline markdown patterns (bold, italic, code, links)
*/
function parseInlineMarkdown(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
// Pattern priority: code > bold > italic > links
// This prevents conflicts between overlapping patterns
while (remaining.length > 0) {
// Inline code (highest priority to avoid parsing inside code)
const codeMatch = remaining.match(/`([^`]+)`/);
if (codeMatch && codeMatch.index !== undefined) {
// Add text before code
if (codeMatch.index > 0) {
parts.push(
<span key={key++}>
{parseBoldItalicLinks(remaining.slice(0, codeMatch.index))}
</span>
);
}
// Add code
parts.push(
<code
key={key++}
className="px-1.5 py-0.5 bg-foreground/10 rounded text-xs font-mono border border-foreground/20"
>
{codeMatch[1]}
</code>
);
remaining = remaining.slice(codeMatch.index + codeMatch[0].length);
continue;
}
// No more special patterns, parse remaining text for bold/italic/links
parts.push(<span key={key++}>{parseBoldItalicLinks(remaining)}</span>);
break;
}
return parts;
}
/**
* Parse bold, italic, and links (after code has been extracted)
*/
function parseBoldItalicLinks(text: string): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Try to match patterns in order
// IMPORTANT: Handle **[link](url)** pattern first (bold markers around link)
const patterns = [
{ regex: /\*\*\[([^\]]+)\]\(([^)]+)\)\*\*/, component: "strong-link" }, // **[text](url)**
{ regex: /__\[([^\]]+)\]\(([^)]+)\)__/, component: "strong-link" }, // __[text](url)__
{ regex: /\*\[([^\]]+)\]\(([^)]+)\)\*/, component: "em-link" }, // *[text](url)*
{ regex: /_\[([^\]]+)\]\(([^)]+)\)_/, component: "em-link" }, // _[text](url)_
{ regex: /\[([^\]]+)\]\(([^)]+)\)/, component: "link" }, // [text](url)
{ regex: /\*\*(.+?)\*\*/, component: "strong" }, // **bold**
{ regex: /__(.+?)__/, component: "strong" }, // __bold__
{ regex: /\*(.+?)\*/, component: "em" }, // *italic*
{ regex: /_(.+?)_/, component: "em" }, // _italic_
];
let matched = false;
for (const pattern of patterns) {
const match = remaining.match(pattern.regex);
if (match && match.index !== undefined) {
// Add text before match
if (match.index > 0) {
parts.push(remaining.slice(0, match.index));
}
// Add matched element
if (pattern.component === "strong") {
parts.push(
<strong key={key++} className="font-semibold">
{match[1]}
</strong>
);
} else if (pattern.component === "em") {
parts.push(
<em key={key++} className="italic">
{match[1]}
</em>
);
} else if (pattern.component === "strong-link") {
// **[text](url)** - Bold link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<strong key={key++} className="font-semibold">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</strong>
);
} else if (pattern.component === "em-link") {
// *[text](url)* - Italic link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<em key={key++} className="italic">
<a
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
</em>
);
} else if (pattern.component === "link") {
// [text](url) - Regular link
const linkText = match[1];
const linkUrl = match[2];
const formattedLinkText = parseBoldItalicLinks(linkText);
parts.push(
<a
key={key++}
href={linkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline break-words"
>
{formattedLinkText}
</a>
);
}
remaining = remaining.slice(match.index + match[0].length);
matched = true;
break;
}
}
// No pattern matched, add remaining text and exit
if (!matched) {
if (remaining.length > 0) {
parts.push(remaining);
}
break;
}
}
return parts;
}
@@ -26,7 +26,6 @@ interface BackendEntityInfo {
tools?: (string | Record<string, unknown>)[];
metadata: Record<string, unknown>;
source?: string;
original_url?: string;
// Agent-specific fields (present when type === "agent")
instructions?: string;
model?: string;
@@ -143,6 +142,7 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
// Agent-specific fields
instructions: entity.instructions,
model: entity.model,
@@ -168,6 +168,7 @@ class ApiClient {
typeof entity.metadata?.module_path === "string"
? entity.metadata.module_path
: undefined,
metadata: entity.metadata, // Preserve metadata including lazy_loaded flag
input_schema:
(entity.input_schema as unknown as import("@/types").JSONSchema) || {
type: "string",
@@ -504,31 +505,6 @@ class ApiClient {
body: JSON.stringify(request),
});
}
// Add entity from URL
async addEntity(url: string, metadata?: Record<string, unknown>): Promise<BackendEntityInfo> {
const response = await this.request<{ success: boolean; entity: BackendEntityInfo }>("/v1/entities/add", {
method: "POST",
body: JSON.stringify({ url, metadata }),
});
if (!response.success || !response.entity) {
throw new Error("Failed to add entity");
}
return response.entity;
}
// Remove entity by ID
async removeEntity(entityId: string): Promise<void> {
const response = await this.request<{ success: boolean }>(`/v1/entities/${entityId}`, {
method: "DELETE",
});
if (!response.success) {
throw new Error("Failed to remove entity");
}
}
}
// Export singleton instance
@@ -0,0 +1,309 @@
/**
* DevUI Unified Store - Single source of truth for all app state
* Organized into logical slices: entity, conversation, UI, gallery, modals
*/
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
Conversation,
PendingApproval,
} from "@/types";
import type { ConversationItem } from "@/types/openai";
import type { AttachmentItem } from "@/components/ui/attachment-gallery";
// ========================================
// State Interface
// ========================================
interface DevUIState {
// Entity Management Slice
agents: AgentInfo[];
workflows: WorkflowInfo[];
selectedAgent: AgentInfo | WorkflowInfo | undefined;
isLoadingEntities: boolean;
entityError: string | null;
// Conversation Slice (per-agent state)
currentConversation: Conversation | undefined;
availableConversations: Conversation[];
chatItems: ConversationItem[];
isStreaming: boolean;
isSubmitting: boolean;
loadingConversations: boolean;
inputValue: string;
attachments: AttachmentItem[];
conversationUsage: {
total_tokens: number;
message_count: number;
};
pendingApprovals: PendingApproval[];
// UI Slice
showDebugPanel: boolean;
debugPanelWidth: number;
debugEvents: ExtendedResponseStreamEvent[];
isResizing: boolean;
// Modal Slice
showAboutModal: boolean;
showGallery: boolean;
showDeployModal: boolean;
showEntityNotFoundToast: boolean;
}
// ========================================
// Actions Interface
// ========================================
interface DevUIActions {
// Entity Actions
setAgents: (agents: AgentInfo[]) => void;
setWorkflows: (workflows: WorkflowInfo[]) => void;
setSelectedAgent: (agent: AgentInfo | WorkflowInfo | undefined) => void;
addAgent: (agent: AgentInfo) => void;
addWorkflow: (workflow: WorkflowInfo) => void;
updateAgent: (agent: AgentInfo) => void;
updateWorkflow: (workflow: WorkflowInfo) => void;
removeEntity: (entityId: string) => void;
setEntityError: (error: string | null) => void;
setIsLoadingEntities: (loading: boolean) => void;
// Conversation Actions
setCurrentConversation: (conv: Conversation | undefined) => void;
setAvailableConversations: (convs: Conversation[]) => void;
setChatItems: (items: ConversationItem[]) => void;
setIsStreaming: (streaming: boolean) => void;
setIsSubmitting: (submitting: boolean) => void;
setLoadingConversations: (loading: boolean) => void;
setInputValue: (value: string) => void;
setAttachments: (files: AttachmentItem[]) => void;
updateConversationUsage: (tokens: number) => void;
setPendingApprovals: (approvals: PendingApproval[]) => void;
// UI Actions
setShowDebugPanel: (show: boolean) => void;
setDebugPanelWidth: (width: number) => void;
addDebugEvent: (event: ExtendedResponseStreamEvent) => void;
clearDebugEvents: () => void;
setIsResizing: (resizing: boolean) => void;
// Modal Actions
setShowAboutModal: (show: boolean) => void;
setShowGallery: (show: boolean) => void;
setShowDeployModal: (show: boolean) => void;
setShowEntityNotFoundToast: (show: boolean) => void;
// Combined Actions (handle multiple state updates + side effects)
selectEntity: (entity: AgentInfo | WorkflowInfo) => void;
}
type DevUIStore = DevUIState & DevUIActions;
// ========================================
// Store Implementation
// ========================================
export const useDevUIStore = create<DevUIStore>()(
devtools(
persist(
(set) => ({
// ========================================
// Initial State
// ========================================
// Entity State
agents: [],
workflows: [],
selectedAgent: undefined,
isLoadingEntities: true,
entityError: null,
// Conversation State
currentConversation: undefined,
availableConversations: [],
chatItems: [],
isStreaming: false,
isSubmitting: false,
loadingConversations: false,
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
pendingApprovals: [],
// UI State
showDebugPanel: true,
debugPanelWidth: 320,
debugEvents: [],
isResizing: false,
// Modal State
showAboutModal: false,
showGallery: false,
showDeployModal: false,
showEntityNotFoundToast: false,
// ========================================
// Entity Actions
// ========================================
setAgents: (agents) => set({ agents }),
setWorkflows: (workflows) => set({ workflows }),
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
addAgent: (agent) =>
set((state) => ({ agents: [...state.agents, agent] })),
addWorkflow: (workflow) =>
set((state) => ({ workflows: [...state.workflows, workflow] })),
updateAgent: (updatedAgent) =>
set((state) => ({
agents: state.agents.map((a) =>
a.id === updatedAgent.id ? updatedAgent : a
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedAgent.id &&
state.selectedAgent.type === "agent"
? updatedAgent
: state.selectedAgent,
})),
updateWorkflow: (updatedWorkflow) =>
set((state) => ({
workflows: state.workflows.map((w) =>
w.id === updatedWorkflow.id ? updatedWorkflow : w
),
// Also update selectedAgent if it's the same one
selectedAgent:
state.selectedAgent?.id === updatedWorkflow.id &&
state.selectedAgent.type === "workflow"
? updatedWorkflow
: state.selectedAgent,
})),
removeEntity: (entityId) =>
set((state) => ({
agents: state.agents.filter((a) => a.id !== entityId),
workflows: state.workflows.filter((w) => w.id !== entityId),
selectedAgent:
state.selectedAgent?.id === entityId
? undefined
: state.selectedAgent,
})),
setEntityError: (error) => set({ entityError: error }),
setIsLoadingEntities: (loading) => set({ isLoadingEntities: loading }),
// ========================================
// Conversation Actions
// ========================================
setCurrentConversation: (conv) => set({ currentConversation: conv }),
setAvailableConversations: (convs) =>
set({ availableConversations: convs }),
setChatItems: (items) => set({ chatItems: items }),
setIsStreaming: (streaming) => set({ isStreaming: streaming }),
setIsSubmitting: (submitting) => set({ isSubmitting: submitting }),
setLoadingConversations: (loading) =>
set({ loadingConversations: loading }),
setInputValue: (value) => set({ inputValue: value }),
setAttachments: (files) => set({ attachments: files }),
updateConversationUsage: (tokens) =>
set((state) => ({
conversationUsage: {
total_tokens: state.conversationUsage.total_tokens + tokens,
message_count: state.conversationUsage.message_count + 1,
},
})),
setPendingApprovals: (approvals) => set({ pendingApprovals: approvals }),
// ========================================
// UI Actions
// ========================================
setShowDebugPanel: (show) => set({ showDebugPanel: show }),
setDebugPanelWidth: (width) => set({ debugPanelWidth: width }),
addDebugEvent: (event) =>
set((state) => ({ debugEvents: [...state.debugEvents, event] })),
clearDebugEvents: () => set({ debugEvents: [] }),
setIsResizing: (resizing) => set({ isResizing: resizing }),
// ========================================
// Modal Actions
// ========================================
setShowAboutModal: (show) => set({ showAboutModal: show }),
setShowGallery: (show) => set({ showGallery: show }),
setShowDeployModal: (show) => set({ showDeployModal: show }),
setShowEntityNotFoundToast: (show) =>
set({ showEntityNotFoundToast: show }),
// ========================================
// Combined Actions
// ========================================
/**
* Select an entity (agent/workflow) and handle all side effects:
* - Update selected entity
* - Clear conversation state (FIXES THE BUG!)
* - Clear debug events
* - Update URL
*/
selectEntity: (entity) => {
set({
selectedAgent: entity,
// CRITICAL: Clear all conversation state when switching entities
currentConversation: undefined,
availableConversations: [], // Let AgentView reload conversations
chatItems: [],
inputValue: "",
attachments: [],
conversationUsage: { total_tokens: 0, message_count: 0 },
isStreaming: false,
isSubmitting: false,
pendingApprovals: [],
// Clear debug events when switching
debugEvents: [],
});
// Update URL with selected entity ID
const url = new URL(window.location.href);
url.searchParams.set("entity_id", entity.id);
window.history.pushState({}, "", url);
},
}),
{
name: "devui-storage",
// Only persist UI preferences, not runtime state
partialize: (state) => ({
showDebugPanel: state.showDebugPanel,
debugPanelWidth: state.debugPanelWidth,
}),
}
),
{ name: "DevUI Store" }
)
);
// ========================================
// Usage Notes
// ========================================
/**
* How to use the store:
*
* 1. For state access, use direct selectors:
* const agents = useDevUIStore((state) => state.agents);
*
* 2. For actions, extract them:
* const setAgents = useDevUIStore((state) => state.setAgents);
*
* 3. For combined state access (use sparingly, can cause unnecessary re-renders):
* const { agents, workflows } = useDevUIStore((state) => ({
* agents: state.agents,
* workflows: state.workflows
* }));
*
* 4. To access state outside React components:
* useDevUIStore.getState().agents
* useDevUIStore.getState().setAgents([...])
*/
@@ -0,0 +1,5 @@
/**
* Store exports - single entry point for all store hooks
*/
export { useDevUIStore } from "./devuiStore";
@@ -31,6 +31,7 @@ export interface AgentInfo {
has_env: boolean;
module_path?: string;
required_env_vars?: EnvVarRequirement[];
metadata?: Record<string, unknown>; // Backend metadata including lazy_loaded flag
// Agent-specific fields
instructions?: string;
model?: string;
@@ -103,8 +104,8 @@ export type {
ResponseWorkflowEventComplete,
ResponseTraceEventComplete,
ResponseOutputItemAddedEvent,
ResponseFunctionResultComplete,
ResponseCompletedEvent,
ResponseFunctionResultComplete,
StructuredEvent,
} from "./openai";
@@ -36,17 +36,6 @@ export interface ResponseWorkflowEventComplete {
sequence_number: number;
}
// Custom DevUI: Function result event
// This is a DevUI extension - OpenAI doesn't stream function execution results
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
call_id: string;
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
}
// Function call event types - matching actual backend output
export interface ResponseFunctionCallComplete {
@@ -173,6 +162,24 @@ export interface ResponseFunctionApprovalRespondedEvent {
sequence_number: number;
}
// DevUI Extension: Function Result Complete
export interface ResponseFunctionResultComplete {
type: "response.function_result.complete";
call_id: string;
output: string;
status: "in_progress" | "completed" | "incomplete";
item_id: string;
output_index: number;
sequence_number: number;
}
// DevUI Extension: Turn Separator (UI-only event for grouping)
export interface TurnSeparatorEvent {
type: "debug.turn_separator";
timestamp: string;
collapsed?: boolean;
}
// Union type for all structured events
export type StructuredEvent =
| ResponseCompletedEvent
@@ -180,13 +187,14 @@ export type StructuredEvent =
| ResponseTraceEventComplete
| ResponseTraceComplete
| ResponseOutputItemAddedEvent
| ResponseFunctionResultComplete
| ResponseFunctionCallComplete
| ResponseFunctionCallDelta
| ResponseFunctionCallArgumentsDelta
| ResponseFunctionResultComplete
| ResponseErrorEvent
| ResponseFunctionApprovalRequestedEvent
| ResponseFunctionApprovalRespondedEvent;
| ResponseFunctionApprovalRespondedEvent
| TurnSeparatorEvent;
// Extended stream event that includes our structured events
export type ExtendedResponseStreamEvent = ResponseStreamEvent | StructuredEvent;
@@ -16,19 +16,9 @@ export default defineConfig({
emptyOutDir: true,
rollupOptions: {
output: {
// Minimize to just 2 files: main app + CSS
manualChunks: undefined,
// Ensure everything goes into a single JS file
inlineDynamicImports: true,
},
},
},
// Ensure proper tree-shaking
optimizeDeps: {
include: ["lucide-react", "@xyflow/react"],
},
// Enable aggressive tree-shaking
esbuild: {
treeShaking: true,
},
});
+6 -1
View File
@@ -2063,7 +2063,7 @@ natural-compare@^1.4.0:
next-themes@^0.4.6:
version "0.4.6"
resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.4.6.tgz#8d7e92d03b8fea6582892a50a928c9b23502e8b6"
integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==
node-releases@^2.0.19:
@@ -2453,3 +2453,8 @@ zustand@^4.4.0:
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zustand@^5.0.8:
version "5.0.8"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.8.tgz#b998a0c088c7027a20f2709141a91cb07ac57f8a"
integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==