feat(ui): enhance agent interface with modal viewer and improved icon picker

- Add AgentRunOutputViewer modal component for inline run preview
- Implement IconPicker component with expanded icon selection
- Refactor AgentRunsList to use modal instead of full-page navigation
- Improve CreateAgent form layout with better grid structure
- Update agent icon system to support wider range of icons
- Enhance UI components with better animations and styling
- Add scroll-area component for better content scrolling
- Remove unused AgentRunView in favor of modal approach
This commit is contained in:
Mufeed VH
2025-06-25 01:31:24 +05:30
parent 5d69b449be
commit e878be2faa
16 changed files with 1608 additions and 300 deletions

View File

@@ -0,0 +1,665 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
X,
Maximize2,
Minimize2,
Copy,
RefreshCw,
RotateCcw,
ChevronDown,
Bot,
Clock,
Hash,
DollarSign,
ExternalLink
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Toast, ToastContainer } from '@/components/ui/toast';
import { Popover } from '@/components/ui/popover';
import { api, type AgentRunWithMetrics } from '@/lib/api';
import { useOutputCache } from '@/lib/outputCache';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { StreamMessage } from './StreamMessage';
import { ErrorBoundary } from './ErrorBoundary';
import { formatISOTimestamp } from '@/lib/date-utils';
import { AGENT_ICONS } from './CCAgents';
import type { ClaudeStreamMessage } from './AgentExecution';
interface AgentRunOutputViewerProps {
/**
* The agent run to display
*/
run: AgentRunWithMetrics;
/**
* Callback when the viewer is closed
*/
onClose: () => void;
/**
* Optional callback to open full view
*/
onOpenFullView?: () => void;
/**
* Optional className for styling
*/
className?: string;
}
/**
* AgentRunOutputViewer - Modal component for viewing agent execution output
*
* @example
* <AgentRunOutputViewer
* run={agentRun}
* onClose={() => setSelectedRun(null)}
* />
*/
export function AgentRunOutputViewer({
run,
onClose,
onOpenFullView,
className
}: AgentRunOutputViewerProps) {
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
const [hasUserScrolled, setHasUserScrolled] = useState(false);
const scrollAreaRef = useRef<HTMLDivElement>(null);
const outputEndRef = useRef<HTMLDivElement>(null);
const fullscreenScrollRef = useRef<HTMLDivElement>(null);
const fullscreenMessagesEndRef = useRef<HTMLDivElement>(null);
const unlistenRefs = useRef<UnlistenFn[]>([]);
const { getCachedOutput, setCachedOutput } = useOutputCache();
// Auto-scroll logic
const isAtBottom = () => {
const container = isFullscreen ? fullscreenScrollRef.current : scrollAreaRef.current;
if (container) {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom < 1;
}
return true;
};
const scrollToBottom = () => {
if (!hasUserScrolled) {
const endRef = isFullscreen ? fullscreenMessagesEndRef.current : outputEndRef.current;
if (endRef) {
endRef.scrollIntoView({ behavior: 'smooth' });
}
}
};
// Clean up listeners on unmount
useEffect(() => {
return () => {
unlistenRefs.current.forEach(unlisten => unlisten());
};
}, []);
// Auto-scroll when messages change
useEffect(() => {
const shouldAutoScroll = !hasUserScrolled || isAtBottom();
if (shouldAutoScroll) {
scrollToBottom();
}
}, [messages, hasUserScrolled, isFullscreen]);
const loadOutput = async (skipCache = false) => {
if (!run.id) return;
try {
// Check cache first if not skipping cache
if (!skipCache) {
const cached = getCachedOutput(run.id);
if (cached) {
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
setRawJsonlOutput(cachedJsonlLines);
setMessages(cached.messages);
// If cache is recent (less than 5 seconds old) and session isn't running, use cache only
if (Date.now() - cached.lastUpdated < 5000 && run.status !== 'running') {
return;
}
}
}
setLoading(true);
const rawOutput = await api.getSessionOutput(run.id);
// Parse JSONL output into messages
const jsonlLines = rawOutput.split('\n').filter(line => line.trim());
setRawJsonlOutput(jsonlLines);
const parsedMessages: ClaudeStreamMessage[] = [];
for (const line of jsonlLines) {
try {
const message = JSON.parse(line) as ClaudeStreamMessage;
parsedMessages.push(message);
} catch (err) {
console.error("Failed to parse message:", err, line);
}
}
setMessages(parsedMessages);
// Update cache
setCachedOutput(run.id, {
output: rawOutput,
messages: parsedMessages,
lastUpdated: Date.now(),
status: run.status
});
// Set up live event listeners for running sessions
if (run.status === 'running') {
setupLiveEventListeners();
try {
await api.streamSessionOutput(run.id);
} catch (streamError) {
console.warn('Failed to start streaming, will poll instead:', streamError);
}
}
} catch (error) {
console.error('Failed to load agent output:', error);
setToast({ message: 'Failed to load agent output', type: 'error' });
} finally {
setLoading(false);
}
};
const setupLiveEventListeners = async () => {
try {
// Clean up existing listeners
unlistenRefs.current.forEach(unlisten => unlisten());
unlistenRefs.current = [];
// Set up live event listeners
const outputUnlisten = await listen<string>("agent-output", (event) => {
try {
// Store raw JSONL
setRawJsonlOutput(prev => [...prev, event.payload]);
// Parse and display
const message = JSON.parse(event.payload) as ClaudeStreamMessage;
setMessages(prev => [...prev, message]);
} catch (err) {
console.error("Failed to parse message:", err, event.payload);
}
});
const errorUnlisten = await listen<string>("agent-error", (event) => {
console.error("Agent error:", event.payload);
setToast({ message: event.payload, type: 'error' });
});
const completeUnlisten = await listen<boolean>("agent-complete", () => {
setToast({ message: 'Agent execution completed', type: 'success' });
});
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
} catch (error) {
console.error('Failed to set up live event listeners:', error);
}
};
// Copy functionality
const handleCopyAsJsonl = async () => {
const jsonl = rawJsonlOutput.join('\n');
await navigator.clipboard.writeText(jsonl);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as JSONL', type: 'success' });
};
const handleCopyAsMarkdown = async () => {
let markdown = `# Agent Execution: ${run.agent_name}\n\n`;
markdown += `**Task:** ${run.task}\n`;
markdown += `**Model:** ${run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
markdown += `**Date:** ${formatISOTimestamp(run.created_at)}\n`;
if (run.metrics?.duration_ms) markdown += `**Duration:** ${(run.metrics.duration_ms / 1000).toFixed(2)}s\n`;
if (run.metrics?.total_tokens) markdown += `**Total Tokens:** ${run.metrics.total_tokens}\n`;
if (run.metrics?.cost_usd) markdown += `**Cost:** $${run.metrics.cost_usd.toFixed(4)} USD\n`;
markdown += `\n---\n\n`;
for (const msg of messages) {
if (msg.type === "system" && msg.subtype === "init") {
markdown += `## System Initialization\n\n`;
markdown += `- Session ID: \`${msg.session_id || 'N/A'}\`\n`;
markdown += `- Model: \`${msg.model || 'default'}\`\n`;
if (msg.cwd) markdown += `- Working Directory: \`${msg.cwd}\`\n`;
if (msg.tools?.length) markdown += `- Tools: ${msg.tools.join(', ')}\n`;
markdown += `\n`;
} else if (msg.type === "assistant" && msg.message) {
markdown += `## Assistant\n\n`;
for (const content of msg.message.content || []) {
if (content.type === "text") {
markdown += `${content.text}\n\n`;
} else if (content.type === "tool_use") {
markdown += `### Tool: ${content.name}\n\n`;
markdown += `\`\`\`json\n${JSON.stringify(content.input, null, 2)}\n\`\`\`\n\n`;
}
}
if (msg.message.usage) {
markdown += `*Tokens: ${msg.message.usage.input_tokens} in, ${msg.message.usage.output_tokens} out*\n\n`;
}
} else if (msg.type === "user" && msg.message) {
markdown += `## User\n\n`;
for (const content of msg.message.content || []) {
if (content.type === "text") {
markdown += `${content.text}\n\n`;
} else if (content.type === "tool_result") {
markdown += `### Tool Result\n\n`;
markdown += `\`\`\`\n${content.content}\n\`\`\`\n\n`;
}
}
} else if (msg.type === "result") {
markdown += `## Execution Result\n\n`;
if (msg.result) {
markdown += `${msg.result}\n\n`;
}
if (msg.error) {
markdown += `**Error:** ${msg.error}\n\n`;
}
}
}
await navigator.clipboard.writeText(markdown);
setCopyPopoverOpen(false);
setToast({ message: 'Output copied as Markdown', type: 'success' });
};
const refreshOutput = async () => {
setRefreshing(true);
await loadOutput(true); // Skip cache
setRefreshing(false);
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = target;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
setHasUserScrolled(distanceFromBottom > 50);
};
// Load output on mount
useEffect(() => {
if (!run.id) return;
// Check cache immediately for instant display
const cached = getCachedOutput(run.id);
if (cached) {
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
setRawJsonlOutput(cachedJsonlLines);
setMessages(cached.messages);
}
// Then load fresh data
loadOutput();
}, [run.id]);
const displayableMessages = useMemo(() => {
return messages.filter((message) => {
if (message.isMeta && !message.leafUuid && !message.summary) return false;
if (message.type === "user" && message.message) {
if (message.isMeta) return false;
const msg = message.message;
if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) return false;
if (Array.isArray(msg.content)) {
let hasVisibleContent = false;
for (const content of msg.content) {
if (content.type === "text") { hasVisibleContent = true; break; }
if (content.type === "tool_result") {
// Check if this tool result will be displayed as a widget
let willBeSkipped = false;
if (content.tool_use_id) {
// Find the corresponding tool use
for (let i = messages.indexOf(message) - 1; i >= 0; i--) {
const prevMsg = messages[i];
if (prevMsg.type === 'assistant' && prevMsg.message?.content && Array.isArray(prevMsg.message.content)) {
const toolUse = prevMsg.message.content.find((c: any) => c.type === 'tool_use' && c.id === content.tool_use_id);
if (toolUse) {
const toolName = toolUse.name?.toLowerCase();
const toolsWithWidgets = ['task','edit','multiedit','todowrite','ls','read','glob','bash','write','grep'];
if (toolsWithWidgets.includes(toolName) || toolUse.name?.startsWith('mcp__')) {
willBeSkipped = true;
}
break;
}
}
}
}
if (!willBeSkipped) { hasVisibleContent = true; break; }
}
}
if (!hasVisibleContent) return false;
}
}
return true;
});
}, [messages]);
const renderIcon = (iconName: string) => {
const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot;
return <Icon className="h-5 w-5" />;
};
const formatDuration = (ms?: number) => {
if (!ms) return "N/A";
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const formatTokens = (tokens?: number) => {
if (!tokens) return "0";
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return tokens.toString();
};
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-40"
onClick={onClose}
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className={`fixed inset-x-4 top-[10%] bottom-[10%] z-50 max-w-4xl mx-auto ${className}`}
>
<Card className="h-full flex flex-col shadow-xl">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5">
{renderIcon(run.agent_icon)}
</div>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg flex items-center gap-2">
{run.agent_name}
{run.status === 'running' && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 font-medium">Running</span>
</div>
)}
</CardTitle>
<p className="text-sm text-muted-foreground mt-1 truncate">
{run.task}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-2">
<Badge variant="outline" className="text-xs">
{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
</Badge>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatISOTimestamp(run.created_at)}</span>
</div>
{run.metrics?.duration_ms && (
<span>{formatDuration(run.metrics.duration_ms)}</span>
)}
{run.metrics?.total_tokens && (
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>{formatTokens(run.metrics.total_tokens)}</span>
</div>
)}
{run.metrics?.cost_usd && (
<div className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
<span>${run.metrics.cost_usd.toFixed(4)}</span>
</div>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Popover
trigger={
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
>
<Copy className="h-4 w-4 mr-1" />
Copy
<ChevronDown className="h-3 w-3 ml-1" />
</Button>
}
content={
<div className="w-44 p-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsJsonl}
>
Copy as JSONL
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsMarkdown}
>
Copy as Markdown
</Button>
</div>
}
open={copyPopoverOpen}
onOpenChange={setCopyPopoverOpen}
align="end"
/>
{onOpenFullView && (
<Button
variant="ghost"
size="sm"
onClick={onOpenFullView}
title="Open in full view"
className="h-8 px-2"
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
className="h-8 px-2"
>
{isFullscreen ? (
<Minimize2 className="h-4 w-4" />
) : (
<Maximize2 className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={refreshOutput}
disabled={refreshing}
title="Refresh output"
className="h-8 px-2"
>
<RotateCcw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 px-2"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'flex-1'} p-0 overflow-hidden`}>
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="flex items-center space-x-2">
<RefreshCw className="h-4 w-4 animate-spin" />
<span>Loading output...</span>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p>No output available yet</p>
</div>
) : (
<div
ref={scrollAreaRef}
className="h-full overflow-y-auto p-4 space-y-2"
onScroll={handleScroll}
>
<AnimatePresence>
{displayableMessages.map((message: ClaudeStreamMessage, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
))}
</AnimatePresence>
<div ref={outputEndRef} />
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* Fullscreen Modal */}
{isFullscreen && (
<div className="fixed inset-0 bg-background z-[60] flex flex-col">
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
{renderIcon(run.agent_icon)}
<div>
<h3 className="font-semibold text-lg">{run.agent_name}</h3>
<p className="text-sm text-muted-foreground">{run.task}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Popover
trigger={
<Button
variant="outline"
size="sm"
>
<Copy className="h-4 w-4 mr-2" />
Copy Output
<ChevronDown className="h-3 w-3 ml-2" />
</Button>
}
content={
<div className="w-44 p-1">
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsJsonl}
>
Copy as JSONL
</Button>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={handleCopyAsMarkdown}
>
Copy as Markdown
</Button>
</div>
}
align="end"
/>
<Button
variant="outline"
size="sm"
onClick={refreshOutput}
disabled={refreshing}
>
<RotateCcw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsFullscreen(false)}
>
<Minimize2 className="h-4 w-4 mr-2" />
Exit Fullscreen
</Button>
</div>
</div>
<div
ref={fullscreenScrollRef}
className="flex-1 overflow-y-auto p-6"
onScroll={handleScroll}
>
<div className="max-w-4xl mx-auto space-y-2">
{messages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No output available yet
</div>
) : (
<>
<AnimatePresence>
{displayableMessages.map((message: ClaudeStreamMessage, index: number) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<ErrorBoundary>
<StreamMessage message={message} streamMessages={messages} />
</ErrorBoundary>
</motion.div>
))}
</AnimatePresence>
<div ref={fullscreenMessagesEndRef} />
</>
)}
</div>
</div>
</div>
)}
{/* Toast Notification */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</>
);
}

