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
@@ -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)} />