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:
665
src/components/AgentRunOutputViewer.tsx
Normal file
665
src/components/AgentRunOutputViewer.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@@ -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>
|
||||
)}
|
||||
|
@@ -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>
|
||||
);
|
||||
};
|
@@ -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
|
||||
|
463
src/components/IconPicker.tsx
Normal file
463
src/components/IconPicker.tsx
Normal 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>);
|
@@ -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";
|
@@ -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: {
|
||||
|
@@ -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: {
|
||||
|
@@ -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>
|
||||
|
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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";
|
@@ -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}
|
||||
|
@@ -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}
|
||||
|
@@ -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
|
||||
)}
|
||||
|
@@ -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}
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user