View File

@@ -1,5 +1,5 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
import { motion, AnimatePresence } from "framer-motion";
import { Play, Clock, Hash, Bot } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { formatISOTimestamp } from "@/lib/date-utils";
import type { AgentRunWithMetrics } from "@/lib/api";
import { AGENT_ICONS } from "./CCAgents";
import { AgentRunOutputViewer } from "./AgentRunOutputViewer";
interface AgentRunsListProps {
/**
@@ -41,6 +42,7 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
className,
}) => {
const [currentPage, setCurrentPage] = useState(1);
const [selectedRun, setSelectedRun] = useState<AgentRunWithMetrics | null>(null);
// Calculate pagination
const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE);
@@ -75,6 +77,16 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
return tokens.toString();
};
const handleRunClick = (run: AgentRunWithMetrics) => {
// If there's a callback, use it (for full-page navigation)
if (onRunClick) {
onRunClick(run);
} else {
// Otherwise, open in modal preview
setSelectedRun(run);
}
};
if (runs.length === 0) {
return (
<div className={cn("text-center py-8 text-muted-foreground", className)}>
@@ -83,92 +95,114 @@ export const AgentRunsList: React.FC<AgentRunsListProps> = ({
</div>
);
}
return (
<div className={cn("space-y-4", className)}>
<div className="space-y-2">
{currentRuns.map((run, index) => (
<motion.div
key={run.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.4, 0, 0.2, 1],
}}
>
<Card
className={cn(
"transition-all hover:shadow-md cursor-pointer",
onRunClick && "hover:shadow-lg hover:border-primary/50 active:scale-[0.99]"
)}
onClick={() => onRunClick?.(run)}
<>
<div className={cn("space-y-2", className)}>
<AnimatePresence mode="popLayout">
{currentRuns.map((run, index) => (
<motion.div
key={run.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.3,
delay: index * 0.05,
ease: [0.4, 0, 0.2, 1],
}}
>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="mt-0.5">
<Card
className={cn(
"cursor-pointer transition-all hover:shadow-md hover:scale-[1.01] active:scale-[0.99]",
run.status === "running" && "border-green-500/50"
)}
onClick={() => handleRunClick(run)}
>
<CardContent className="p-3">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
{renderIcon(run.agent_icon)}
</div>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{run.task}</p>
<Badge variant="outline" className="text-xs">
{run.model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="truncate">by {run.agent_name}</span>
{run.completed_at && (
<>
<span></span>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatDuration(run.metrics?.duration_ms)}</span>
</div>
</>
)}
{run.metrics?.total_tokens && (
<>
<span></span>
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>{formatTokens(run.metrics?.total_tokens)}</span>
</div>
</>
)}
{run.metrics?.cost_usd && (
<>
<span></span>
<span>${run.metrics?.cost_usd?.toFixed(4)}</span>
</>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium truncate">
{run.agent_name}
</h4>
{run.status === "running" && (
<div className="flex items-center gap-1">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<span className="text-xs text-green-600 font-medium">Running</span>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatISOTimestamp(run.created_at)}
<p className="text-xs text-muted-foreground truncate mb-1">
{run.task}
</p>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatISOTimestamp(run.created_at)}</span>
</div>
{run.metrics?.duration_ms && (
<span>{formatDuration(run.metrics.duration_ms)}</span>
)}
{run.metrics?.total_tokens && (
<div className="flex items-center gap-1">
<Hash className="h-3 w-3" />
<span>{formatTokens(run.metrics.total_tokens)}</span>
</div>
)}
</div>
</div>
<div className="flex-shrink-0">
<Badge
variant={
run.status === "completed" ? "default" :
run.status === "running" ? "secondary" :
run.status === "failed" ? "destructive" :
"outline"
}
className="text-xs"
>
{run.status === "completed" ? "Completed" :
run.status === "running" ? "Running" :
run.status === "failed" ? "Failed" :
"Pending"}
</Badge>
</div>
</div>
{!run.completed_at && (
<Badge variant="secondary" className="text-xs">
Running
</Badge>
)}
</div>
</CardContent>
</Card>
</motion.div>
))}
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
{/* Pagination */}
{totalPages > 1 && (
<div className="pt-2">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
)}
</div>
{totalPages > 1 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
{/* Agent Run Output Viewer Modal */}
{selectedRun && (
<AgentRunOutputViewer
run={selectedRun}
onClose={() => setSelectedRun(null)}
/>
)}
</div>
</>
);
};

View File

@@ -6,14 +6,6 @@ import {
Trash2,
Play,
Bot,
Brain,
Code,
Sparkles,
Zap,
Cpu,
Rocket,
Shield,
Terminal,
ArrowLeft,
History,
Download,
@@ -46,9 +38,9 @@ import { Toast, ToastContainer } from "@/components/ui/toast";
import { CreateAgent } from "./CreateAgent";
import { AgentExecution } from "./AgentExecution";
import { AgentRunsList } from "./AgentRunsList";
import { AgentRunView } from "./AgentRunView";
import { RunningSessionsView } from "./RunningSessionsView";
import { GitHubAgentBrowser } from "./GitHubAgentBrowser";
import { ICON_MAP } from "./IconPicker";
interface CCAgentsProps {
/**
@@ -61,18 +53,8 @@ interface CCAgentsProps {
className?: string;
}
// Available icons for agents
export const AGENT_ICONS = {
bot: Bot,
brain: Brain,
code: Code,
sparkles: Sparkles,
zap: Zap,
cpu: Cpu,
rocket: Rocket,
shield: Shield,
terminal: Terminal,
};
// Available icons for agents - now using all icons from IconPicker
export const AGENT_ICONS = ICON_MAP;
export type AgentIconName = keyof typeof AGENT_ICONS;
@@ -90,10 +72,10 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [view, setView] = useState<"list" | "create" | "edit" | "execute" | "viewRun">("list");
const [view, setView] = useState<"list" | "create" | "edit" | "execute">("list");
const [activeTab, setActiveTab] = useState<"agents" | "running">("agents");
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
// const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
const [showGitHubBrowser, setShowGitHubBrowser] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [agentToDelete, setAgentToDelete] = useState<Agent | null>(null);
@@ -195,12 +177,12 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
setToast({ message: "Agent updated successfully", type: "success" });
};
const handleRunClick = (run: AgentRunWithMetrics) => {
if (run.id) {
setSelectedRunId(run.id);
setView("viewRun");
}
};
// const handleRunClick = (run: AgentRunWithMetrics) => {
// if (run.id) {
// setSelectedRunId(run.id);
// setView("viewRun");
// }
// };
const handleExecutionComplete = async () => {
// Reload runs when returning from execution
@@ -270,7 +252,7 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
const paginatedAgents = agents.slice(startIndex, startIndex + AGENTS_PER_PAGE);
const renderIcon = (iconName: string) => {
const Icon = AGENT_ICONS[iconName as AgentIconName] || Bot;
const Icon = AGENT_ICONS[iconName as AgentIconName] || AGENT_ICONS.bot;
return <Icon className="h-12 w-12" />;
};
@@ -305,14 +287,7 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
);
}
if (view === "viewRun" && selectedRunId) {
return (
<AgentRunView
runId={selectedRunId}
onBack={() => setView("list")}
/>
);
}
// Removed viewRun case - now using modal preview in AgentRunsList
return (
<div className={cn("flex flex-col h-full bg-background", className)}>
@@ -564,7 +539,9 @@ export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
</div>
) : (
<AgentRunsList runs={runs} onRunClick={handleRunClick} />
<AgentRunsList
runs={runs}
/>
)}
</div>
)}

View File

@@ -1,6 +1,6 @@
import React, { useState } from "react";
import { motion } from "framer-motion";
import { ArrowLeft, Save, Loader2 } from "lucide-react";
import { ArrowLeft, Save, Loader2, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -8,8 +8,9 @@ import { Toast, ToastContainer } from "@/components/ui/toast";
import { api, type Agent } from "@/lib/api";
import { cn } from "@/lib/utils";
import MDEditor from "@uiw/react-md-editor";
import { AGENT_ICONS, type AgentIconName } from "./CCAgents";
import { type AgentIconName } from "./CCAgents";
import { AgentSandboxSettings } from "./AgentSandboxSettings";
import { IconPicker, ICON_MAP } from "./IconPicker";
interface CreateAgentProps {
/**
@@ -54,6 +55,7 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const [showIconPicker, setShowIconPicker] = useState(false);
const isEditMode = !!agent;
@@ -183,164 +185,172 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
)}
{/* Form */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="space-y-6">
{/* Agent Name */}
<div className="space-y-2">
<Label htmlFor="agent-name">Agent Name</Label>
<Input
id="agent-name"
type="text"
placeholder="e.g., Code Reviewer, Test Generator"
value={name}
onChange={(e) => setName(e.target.value)}
className="max-w-md"
/>
</div>
{/* Icon Picker */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="grid grid-cols-3 sm:grid-cols-5 md:grid-cols-9 gap-2 max-w-2xl">
{(Object.keys(AGENT_ICONS) as AgentIconName[]).map((iconName) => {
const Icon = AGENT_ICONS[iconName];
return (
<button
key={iconName}
type="button"
onClick={() => setSelectedIcon(iconName)}
className={cn(
"p-3 rounded-lg border-2 transition-all hover:scale-105",
"flex items-center justify-center",
selectedIcon === iconName
? "border-primary bg-primary/10"
: "border-border hover:border-primary/50"
)}
>
<Icon className="h-6 w-6" />
</button>
);
})}
<div className="flex-1 overflow-y-auto px-4 py-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
className="space-y-6"
>
{/* Basic Information */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-medium mb-4">Basic Information</h3>
</div>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label>Model</Label>
<div className="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={() => setModel("sonnet")}
className={cn(
"flex-1 px-4 py-2.5 rounded-full border-2 font-medium transition-all",
"hover:scale-[1.02] active:scale-[0.98]",
model === "sonnet"
? "border-primary bg-primary text-primary-foreground shadow-lg"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2.5">
<div className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0",
model === "sonnet" ? "border-primary-foreground" : "border-current"
)}>
{model === "sonnet" && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
<div className="text-left">
<div className="text-sm font-semibold">Claude 4 Sonnet</div>
<div className="text-xs opacity-80">Faster, efficient for most tasks</div>
</div>
</div>
</button>
{/* Name and Icon */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Agent Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Code Assistant"
required
/>
</div>
<button
type="button"
onClick={() => setModel("opus")}
className={cn(
"flex-1 px-4 py-2.5 rounded-full border-2 font-medium transition-all",
"hover:scale-[1.02] active:scale-[0.98]",
model === "opus"
? "border-primary bg-primary text-primary-foreground shadow-lg"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2.5">
<div className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0",
model === "opus" ? "border-primary-foreground" : "border-current"
)}>
{model === "opus" && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
<div className="text-left">
<div className="text-sm font-semibold">Claude 4 Opus</div>
<div className="text-xs opacity-80">More capable, better for complex tasks</div>
<div className="space-y-2">
<Label>Agent Icon</Label>
<div
onClick={() => setShowIconPicker(true)}
className="h-10 px-3 py-2 bg-background border border-input rounded-md cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-2">
{(() => {
const Icon = ICON_MAP[selectedIcon] || ICON_MAP.bot;
return (
<>
<Icon className="h-4 w-4" />
<span className="text-sm">{selectedIcon}</span>
</>
);
})()}
</div>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</div>
</button>
</div>
</div>
</div>
{/* Default Task */}
<div className="space-y-2">
<Label htmlFor="default-task">Default Task (Optional)</Label>
<Input
id="default-task"
type="text"
placeholder="e.g., Review this code for security issues"
value={defaultTask}
onChange={(e) => setDefaultTask(e.target.value)}
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">
This will be used as the default task placeholder when executing the agent
</p>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label>Model</Label>
<div className="flex flex-col sm:flex-row gap-3">
<button
type="button"
onClick={() => setModel("sonnet")}
className={cn(
"flex-1 px-4 py-2.5 rounded-full border-2 font-medium transition-all",
"hover:scale-[1.02] active:scale-[0.98]",
model === "sonnet"
? "border-primary bg-primary text-primary-foreground shadow-lg"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2.5">
<div className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0",
model === "sonnet" ? "border-primary-foreground" : "border-current"
)}>
{model === "sonnet" && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
<div className="text-left">
<div className="text-sm font-semibold">Claude 4 Sonnet</div>
<div className="text-xs opacity-80">Faster, efficient for most tasks</div>
</div>
</div>
</button>
<button
type="button"
onClick={() => setModel("opus")}
className={cn(
"flex-1 px-4 py-2.5 rounded-full border-2 font-medium transition-all",
"hover:scale-[1.02] active:scale-[0.98]",
model === "opus"
? "border-primary bg-primary text-primary-foreground shadow-lg"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2.5">
<div className={cn(
"w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0",
model === "opus" ? "border-primary-foreground" : "border-current"
)}>
{model === "opus" && (
<div className="w-2 h-2 rounded-full bg-primary-foreground" />
)}
</div>
<div className="text-left">
<div className="text-sm font-semibold">Claude 4 Opus</div>
<div className="text-xs opacity-80">More capable, better for complex tasks</div>
</div>
</div>
</button>
</div>
</div>
{/* Sandbox Settings */}
<AgentSandboxSettings
agent={{
id: agent?.id,
name,
icon: selectedIcon,
system_prompt: systemPrompt,
default_task: defaultTask || undefined,
model,
sandbox_enabled: sandboxEnabled,
enable_file_read: enableFileRead,
enable_file_write: enableFileWrite,
enable_network: enableNetwork,
created_at: agent?.created_at || "",
updated_at: agent?.updated_at || ""
}}
onUpdate={(updates) => {
if ('sandbox_enabled' in updates) setSandboxEnabled(updates.sandbox_enabled!);
if ('enable_file_read' in updates) setEnableFileRead(updates.enable_file_read!);
if ('enable_file_write' in updates) setEnableFileWrite(updates.enable_file_write!);
if ('enable_network' in updates) setEnableNetwork(updates.enable_network!);
}}
/>
{/* System Prompt Editor */}
<div className="space-y-2">
<Label>System Prompt</Label>
<p className="text-xs text-muted-foreground mb-2">
Define the behavior and capabilities of your CC Agent
</p>
<div className="rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
<MDEditor
value={systemPrompt}
onChange={(val) => setSystemPrompt(val || "")}
preview="edit"
height={400}
visibleDragbar={false}
{/* Default Task */}
<div className="space-y-2">
<Label htmlFor="default-task">Default Task (Optional)</Label>
<Input
id="default-task"
type="text"
placeholder="e.g., Review this code for security issues"
value={defaultTask}
onChange={(e) => setDefaultTask(e.target.value)}
className="max-w-md"
/>
<p className="text-xs text-muted-foreground">
This will be used as the default task placeholder when executing the agent
</p>
</div>
{/* Sandbox Settings */}
<AgentSandboxSettings
agent={{
id: agent?.id,
name,
icon: selectedIcon,
system_prompt: systemPrompt,
default_task: defaultTask || undefined,
model,
sandbox_enabled: sandboxEnabled,
enable_file_read: enableFileRead,
enable_file_write: enableFileWrite,
enable_network: enableNetwork,
created_at: agent?.created_at || "",
updated_at: agent?.updated_at || ""
}}
onUpdate={(updates) => {
if ('sandbox_enabled' in updates) setSandboxEnabled(updates.sandbox_enabled!);
if ('enable_file_read' in updates) setEnableFileRead(updates.enable_file_read!);
if ('enable_file_write' in updates) setEnableFileWrite(updates.enable_file_write!);
if ('enable_network' in updates) setEnableNetwork(updates.enable_network!);
}}
/>
{/* System Prompt Editor */}
<div className="space-y-2">
<Label>System Prompt</Label>
<p className="text-xs text-muted-foreground mb-2">
Define the behavior and capabilities of your CC Agent
</p>
<div className="rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
<MDEditor
value={systemPrompt}
onChange={(val) => setSystemPrompt(val || "")}
preview="edit"
height={400}
visibleDragbar={false}
/>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
@@ -354,6 +364,17 @@ export const CreateAgent: React.FC<CreateAgentProps> = ({
/>
)}
</ToastContainer>
{/* Icon Picker Dialog */}
<IconPicker
value={selectedIcon}
onSelect={(iconName) => {
setSelectedIcon(iconName as AgentIconName);
setShowIconPicker(false);
}}
isOpen={showIconPicker}
onClose={() => setShowIconPicker(false)}
/>
</div>
);
};

View File

@@ -3,7 +3,6 @@ import { motion, AnimatePresence } from "framer-motion";
import {
Search,
Download,
X,
Loader2,
AlertCircle,
Eye,
@@ -16,8 +15,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { api, type GitHubAgentFile, type AgentExport } from "@/lib/api";
import { AGENT_ICONS, type AgentIconName } from "./CCAgents";
import { api, type GitHubAgentFile, type AgentExport, type Agent } from "@/lib/api";
import { type AgentIconName } from "./CCAgents";
import { ICON_MAP } from "./IconPicker";
interface GitHubAgentBrowserProps {
isOpen: boolean;
onClose: () => void;
@@ -42,14 +43,24 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
const [searchQuery, setSearchQuery] = useState("");
const [selectedAgent, setSelectedAgent] = useState<AgentPreview | null>(null);
const [importing, setImporting] = useState(false);
const [importedAgents, setImportedAgents] = useState<Set<string>>(new Set());
const [existingAgents, setExistingAgents] = useState<Agent[]>([]);
useEffect(() => {
if (isOpen) {
fetchAgents();
fetchExistingAgents();
}
}, [isOpen]);
const fetchExistingAgents = async () => {
try {
const agents = await api.listAgents();
setExistingAgents(agents);
} catch (err) {
console.error("Failed to fetch existing agents:", err);
}
};
const fetchAgents = async () => {
try {
setLoading(true);
@@ -91,6 +102,13 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
}
};
const isAgentImported = (fileName: string) => {
const agentName = getAgentDisplayName(fileName);
return existingAgents.some(agent =>
agent.name.toLowerCase() === agentName.toLowerCase()
);
};
const handleImportAgent = async () => {
if (!selectedAgent?.file) return;
@@ -98,8 +116,8 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
setImporting(true);
await api.importAgentFromGitHub(selectedAgent.file.download_url);
// Mark as imported
setImportedAgents(prev => new Set(prev).add(selectedAgent.file.name));
// Refresh existing agents list
await fetchExistingAgents();
// Close preview
setSelectedAgent(null);
@@ -126,7 +144,7 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
};
const renderIcon = (iconName: string) => {
const Icon = AGENT_ICONS[iconName as AgentIconName] || AGENT_ICONS.bot;
const Icon = ICON_MAP[iconName as AgentIconName] || ICON_MAP.bot;
return <Icon className="h-8 w-8" />;
};
@@ -141,6 +159,25 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* Repository Info */}
<div className="px-4 py-3 bg-muted/50 rounded-lg mb-4">
<p className="text-sm text-muted-foreground">
Agents are fetched from{" "}
<a
href="https://github.com/getAsterisk/claudia/tree/main/cc_agents"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
github.com/getAsterisk/claudia/cc_agents
<Globe className="h-3 w-3" />
</a>
</p>
<p className="text-sm text-muted-foreground mt-1">
You can contribute your custom agents to the repository!
</p>
</div>
{/* Search Bar */}
<div className="mb-4">
<div className="relative">
@@ -190,11 +227,20 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
onClick={() => handlePreviewAgent(agent)}>
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold line-clamp-2">
{getAgentDisplayName(agent.name)}
</h3>
{importedAgents.has(agent.name) && (
<Badge variant="secondary" className="ml-2">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 rounded-lg bg-primary/10 text-primary flex-shrink-0">
{/* Default to bot icon for now, will be loaded from preview */}
{(() => {
const Icon = ICON_MAP.bot;
return <Icon className="h-6 w-6" />;
})()}
</div>
<h3 className="text-sm font-semibold line-clamp-2">
{getAgentDisplayName(agent.name)}
</h3>
</div>
{isAgentImported(agent.name) && (
<Badge variant="secondary" className="ml-2 flex-shrink-0">
<Check className="h-3 w-3 mr-1" />
Imported
</Badge>
@@ -234,17 +280,7 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
<Dialog open={!!selectedAgent} onOpenChange={() => setSelectedAgent(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Agent Preview</span>
<Button
variant="ghost"
size="icon"
onClick={() => setSelectedAgent(null)}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</DialogTitle>
<DialogTitle>Agent Preview</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
@@ -333,14 +369,14 @@ export const GitHubAgentBrowser: React.FC<GitHubAgentBrowserProps> = ({
</Button>
<Button
onClick={handleImportAgent}
disabled={importing || importedAgents.has(selectedAgent.file.name)}
disabled={importing || isAgentImported(selectedAgent.file.name)}
>
{importing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Importing...
</>
) : importedAgents.has(selectedAgent.file.name) ? (
) : isAgentImported(selectedAgent.file.name) ? (
<>
<Check className="h-4 w-4 mr-2" />
Already Imported

View File

@@ -0,0 +1,463 @@
import React, { useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
// Interface & Navigation
Home,
Menu,
Settings,
User,
Users,
LogOut,
Bell,
Bookmark,
Calendar,
Clock,
Eye,
EyeOff,
Hash,
Heart,
Info,
Link,
Lock,
Map,
MessageSquare,
Mic,
Music,
Paperclip,
Phone,
Pin,
Plus,
Save,
Share,
Star,
Tag,
Trash,
Upload,
Download,
Edit,
Copy,
// Development & Tech
Bot,
Brain,
Code,
Terminal,
Cpu,
Database,
GitBranch,
Github,
Globe,
HardDrive,
Laptop,
Monitor,
Server,
Wifi,
Cloud,
Command,
FileCode,
FileJson,
Folder,
FolderOpen,
Bug,
Coffee,
// Business & Finance
Briefcase,
Building,
CreditCard,
DollarSign,
TrendingUp,
TrendingDown,
BarChart,
PieChart,
Calculator,
Receipt,
Wallet,
// Creative & Design
Palette,
Brush,
Camera,
Film,
Image,
Layers,
Layout,
PenTool,
Scissors,
Type,
Zap,
Sparkles,
Wand2,
// Nature & Science
Beaker,
Atom,
Dna,
Flame,
Leaf,
Mountain,
Sun,
Moon,
CloudRain,
Snowflake,
TreePine,
Waves,
Wind,
// Gaming & Entertainment
Gamepad2,
Dice1,
Trophy,
Medal,
Crown,
Rocket,
Target,
Swords,
Shield,
// Communication
Mail,
Send,
MessageCircle,
Video,
Voicemail,
Radio,
Podcast,
Megaphone,
// Miscellaneous
Activity,
Anchor,
Award,
Battery,
Bluetooth,
Compass,
Crosshair,
Flag,
Flashlight,
Gift,
Headphones,
Key,
Lightbulb,
Package,
Puzzle,
Search as SearchIcon,
Smile,
ThumbsUp,
Umbrella,
Watch,
Wrench,
type LucideIcon,
} from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
/**
* Icon categories for better organization
*/
const ICON_CATEGORIES = {
"Interface & Navigation": [
{ name: "home", icon: Home },
{ name: "menu", icon: Menu },
{ name: "settings", icon: Settings },
{ name: "user", icon: User },
{ name: "users", icon: Users },
{ name: "log-out", icon: LogOut },
{ name: "bell", icon: Bell },
{ name: "bookmark", icon: Bookmark },
{ name: "calendar", icon: Calendar },
{ name: "clock", icon: Clock },
{ name: "eye", icon: Eye },
{ name: "eye-off", icon: EyeOff },
{ name: "hash", icon: Hash },
{ name: "heart", icon: Heart },
{ name: "info", icon: Info },
{ name: "link", icon: Link },
{ name: "lock", icon: Lock },
{ name: "map", icon: Map },
{ name: "message-square", icon: MessageSquare },
{ name: "mic", icon: Mic },
{ name: "music", icon: Music },
{ name: "paperclip", icon: Paperclip },
{ name: "phone", icon: Phone },
{ name: "pin", icon: Pin },
{ name: "plus", icon: Plus },
{ name: "save", icon: Save },
{ name: "share", icon: Share },
{ name: "star", icon: Star },
{ name: "tag", icon: Tag },
{ name: "trash", icon: Trash },
{ name: "upload", icon: Upload },
{ name: "download", icon: Download },
{ name: "edit", icon: Edit },
{ name: "copy", icon: Copy },
],
"Development & Tech": [
{ name: "bot", icon: Bot },
{ name: "brain", icon: Brain },
{ name: "code", icon: Code },
{ name: "terminal", icon: Terminal },
{ name: "cpu", icon: Cpu },
{ name: "database", icon: Database },
{ name: "git-branch", icon: GitBranch },
{ name: "github", icon: Github },
{ name: "globe", icon: Globe },
{ name: "hard-drive", icon: HardDrive },
{ name: "laptop", icon: Laptop },
{ name: "monitor", icon: Monitor },
{ name: "server", icon: Server },
{ name: "wifi", icon: Wifi },
{ name: "cloud", icon: Cloud },
{ name: "command", icon: Command },
{ name: "file-code", icon: FileCode },
{ name: "file-json", icon: FileJson },
{ name: "folder", icon: Folder },
{ name: "folder-open", icon: FolderOpen },
{ name: "bug", icon: Bug },
{ name: "coffee", icon: Coffee },
],
"Business & Finance": [
{ name: "briefcase", icon: Briefcase },
{ name: "building", icon: Building },
{ name: "credit-card", icon: CreditCard },
{ name: "dollar-sign", icon: DollarSign },
{ name: "trending-up", icon: TrendingUp },
{ name: "trending-down", icon: TrendingDown },
{ name: "bar-chart", icon: BarChart },
{ name: "pie-chart", icon: PieChart },
{ name: "calculator", icon: Calculator },
{ name: "receipt", icon: Receipt },
{ name: "wallet", icon: Wallet },
],
"Creative & Design": [
{ name: "palette", icon: Palette },
{ name: "brush", icon: Brush },
{ name: "camera", icon: Camera },
{ name: "film", icon: Film },
{ name: "image", icon: Image },
{ name: "layers", icon: Layers },
{ name: "layout", icon: Layout },
{ name: "pen-tool", icon: PenTool },
{ name: "scissors", icon: Scissors },
{ name: "type", icon: Type },
{ name: "zap", icon: Zap },
{ name: "sparkles", icon: Sparkles },
{ name: "wand-2", icon: Wand2 },
],
"Nature & Science": [
{ name: "beaker", icon: Beaker },
{ name: "atom", icon: Atom },
{ name: "dna", icon: Dna },
{ name: "flame", icon: Flame },
{ name: "leaf", icon: Leaf },
{ name: "mountain", icon: Mountain },
{ name: "sun", icon: Sun },
{ name: "moon", icon: Moon },
{ name: "cloud-rain", icon: CloudRain },
{ name: "snowflake", icon: Snowflake },
{ name: "tree-pine", icon: TreePine },
{ name: "waves", icon: Waves },
{ name: "wind", icon: Wind },
],
"Gaming & Entertainment": [
{ name: "gamepad-2", icon: Gamepad2 },
{ name: "dice-1", icon: Dice1 },
{ name: "trophy", icon: Trophy },
{ name: "medal", icon: Medal },
{ name: "crown", icon: Crown },
{ name: "rocket", icon: Rocket },
{ name: "target", icon: Target },
{ name: "swords", icon: Swords },
{ name: "shield", icon: Shield },
],
"Communication": [
{ name: "mail", icon: Mail },
{ name: "send", icon: Send },
{ name: "message-circle", icon: MessageCircle },
{ name: "video", icon: Video },
{ name: "voicemail", icon: Voicemail },
{ name: "radio", icon: Radio },
{ name: "podcast", icon: Podcast },
{ name: "megaphone", icon: Megaphone },
],
"Miscellaneous": [
{ name: "activity", icon: Activity },
{ name: "anchor", icon: Anchor },
{ name: "award", icon: Award },
{ name: "battery", icon: Battery },
{ name: "bluetooth", icon: Bluetooth },
{ name: "compass", icon: Compass },
{ name: "crosshair", icon: Crosshair },
{ name: "flag", icon: Flag },
{ name: "flashlight", icon: Flashlight },
{ name: "gift", icon: Gift },
{ name: "headphones", icon: Headphones },
{ name: "key", icon: Key },
{ name: "lightbulb", icon: Lightbulb },
{ name: "package", icon: Package },
{ name: "puzzle", icon: Puzzle },
{ name: "search", icon: SearchIcon },
{ name: "smile", icon: Smile },
{ name: "thumbs-up", icon: ThumbsUp },
{ name: "umbrella", icon: Umbrella },
{ name: "watch", icon: Watch },
{ name: "wrench", icon: Wrench },
],
} as const;
type IconCategory = typeof ICON_CATEGORIES[keyof typeof ICON_CATEGORIES];
type IconItem = IconCategory[number];
interface IconPickerProps {
/**
* Currently selected icon name
*/
value: string;
/**
* Callback when an icon is selected
*/
onSelect: (iconName: string) => void;
/**
* Whether the picker is open
*/
isOpen: boolean;
/**
* Callback to close the picker
*/
onClose: () => void;
}
/**
* Icon picker component with search and categories
* Similar to Notion's icon picker interface
*/
export const IconPicker: React.FC<IconPickerProps> = ({
value,
onSelect,
isOpen,
onClose,
}) => {
const [searchQuery, setSearchQuery] = useState("");
const [hoveredIcon, setHoveredIcon] = useState<string | null>(null);
// Filter icons based on search query
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return ICON_CATEGORIES;
const query = searchQuery.toLowerCase();
const filtered: Record<string, IconItem[]> = {};
Object.entries(ICON_CATEGORIES).forEach(([category, icons]) => {
const matchingIcons = icons.filter(({ name }) =>
name.toLowerCase().includes(query)
);
if (matchingIcons.length > 0) {
filtered[category] = matchingIcons;
}
});
return filtered;
}, [searchQuery]);
// Get all icons for search
const allIcons = useMemo(() => {
return Object.values(ICON_CATEGORIES).flat();
}, []);
const handleSelect = (iconName: string) => {
onSelect(iconName);
onClose();
setSearchQuery("");
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[80vh] p-0">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle>Choose an icon</DialogTitle>
</DialogHeader>
{/* Search Bar */}
<div className="px-6 py-3 border-b">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search icons..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
autoFocus
/>
</div>
</div>
{/* Icon Grid */}
<div className="h-[60vh] px-6 py-4 overflow-y-auto">
{Object.keys(filteredCategories).length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<p className="text-sm text-muted-foreground">
No icons found for "{searchQuery}"
</p>
</div>
) : (
<div className="space-y-6">
<AnimatePresence mode="wait">
{Object.entries(filteredCategories).map(([category, icons]) => (
<motion.div
key={category}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
{category}
</h3>
<div className="grid grid-cols-10 gap-2">
{icons.map((item: IconItem) => {
const Icon = item.icon;
return (
<motion.button
key={item.name}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleSelect(item.name)}
onMouseEnter={() => setHoveredIcon(item.name)}
onMouseLeave={() => setHoveredIcon(null)}
className={cn(
"p-2.5 rounded-lg transition-colors relative group",
"hover:bg-accent hover:text-accent-foreground",
value === item.name && "bg-primary/10 text-primary"
)}
>
<Icon className="h-5 w-5" />
{hoveredIcon === item.name && (
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-popover text-popover-foreground text-xs rounded shadow-lg whitespace-nowrap z-10">
{item.name}
</div>
)}
</motion.button>
);
})}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t bg-muted/50">
<p className="text-xs text-muted-foreground text-center">
Click an icon to select {allIcons.length} icons available
</p>
</div>
</DialogContent>
</Dialog>
);
};
// Export all available icon names for type safety
export const AVAILABLE_ICONS = Object.values(ICON_CATEGORIES)
.flat()
.map(({ name }) => name);
// Export icon map for easy access
export const ICON_MAP = Object.values(ICON_CATEGORIES)
.flat()
.reduce((acc, { name, icon }) => ({ ...acc, [name]: icon }), {} as Record<string, LucideIcon>);

View File

@@ -1,4 +1,29 @@
export * from "./AgentExecutionDemo";
export * from "./AgentRunOutputViewer";
export * from "./StreamMessage";
export * from "./ToolWidgets";
export * from "./NFOCredits";
export * from "./NFOCredits";
export * from "./UsageDashboard";
export * from "./WebviewPreview";
export * from "./ImagePreview";
export * from "./MCPManager";
export * from "./MCPServerList";
export * from "./MCPAddServer";
export * from "./MCPImportExport";
export * from "./ui/badge";
export * from "./ui/button";
export * from "./ui/card";
export * from "./ui/dialog";
export * from "./ui/dropdown-menu";
export * from "./ui/input";
export * from "./ui/label";
export * from "./ui/select";
export * from "./ui/switch";
export * from "./ui/tabs";
export * from "./ui/textarea";
export * from "./ui/toast";
export * from "./ui/tooltip";
export * from "./ui/popover";
export * from "./ui/pagination";
export * from "./ui/split-pane";
export * from "./ui/scroll-area";

View File

@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {

View File

@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
* Button variants configuration using class-variance-authority
*/
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {

View File

@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Optional className for styling
*/
className?: string;
/**
* Children to render inside the scroll area
*/
children: React.ReactNode;
}
/**
* ScrollArea component for scrollable content with custom scrollbar styling
*
* @example
* <ScrollArea className="h-[200px]">
* <div>Scrollable content here</div>
* </ScrollArea>
*/
export const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"relative overflow-auto",
// Custom scrollbar styling
"scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent",
"[&::-webkit-scrollbar]:w-2",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:bg-border [&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb:hover]:bg-border/80",
className
)}
{...props}
>
{children}
</div>
);
}
);
ScrollArea.displayName = "ScrollArea";

View File

@@ -16,7 +16,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
@@ -114,7 +114,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}

View File

@@ -174,7 +174,6 @@ export const SplitPane: React.FC<SplitPaneProps> = ({
"w-1 hover:w-2 transition-all duration-150",
"bg-border hover:bg-primary/50",
"cursor-col-resize",
"focus:outline-none focus:bg-primary focus:w-2",
isDragging && "bg-primary w-2"
)}
onMouseDown={handleMouseDown}

View File

@@ -30,7 +30,6 @@ const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors",
"focus-visible:outline-none focus-visible:ring-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className
)}

View File

@@ -106,7 +106,6 @@ const TabsTrigger = React.forwardRef<
onClick={() => onValueChange(value)}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-all",
"focus-visible:outline-none focus-visible:ring-2",
"disabled:pointer-events-none disabled:opacity-50",
className
)}
@@ -145,7 +144,7 @@ const TabsContent = React.forwardRef<
ref={ref}
role="tabpanel"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"mt-2",
className
)}
{...props}

View File

@@ -80,6 +80,49 @@ button:disabled,
cursor: not-allowed !important;
}
/* Remove all focus styles globally */
* {
outline: none !important;
outline-offset: 0 !important;
}
*:focus,
*:focus-visible,
*:focus-within {
outline: none !important;
box-shadow: none !important;
}
/* Specifically remove focus styles from form elements */
input:focus,
input:focus-visible,
textarea:focus,
textarea:focus-visible,
select:focus,
select:focus-visible,
button:focus,
button:focus-visible,
[role="button"]:focus,
[role="button"]:focus-visible,
[role="combobox"]:focus,
[role="combobox"]:focus-visible {
outline: none !important;
box-shadow: none !important;
border-color: var(--color-input) !important;
}
/* Remove ring styles */
.ring-0,
.ring-1,
.ring-2,
.ring,
.ring-offset-0,
.ring-offset-1,
.ring-offset-2,
.ring-offset {
box-shadow: none !important;
}
/* Custom utilities */
@utility animate-in {
animation-name: enter;
@@ -134,6 +177,7 @@ button:disabled,
.w-md-editor.w-md-editor-focus {
box-shadow: none !important;
border-color: var(--color-border) !important;
outline: none !important;
}
.w-md-editor-toolbar {