init: push source
This commit is contained in:
772
src/components/AgentExecution.tsx
Normal file
772
src/components/AgentExecution.tsx
Normal file
@@ -0,0 +1,772 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Play,
|
||||
StopCircle,
|
||||
FolderOpen,
|
||||
Terminal,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
Maximize2,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import { api, type Agent } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { StreamMessage } from "./StreamMessage";
|
||||
import { ExecutionControlBar } from "./ExecutionControlBar";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
interface AgentExecutionProps {
|
||||
/**
|
||||
* The agent to execute
|
||||
*/
|
||||
agent: Agent;
|
||||
/**
|
||||
* Callback to go back to the agents list
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeStreamMessage {
|
||||
type: "system" | "assistant" | "user" | "result";
|
||||
subtype?: string;
|
||||
message?: {
|
||||
content?: any[];
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentExecution component for running CC agents
|
||||
*
|
||||
* @example
|
||||
* <AgentExecution agent={agent} onBack={() => setView('list')} />
|
||||
*/
|
||||
export const AgentExecution: React.FC<AgentExecutionProps> = ({
|
||||
agent,
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [projectPath, setProjectPath] = useState("");
|
||||
const [task, setTask] = useState("");
|
||||
const [model, setModel] = useState(agent.model || "sonnet");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||
|
||||
// Execution stats
|
||||
const [executionStartTime, setExecutionStartTime] = useState<number | null>(null);
|
||||
const [totalTokens, setTotalTokens] = useState(0);
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [hasUserScrolled, setHasUserScrolled] = useState(false);
|
||||
const [isFullscreenModalOpen, setIsFullscreenModalOpen] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const fullscreenScrollRef = useRef<HTMLDivElement>(null);
|
||||
const fullscreenMessagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const unlistenRefs = useRef<UnlistenFn[]>([]);
|
||||
const elapsedTimeIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Clean up listeners on unmount
|
||||
return () => {
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
if (elapsedTimeIntervalRef.current) {
|
||||
clearInterval(elapsedTimeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Check if user is at the very bottom of the scrollable container
|
||||
const isAtBottom = () => {
|
||||
const container = isFullscreenModalOpen ? fullscreenScrollRef.current : scrollContainerRef.current;
|
||||
if (container) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
return distanceFromBottom < 1;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-scroll if user hasn't manually scrolled OR if they're at the bottom
|
||||
const shouldAutoScroll = !hasUserScrolled || isAtBottom();
|
||||
|
||||
if (shouldAutoScroll) {
|
||||
const endRef = isFullscreenModalOpen ? fullscreenMessagesEndRef.current : messagesEndRef.current;
|
||||
if (endRef) {
|
||||
endRef.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}, [messages, hasUserScrolled, isFullscreenModalOpen]);
|
||||
|
||||
|
||||
// Update elapsed time while running
|
||||
useEffect(() => {
|
||||
if (isRunning && executionStartTime) {
|
||||
elapsedTimeIntervalRef.current = setInterval(() => {
|
||||
setElapsedTime(Math.floor((Date.now() - executionStartTime) / 1000));
|
||||
}, 100);
|
||||
} else {
|
||||
if (elapsedTimeIntervalRef.current) {
|
||||
clearInterval(elapsedTimeIntervalRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (elapsedTimeIntervalRef.current) {
|
||||
clearInterval(elapsedTimeIntervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning, executionStartTime]);
|
||||
|
||||
// Calculate total tokens from messages
|
||||
useEffect(() => {
|
||||
const tokens = messages.reduce((total, msg) => {
|
||||
if (msg.message?.usage) {
|
||||
return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens;
|
||||
}
|
||||
if (msg.usage) {
|
||||
return total + msg.usage.input_tokens + msg.usage.output_tokens;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
setTotalTokens(tokens);
|
||||
}, [messages]);
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Project Directory"
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
setProjectPath(selected as string);
|
||||
setError(null); // Clear any previous errors
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to select directory:", err);
|
||||
// More detailed error logging
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to select directory: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!projectPath || !task.trim()) return;
|
||||
|
||||
try {
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setMessages([]);
|
||||
setRawJsonlOutput([]);
|
||||
setExecutionStartTime(Date.now());
|
||||
setElapsedTime(0);
|
||||
setTotalTokens(0);
|
||||
|
||||
// Set up 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);
|
||||
setError(event.payload);
|
||||
});
|
||||
|
||||
const completeUnlisten = await listen<boolean>("agent-complete", (event) => {
|
||||
setIsRunning(false);
|
||||
setExecutionStartTime(null);
|
||||
if (!event.payload) {
|
||||
setError("Agent execution failed");
|
||||
}
|
||||
});
|
||||
|
||||
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
|
||||
|
||||
// Execute the agent with model override
|
||||
await api.executeAgent(agent.id!, projectPath, task, model);
|
||||
} catch (err) {
|
||||
console.error("Failed to execute agent:", err);
|
||||
setError("Failed to execute agent");
|
||||
setIsRunning(false);
|
||||
setExecutionStartTime(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
// TODO: Implement actual stop functionality via API
|
||||
// For now, just update the UI state
|
||||
setIsRunning(false);
|
||||
setExecutionStartTime(null);
|
||||
|
||||
// Clean up listeners
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
unlistenRefs.current = [];
|
||||
|
||||
// Add a message indicating execution was stopped
|
||||
setMessages(prev => [...prev, {
|
||||
type: "result",
|
||||
subtype: "error",
|
||||
is_error: true,
|
||||
result: "Execution stopped by user",
|
||||
duration_ms: elapsedTime * 1000,
|
||||
usage: {
|
||||
input_tokens: totalTokens,
|
||||
output_tokens: 0
|
||||
}
|
||||
}]);
|
||||
} catch (err) {
|
||||
console.error("Failed to stop agent:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackWithConfirmation = () => {
|
||||
if (isRunning) {
|
||||
// Show confirmation dialog before navigating away during execution
|
||||
const shouldLeave = window.confirm(
|
||||
"An agent is currently running. If you navigate away, the agent will continue running in the background. You can view running sessions in the 'Running Sessions' tab within CC Agents.\n\nDo you want to continue?"
|
||||
);
|
||||
if (!shouldLeave) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up listeners but don't stop the actual agent process
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
unlistenRefs.current = [];
|
||||
|
||||
// Navigate back
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleCopyAsJsonl = async () => {
|
||||
const jsonl = rawJsonlOutput.join('\n');
|
||||
await navigator.clipboard.writeText(jsonl);
|
||||
setCopyPopoverOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyAsMarkdown = async () => {
|
||||
let markdown = `# Agent Execution: ${agent.name}\n\n`;
|
||||
markdown += `**Task:** ${task}\n`;
|
||||
markdown += `**Model:** ${model === 'opus' ? 'Claude 4 Opus' : 'Claude 4 Sonnet'}\n`;
|
||||
markdown += `**Date:** ${new Date().toISOString()}\n\n`;
|
||||
markdown += `---\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`;
|
||||
}
|
||||
if (msg.cost_usd !== undefined) {
|
||||
markdown += `- **Cost:** $${msg.cost_usd.toFixed(4)} USD\n`;
|
||||
}
|
||||
if (msg.duration_ms !== undefined) {
|
||||
markdown += `- **Duration:** ${(msg.duration_ms / 1000).toFixed(2)}s\n`;
|
||||
}
|
||||
if (msg.num_turns !== undefined) {
|
||||
markdown += `- **Turns:** ${msg.num_turns}\n`;
|
||||
}
|
||||
if (msg.usage) {
|
||||
const total = msg.usage.input_tokens + msg.usage.output_tokens;
|
||||
markdown += `- **Total Tokens:** ${total} (${msg.usage.input_tokens} in, ${msg.usage.output_tokens} out)\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setCopyPopoverOpen(false);
|
||||
};
|
||||
|
||||
const renderIcon = () => {
|
||||
const Icon = agent.icon in AGENT_ICONS ? AGENT_ICONS[agent.icon as keyof typeof AGENT_ICONS] : Terminal;
|
||||
return <Icon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBackWithConfirmation}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{agent.name}</h2>
|
||||
{isRunning && (
|
||||
<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">
|
||||
{isRunning ? "Click back to return to main menu - view in CC Agents > Running Sessions" : "Execute CC Agent"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Fullscreen
|
||||
</Button>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="p-4 border-b border-border space-y-4">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Project Path */}
|
||||
<div className="space-y-2">
|
||||
<Label>Project Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={projectPath}
|
||||
onChange={(e) => setProjectPath(e.target.value)}
|
||||
placeholder="Select or enter project path"
|
||||
disabled={isRunning}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSelectPath}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>Model</Label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isRunning && setModel("sonnet")}
|
||||
className={cn(
|
||||
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
||||
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
||||
isRunning && "opacity-50 cursor-not-allowed",
|
||||
model === "sonnet"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className={cn(
|
||||
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||
model === "sonnet" ? "border-primary-foreground" : "border-current"
|
||||
)}>
|
||||
{model === "sonnet" && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>Claude 4 Sonnet</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isRunning && setModel("opus")}
|
||||
className={cn(
|
||||
"flex-1 px-3.5 py-2 rounded-full border-2 font-medium transition-all text-sm",
|
||||
!isRunning && "hover:scale-[1.02] active:scale-[0.98]",
|
||||
isRunning && "opacity-50 cursor-not-allowed",
|
||||
model === "opus"
|
||||
? "border-primary bg-primary text-primary-foreground shadow-lg"
|
||||
: "border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className={cn(
|
||||
"w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center flex-shrink-0",
|
||||
model === "opus" ? "border-primary-foreground" : "border-current"
|
||||
)}>
|
||||
{model === "opus" && (
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>Claude 4 Opus</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Input */}
|
||||
<div className="space-y-2">
|
||||
<Label>Task</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={task}
|
||||
onChange={(e) => setTask(e.target.value)}
|
||||
placeholder={agent.default_task || "Enter the task for the agent"}
|
||||
disabled={isRunning}
|
||||
className="flex-1"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter" && !isRunning && projectPath && task.trim()) {
|
||||
handleExecute();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onClick={isRunning ? handleStop : handleExecute}
|
||||
disabled={!projectPath || !task.trim()}
|
||||
variant={isRunning ? "destructive" : "default"}
|
||||
>
|
||||
{isRunning ? (
|
||||
<>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Execute
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output Display */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-[600px] w-full overflow-y-auto p-6 space-y-8"
|
||||
onScroll={() => {
|
||||
// Mark that user has scrolled manually
|
||||
if (!hasUserScrolled) {
|
||||
setHasUserScrolled(true);
|
||||
}
|
||||
|
||||
// If user scrolls back to bottom, re-enable auto-scroll
|
||||
if (isAtBottom()) {
|
||||
setHasUserScrolled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div ref={messagesContainerRef}>
|
||||
{messages.length === 0 && !isRunning && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a project path and enter a task to run the agent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Initializing agent...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<StreamMessage message={message} streamMessages={messages} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Execution Control Bar */}
|
||||
<ExecutionControlBar
|
||||
isExecuting={isRunning}
|
||||
onStop={handleStop}
|
||||
totalTokens={totalTokens}
|
||||
elapsedTime={elapsedTime}
|
||||
/>
|
||||
|
||||
{/* Fullscreen Modal */}
|
||||
{isFullscreenModalOpen && (
|
||||
<div className="fixed inset-0 z-50 bg-background flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
<h2 className="text-lg font-semibold">{agent.name} - Output</h2>
|
||||
{isRunning && (
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreenModalOpen(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div
|
||||
ref={fullscreenScrollRef}
|
||||
className="h-full overflow-y-auto space-y-8"
|
||||
onScroll={() => {
|
||||
// Mark that user has scrolled manually
|
||||
if (!hasUserScrolled) {
|
||||
setHasUserScrolled(true);
|
||||
}
|
||||
|
||||
// If user scrolls back to bottom, re-enable auto-scroll
|
||||
if (isAtBottom()) {
|
||||
setHasUserScrolled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 && !isRunning && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Ready to Execute</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a project path and enter a task to run the agent
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRunning && messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Initializing agent...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<StreamMessage message={message} streamMessages={messages} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<div ref={fullscreenMessagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Import AGENT_ICONS for icon rendering
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
181
src/components/AgentExecutionDemo.tsx
Normal file
181
src/components/AgentExecutionDemo.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import React from "react";
|
||||
import { StreamMessage } from "./StreamMessage";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
|
||||
/**
|
||||
* Demo component showing all the different message types and tools
|
||||
*/
|
||||
export const AgentExecutionDemo: React.FC = () => {
|
||||
// Sample messages based on the provided JSONL session
|
||||
const messages: ClaudeStreamMessage[] = [
|
||||
// Skip meta message (should not render)
|
||||
{
|
||||
type: "user",
|
||||
isMeta: true,
|
||||
message: { content: [] },
|
||||
timestamp: "2025-06-11T14:08:53.771Z"
|
||||
},
|
||||
|
||||
// Summary message
|
||||
{
|
||||
leafUuid: "3c5ecb4f-c1f0-40c2-a357-ab7642ad28b8",
|
||||
summary: "JSONL Viewer Model Configuration and Setup",
|
||||
type: "summary" as any
|
||||
},
|
||||
|
||||
// Assistant with Edit tool
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_use",
|
||||
name: "Edit",
|
||||
input: {
|
||||
file_path: "/Users/mufeedvh/dev/jsonl-viewer/script.js",
|
||||
new_string: "reader.onerror = () => reject(new Error('Failed to read file'));",
|
||||
old_string: "reader.onerror = e => reject(new Error('Failed to read file'));"
|
||||
}
|
||||
}],
|
||||
usage: { input_tokens: 4, output_tokens: 158 }
|
||||
}
|
||||
},
|
||||
|
||||
// User with Edit tool result
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_result",
|
||||
content: `The file /Users/mufeedvh/dev/jsonl-viewer/script.js has been updated. Here's the result of running \`cat -n\` on a snippet of the edited file:
|
||||
220 readFileAsText(file) {
|
||||
221 return new Promise((resolve, reject) => {
|
||||
222 const reader = new FileReader();
|
||||
223 reader.onload = e => resolve(e.target.result);
|
||||
224 reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
225 reader.readAsText(file);
|
||||
226 });
|
||||
227 }
|
||||
228`
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// Assistant with MCP tool
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_use",
|
||||
name: "mcp__ide__getDiagnostics",
|
||||
input: {}
|
||||
}],
|
||||
usage: { input_tokens: 4, output_tokens: 37 }
|
||||
}
|
||||
},
|
||||
|
||||
// User with empty tool result
|
||||
{
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_result",
|
||||
content: ""
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// Assistant with Write tool (large content)
|
||||
{
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [{
|
||||
type: "tool_use",
|
||||
name: "Write",
|
||||
input: {
|
||||
file_path: "/Users/mufeedvh/dev/jsonl-viewer/styles.css",
|
||||
content: `/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Container */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #1a202c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #718096;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Input Section */
|
||||
.input-section {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Drop Zone */
|
||||
.drop-zone {
|
||||
border: 2px dashed #cbd5e0;
|
||||
border-radius: 12px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drop-zone:hover,
|
||||
.drop-zone.drag-over {
|
||||
border-color: #4299e1;
|
||||
background: #ebf8ff;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(66, 153, 225, 0.1);
|
||||
}
|
||||
|
||||
/* ... many more lines of CSS ... */
|
||||
/* This content is over 1000 characters so it should show the maximize button */
|
||||
` + '\n'.repeat(100) + '/* End of very long CSS file */'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-8 space-y-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Agent Execution Demo</h1>
|
||||
|
||||
{messages.map((message, idx) => (
|
||||
<StreamMessage key={idx} message={message} streamMessages={messages} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
306
src/components/AgentRunView.tsx
Normal file
306
src/components/AgentRunView.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Hash,
|
||||
DollarSign,
|
||||
Bot
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import { api, type AgentRunWithMetrics } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatISOTimestamp } from "@/lib/date-utils";
|
||||
import { StreamMessage } from "./StreamMessage";
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
interface AgentRunViewProps {
|
||||
/**
|
||||
* The run ID to view
|
||||
*/
|
||||
runId: number;
|
||||
/**
|
||||
* Callback to go back
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentRunView component for viewing past agent execution details
|
||||
*
|
||||
* @example
|
||||
* <AgentRunView runId={123} onBack={() => setView('list')} />
|
||||
*/
|
||||
export const AgentRunView: React.FC<AgentRunViewProps> = ({
|
||||
runId,
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [run, setRun] = useState<AgentRunWithMetrics | null>(null);
|
||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadRun();
|
||||
}, [runId]);
|
||||
|
||||
const loadRun = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const runData = await api.getAgentRunWithRealTimeMetrics(runId);
|
||||
setRun(runData);
|
||||
|
||||
// Parse JSONL output into messages
|
||||
if (runData.output) {
|
||||
const parsedMessages: ClaudeStreamMessage[] = [];
|
||||
const lines = runData.output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const msg = JSON.parse(line) as ClaudeStreamMessage;
|
||||
parsedMessages.push(msg);
|
||||
} catch (err) {
|
||||
console.error("Failed to parse line:", line, err);
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(parsedMessages);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load run:", err);
|
||||
setError("Failed to load execution details");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAsJsonl = async () => {
|
||||
if (!run?.output) return;
|
||||
await navigator.clipboard.writeText(run.output);
|
||||
setCopyPopoverOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyAsMarkdown = async () => {
|
||||
if (!run) return;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const renderIcon = (iconName: string) => {
|
||||
const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot;
|
||||
return <Icon className="h-5 w-5" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center h-full", className)}>
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !run) {
|
||||
return (
|
||||
<div className={cn("flex flex-col items-center justify-center h-full", className)}>
|
||||
<p className="text-destructive mb-4">{error || "Run not found"}</p>
|
||||
<Button onClick={onBack}>Go Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderIcon(run.agent_icon)}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{run.agent_name}</h2>
|
||||
<p className="text-xs text-muted-foreground">Execution History</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Run Details */}
|
||||
<Card className="m-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Task:</h3>
|
||||
<p className="text-sm text-muted-foreground flex-1">{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-4 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 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{(run.metrics.duration_ms / 1000).toFixed(2)}s</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{run.metrics?.total_tokens && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
<span>{run.metrics.total_tokens} 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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Output Display */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto p-4 space-y-2">
|
||||
{messages.map((message, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<StreamMessage message={message} streamMessages={messages} />
|
||||
</ErrorBoundary>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
174
src/components/AgentRunsList.tsx
Normal file
174
src/components/AgentRunsList.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Play, Clock, Hash, Bot } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatISOTimestamp } from "@/lib/date-utils";
|
||||
import type { AgentRunWithMetrics } from "@/lib/api";
|
||||
import { AGENT_ICONS } from "./CCAgents";
|
||||
|
||||
interface AgentRunsListProps {
|
||||
/**
|
||||
* Array of agent runs to display
|
||||
*/
|
||||
runs: AgentRunWithMetrics[];
|
||||
/**
|
||||
* Callback when a run is clicked
|
||||
*/
|
||||
onRunClick?: (run: AgentRunWithMetrics) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
/**
|
||||
* AgentRunsList component - Displays a paginated list of agent execution runs
|
||||
*
|
||||
* @example
|
||||
* <AgentRunsList
|
||||
* runs={runs}
|
||||
* onRunClick={(run) => console.log('Selected:', run)}
|
||||
* />
|
||||
*/
|
||||
export const AgentRunsList: React.FC<AgentRunsListProps> = ({
|
||||
runs,
|
||||
onRunClick,
|
||||
className,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(runs.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentRuns = runs.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to page 1 if runs change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [runs.length]);
|
||||
|
||||
const renderIcon = (iconName: string) => {
|
||||
const Icon = AGENT_ICONS[iconName as keyof typeof AGENT_ICONS] || Bot;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
if (runs.length === 0) {
|
||||
return (
|
||||
<div className={cn("text-center py-8 text-muted-foreground", className)}>
|
||||
<Play className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No execution history yet</p>
|
||||
</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)}
|
||||
>
|
||||
<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">
|
||||
{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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatISOTimestamp(run.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!run.completed_at && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
122
src/components/AgentSandboxSettings.tsx
Normal file
122
src/components/AgentSandboxSettings.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import React from "react";
|
||||
import { Shield, FileText, Upload, Network, AlertTriangle } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { type Agent } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentSandboxSettingsProps {
|
||||
agent: Agent;
|
||||
onUpdate: (updates: Partial<Agent>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for managing per-agent sandbox permissions
|
||||
* Provides simple toggles for sandbox enable/disable and file/network permissions
|
||||
*/
|
||||
export const AgentSandboxSettings: React.FC<AgentSandboxSettingsProps> = ({
|
||||
agent,
|
||||
onUpdate,
|
||||
className
|
||||
}) => {
|
||||
const handleToggle = (field: keyof Agent, value: boolean) => {
|
||||
onUpdate({ [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={cn("p-4 space-y-4", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-amber-500" />
|
||||
<h4 className="font-semibold">Sandbox Permissions</h4>
|
||||
{!agent.sandbox_enabled && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Master sandbox toggle */}
|
||||
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">Enable Sandbox</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run this agent in a secure sandbox environment
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.sandbox_enabled}
|
||||
onCheckedChange={(checked) => handleToggle('sandbox_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Permission toggles - only visible when sandbox is enabled */}
|
||||
{agent.sandbox_enabled && (
|
||||
<div className="space-y-3 pl-4 border-l-2 border-amber-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">File Read Access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow reading files and directories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.enable_file_read}
|
||||
onCheckedChange={(checked) => handleToggle('enable_file_read', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-green-500" />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">File Write Access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow creating and modifying files
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.enable_file_write}
|
||||
onCheckedChange={(checked) => handleToggle('enable_file_write', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-4 w-4 text-purple-500" />
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Network Access</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow outbound network connections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={agent.enable_network}
|
||||
onCheckedChange={(checked) => handleToggle('enable_network', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning when sandbox is disabled */}
|
||||
{!agent.sandbox_enabled && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-800 dark:bg-amber-950/50 dark:border-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium">Sandbox Disabled</p>
|
||||
<p>This agent will run with full system access. Use with caution.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
455
src/components/CCAgents.tsx
Normal file
455
src/components/CCAgents.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Play,
|
||||
Bot,
|
||||
Brain,
|
||||
Code,
|
||||
Sparkles,
|
||||
Zap,
|
||||
Cpu,
|
||||
Rocket,
|
||||
Shield,
|
||||
Terminal,
|
||||
ArrowLeft,
|
||||
History
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { api, type Agent, type AgentRunWithMetrics } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
interface CCAgentsProps {
|
||||
/**
|
||||
* Callback to go back to the main view
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
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,
|
||||
};
|
||||
|
||||
export type AgentIconName = keyof typeof AGENT_ICONS;
|
||||
|
||||
/**
|
||||
* CCAgents component for managing Claude Code agents
|
||||
*
|
||||
* @example
|
||||
* <CCAgents onBack={() => setView('home')} />
|
||||
*/
|
||||
export const CCAgents: React.FC<CCAgentsProps> = ({ onBack, className }) => {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [runs, setRuns] = useState<AgentRunWithMetrics[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [runsLoading, setRunsLoading] = useState(false);
|
||||
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 [activeTab, setActiveTab] = useState<"agents" | "running">("agents");
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
||||
|
||||
const AGENTS_PER_PAGE = 9; // 3x3 grid
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
loadRuns();
|
||||
}, []);
|
||||
|
||||
const loadAgents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const agentsList = await api.listAgents();
|
||||
setAgents(agentsList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load agents:", err);
|
||||
setError("Failed to load agents");
|
||||
setToast({ message: "Failed to load agents", type: "error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRuns = async () => {
|
||||
try {
|
||||
setRunsLoading(true);
|
||||
const runsList = await api.listAgentRuns();
|
||||
setRuns(runsList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load runs:", err);
|
||||
} finally {
|
||||
setRunsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this agent?")) return;
|
||||
|
||||
try {
|
||||
await api.deleteAgent(id);
|
||||
setToast({ message: "Agent deleted successfully", type: "success" });
|
||||
await loadAgents();
|
||||
await loadRuns(); // Reload runs as they might be affected
|
||||
} catch (err) {
|
||||
console.error("Failed to delete agent:", err);
|
||||
setToast({ message: "Failed to delete agent", type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAgent = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setView("edit");
|
||||
};
|
||||
|
||||
const handleExecuteAgent = (agent: Agent) => {
|
||||
setSelectedAgent(agent);
|
||||
setView("execute");
|
||||
};
|
||||
|
||||
const handleAgentCreated = async () => {
|
||||
setView("list");
|
||||
await loadAgents();
|
||||
setToast({ message: "Agent created successfully", type: "success" });
|
||||
};
|
||||
|
||||
const handleAgentUpdated = async () => {
|
||||
setView("list");
|
||||
await loadAgents();
|
||||
setToast({ message: "Agent updated successfully", type: "success" });
|
||||
};
|
||||
|
||||
const handleRunClick = (run: AgentRunWithMetrics) => {
|
||||
if (run.id) {
|
||||
setSelectedRunId(run.id);
|
||||
setView("viewRun");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecutionComplete = async () => {
|
||||
// Reload runs when returning from execution
|
||||
await loadRuns();
|
||||
};
|
||||
|
||||
// Pagination calculations
|
||||
const totalPages = Math.ceil(agents.length / AGENTS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * AGENTS_PER_PAGE;
|
||||
const paginatedAgents = agents.slice(startIndex, startIndex + AGENTS_PER_PAGE);
|
||||
|
||||
const renderIcon = (iconName: string) => {
|
||||
const Icon = AGENT_ICONS[iconName as AgentIconName] || Bot;
|
||||
return <Icon className="h-12 w-12" />;
|
||||
};
|
||||
|
||||
if (view === "create") {
|
||||
return (
|
||||
<CreateAgent
|
||||
onBack={() => setView("list")}
|
||||
onAgentCreated={handleAgentCreated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "edit" && selectedAgent) {
|
||||
return (
|
||||
<CreateAgent
|
||||
agent={selectedAgent}
|
||||
onBack={() => setView("list")}
|
||||
onAgentCreated={handleAgentUpdated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "execute" && selectedAgent) {
|
||||
return (
|
||||
<AgentExecution
|
||||
agent={selectedAgent}
|
||||
onBack={() => {
|
||||
setView("list");
|
||||
handleExecutionComplete();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === "viewRun" && selectedRunId) {
|
||||
return (
|
||||
<AgentRunView
|
||||
runId={selectedRunId}
|
||||
onBack={() => setView("list")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-6xl mx-auto flex flex-col h-full p-6">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-6"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">CC Agents</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your Claude Code agents
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setView("create")}
|
||||
size="default"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create CC Agent
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-border">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab("agents")}
|
||||
className={cn(
|
||||
"py-2 px-1 border-b-2 font-medium text-sm transition-colors",
|
||||
activeTab === "agents"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-4 w-4" />
|
||||
Agents
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("running")}
|
||||
className={cn(
|
||||
"py-2 px-1 border-b-2 font-medium text-sm transition-colors",
|
||||
activeTab === "running"
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Running Sessions
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{activeTab === "agents" && (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
{/* Agents Grid */}
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Bot className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No agents yet</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create your first CC Agent to get started
|
||||
</p>
|
||||
<Button onClick={() => setView("create")} size="default">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create CC Agent
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{paginatedAgents.map((agent, index) => (
|
||||
<motion.div
|
||||
key={agent.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
<div className="mb-4 p-4 rounded-full bg-primary/10 text-primary">
|
||||
{renderIcon(agent.icon)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{agent.name}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created: {new Date(agent.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="p-4 pt-0 flex justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleExecuteAgent(agent)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
Execute
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditAgent(agent)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteAgent(agent.id!)}
|
||||
className="flex items-center gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-3 text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution History */}
|
||||
{!loading && agents.length > 0 && (
|
||||
<div className="overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<History className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold">Recent Executions</h2>
|
||||
</div>
|
||||
{runsLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
) : (
|
||||
<AgentRunsList runs={runs} onRunClick={handleRunClick} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{activeTab === "running" && (
|
||||
<motion.div
|
||||
key="running"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="pt-6"
|
||||
>
|
||||
<RunningSessionsView />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
280
src/components/CheckpointSettings.tsx
Normal file
280
src/components/CheckpointSettings.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Settings,
|
||||
Save,
|
||||
Trash2,
|
||||
HardDrive,
|
||||
AlertCircle
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { SelectComponent, type SelectOption } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, type CheckpointStrategy } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface CheckpointSettingsProps {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
onClose?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CheckpointSettings component for managing checkpoint configuration
|
||||
*
|
||||
* @example
|
||||
* <CheckpointSettings
|
||||
* sessionId={session.id}
|
||||
* projectId={session.project_id}
|
||||
* projectPath={projectPath}
|
||||
* />
|
||||
*/
|
||||
export const CheckpointSettings: React.FC<CheckpointSettingsProps> = ({
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
onClose,
|
||||
className,
|
||||
}) => {
|
||||
const [autoCheckpointEnabled, setAutoCheckpointEnabled] = useState(true);
|
||||
const [checkpointStrategy, setCheckpointStrategy] = useState<CheckpointStrategy>("smart");
|
||||
const [totalCheckpoints, setTotalCheckpoints] = useState(0);
|
||||
const [keepCount, setKeepCount] = useState(10);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
const strategyOptions: SelectOption[] = [
|
||||
{ value: "manual", label: "Manual Only" },
|
||||
{ value: "per_prompt", label: "After Each Prompt" },
|
||||
{ value: "per_tool_use", label: "After Tool Use" },
|
||||
{ value: "smart", label: "Smart (Recommended)" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [sessionId, projectId, projectPath]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const settings = await api.getCheckpointSettings(sessionId, projectId, projectPath);
|
||||
setAutoCheckpointEnabled(settings.auto_checkpoint_enabled);
|
||||
setCheckpointStrategy(settings.checkpoint_strategy);
|
||||
setTotalCheckpoints(settings.total_checkpoints);
|
||||
} catch (err) {
|
||||
console.error("Failed to load checkpoint settings:", err);
|
||||
setError("Failed to load checkpoint settings");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
await api.updateCheckpointSettings(
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
autoCheckpointEnabled,
|
||||
checkpointStrategy
|
||||
);
|
||||
|
||||
setSuccessMessage("Settings saved successfully");
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
} catch (err) {
|
||||
console.error("Failed to save checkpoint settings:", err);
|
||||
setError("Failed to save checkpoint settings");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCleanup = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const removed = await api.cleanupOldCheckpoints(
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
keepCount
|
||||
);
|
||||
|
||||
setSuccessMessage(`Removed ${removed} old checkpoints`);
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
|
||||
// Reload settings to get updated count
|
||||
await loadSettings();
|
||||
} catch (err) {
|
||||
console.error("Failed to cleanup checkpoints:", err);
|
||||
setError("Failed to cleanup checkpoints");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className={cn("space-y-6", className)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Checkpoint Settings</h3>
|
||||
</div>
|
||||
{onClose && (
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Experimental Feature Warning */}
|
||||
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium text-yellow-600">Experimental Feature</p>
|
||||
<p className="text-yellow-600/80">
|
||||
Checkpointing may affect directory structure or cause data loss. Use with caution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-lg border border-green-500/50 bg-green-500/10 p-3 text-xs text-green-600"
|
||||
>
|
||||
{successMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Auto-checkpoint toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-checkpoint">Automatic Checkpoints</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically create checkpoints based on the selected strategy
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-checkpoint"
|
||||
checked={autoCheckpointEnabled}
|
||||
onCheckedChange={setAutoCheckpointEnabled}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Checkpoint strategy */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="strategy">Checkpoint Strategy</Label>
|
||||
<SelectComponent
|
||||
value={checkpointStrategy}
|
||||
onValueChange={(value: string) => setCheckpointStrategy(value as CheckpointStrategy)}
|
||||
options={strategyOptions}
|
||||
disabled={isLoading || !autoCheckpointEnabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{checkpointStrategy === "manual" && "Checkpoints will only be created manually"}
|
||||
{checkpointStrategy === "per_prompt" && "A checkpoint will be created after each user prompt"}
|
||||
{checkpointStrategy === "per_tool_use" && "A checkpoint will be created after each tool use"}
|
||||
{checkpointStrategy === "smart" && "Checkpoints will be created after destructive operations"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isLoading || isSaving}
|
||||
className="w-full"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Storage Management</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Total checkpoints: {totalCheckpoints}
|
||||
</p>
|
||||
</div>
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* Cleanup settings */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keep-count">Keep Recent Checkpoints</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="keep-count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={keepCount}
|
||||
onChange={(e) => setKeepCount(parseInt(e.target.value) || 10)}
|
||||
disabled={isLoading}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCleanup}
|
||||
disabled={isLoading || totalCheckpoints <= keepCount}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Clean Up
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove old checkpoints, keeping only the most recent {keepCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
104
src/components/ClaudeBinaryDialog.tsx
Normal file
104
src/components/ClaudeBinaryDialog.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ExternalLink, FileQuestion, Terminal } from "lucide-react";
|
||||
|
||||
interface ClaudeBinaryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export function ClaudeBinaryDialog({ open, onOpenChange, onSuccess, onError }: ClaudeBinaryDialogProps) {
|
||||
const [binaryPath, setBinaryPath] = useState("");
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!binaryPath.trim()) {
|
||||
onError("Please enter a valid path");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
try {
|
||||
await api.setClaudeBinaryPath(binaryPath.trim());
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save Claude binary path:", error);
|
||||
onError(error instanceof Error ? error.message : "Failed to save Claude binary path");
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileQuestion className="w-5 h-5" />
|
||||
Couldn't locate Claude Code installation
|
||||
</DialogTitle>
|
||||
<DialogDescription className="space-y-3 mt-4">
|
||||
<p>
|
||||
Claude Code was not found in any of the common installation locations.
|
||||
Please specify the path to the Claude binary manually.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-md">
|
||||
<Terminal className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Tip:</span> Run{" "}
|
||||
<code className="px-1 py-0.5 bg-black/10 dark:bg-white/10 rounded">which claude</code>{" "}
|
||||
in your terminal to find the installation path
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="/usr/local/bin/claude"
|
||||
value={binaryPath}
|
||||
onChange={(e) => setBinaryPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !isValidating) {
|
||||
handleSave();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Common locations: /usr/local/bin/claude, /opt/homebrew/bin/claude, ~/.claude/local/claude
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.open("https://docs.claude.ai/claude/how-to-install", "_blank")}
|
||||
className="mr-auto"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Installation Guide
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isValidating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isValidating || !binaryPath.trim()}>
|
||||
{isValidating ? "Validating..." : "Save Path"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
737
src/components/ClaudeCodeSession.tsx
Normal file
737
src/components/ClaudeCodeSession.tsx
Normal file
@@ -0,0 +1,737 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Terminal,
|
||||
Loader2,
|
||||
FolderOpen,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
GitBranch,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import { api, type Session } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { StreamMessage } from "./StreamMessage";
|
||||
import { FloatingPromptInput } from "./FloatingPromptInput";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { TokenCounter } from "./TokenCounter";
|
||||
import { TimelineNavigator } from "./TimelineNavigator";
|
||||
import { CheckpointSettings } from "./CheckpointSettings";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
|
||||
interface ClaudeCodeSessionProps {
|
||||
/**
|
||||
* Optional session to resume (when clicking from SessionList)
|
||||
*/
|
||||
session?: Session;
|
||||
/**
|
||||
* Initial project path (for new sessions)
|
||||
*/
|
||||
initialProjectPath?: string;
|
||||
/**
|
||||
* Callback to go back
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeCodeSession component for interactive Claude Code sessions
|
||||
*
|
||||
* @example
|
||||
* <ClaudeCodeSession onBack={() => setView('projects')} />
|
||||
*/
|
||||
export const ClaudeCodeSession: React.FC<ClaudeCodeSessionProps> = ({
|
||||
session,
|
||||
initialProjectPath = "",
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [projectPath, setProjectPath] = useState(initialProjectPath || session?.project_path || "");
|
||||
const [messages, setMessages] = useState<ClaudeStreamMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rawJsonlOutput, setRawJsonlOutput] = useState<string[]>([]);
|
||||
const [copyPopoverOpen, setCopyPopoverOpen] = useState(false);
|
||||
const [isFirstPrompt, setIsFirstPrompt] = useState(!session);
|
||||
const [currentModel, setCurrentModel] = useState<"sonnet" | "opus">("sonnet");
|
||||
const [totalTokens, setTotalTokens] = useState(0);
|
||||
const [extractedSessionInfo, setExtractedSessionInfo] = useState<{
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
} | null>(null);
|
||||
const [showTimeline, setShowTimeline] = useState(false);
|
||||
const [timelineVersion, setTimelineVersion] = useState(0);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showForkDialog, setShowForkDialog] = useState(false);
|
||||
const [forkCheckpointId, setForkCheckpointId] = useState<string | null>(null);
|
||||
const [forkSessionName, setForkSessionName] = useState("");
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const unlistenRefs = useRef<UnlistenFn[]>([]);
|
||||
const hasActiveSessionRef = useRef(false);
|
||||
|
||||
// Get effective session info (from prop or extracted) - use useMemo to ensure it updates
|
||||
const effectiveSession = useMemo(() => {
|
||||
if (session) return session;
|
||||
if (extractedSessionInfo) {
|
||||
return {
|
||||
id: extractedSessionInfo.sessionId,
|
||||
project_id: extractedSessionInfo.projectId,
|
||||
project_path: projectPath,
|
||||
created_at: Date.now(),
|
||||
} as Session;
|
||||
}
|
||||
return null;
|
||||
}, [session, extractedSessionInfo, projectPath]);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('[ClaudeCodeSession] State update:', {
|
||||
projectPath,
|
||||
session,
|
||||
extractedSessionInfo,
|
||||
effectiveSession,
|
||||
messagesCount: messages.length,
|
||||
isLoading
|
||||
});
|
||||
}, [projectPath, session, extractedSessionInfo, effectiveSession, messages.length, isLoading]);
|
||||
|
||||
// Load session history if resuming
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
loadSessionHistory();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Calculate total tokens from messages
|
||||
useEffect(() => {
|
||||
const tokens = messages.reduce((total, msg) => {
|
||||
if (msg.message?.usage) {
|
||||
return total + msg.message.usage.input_tokens + msg.message.usage.output_tokens;
|
||||
}
|
||||
if (msg.usage) {
|
||||
return total + msg.usage.input_tokens + msg.usage.output_tokens;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
setTotalTokens(tokens);
|
||||
}, [messages]);
|
||||
|
||||
const loadSessionHistory = async () => {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const history = await api.loadSessionHistory(session.id, session.project_id);
|
||||
|
||||
// Convert history to messages format
|
||||
const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({
|
||||
...entry,
|
||||
type: entry.type || "assistant"
|
||||
}));
|
||||
|
||||
setMessages(loadedMessages);
|
||||
setRawJsonlOutput(history.map(h => JSON.stringify(h)));
|
||||
|
||||
// After loading history, we're continuing a conversation
|
||||
setIsFirstPrompt(false);
|
||||
} catch (err) {
|
||||
console.error("Failed to load session history:", err);
|
||||
setError("Failed to load session history");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Select Project Directory"
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
setProjectPath(selected as string);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to select directory:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to select directory: ${errorMessage}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendPrompt = async (prompt: string, model: "sonnet" | "opus") => {
|
||||
if (!projectPath || !prompt.trim() || isLoading) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setCurrentModel(model);
|
||||
hasActiveSessionRef.current = true;
|
||||
|
||||
// Add the user message immediately to the UI
|
||||
const userMessage: ClaudeStreamMessage = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
// Clean up any existing listeners before creating new ones
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
unlistenRefs.current = [];
|
||||
|
||||
// Set up event listeners
|
||||
const outputUnlisten = await listen<string>("claude-output", async (event) => {
|
||||
try {
|
||||
console.log('[ClaudeCodeSession] Received claude-output:', event.payload);
|
||||
|
||||
// Store raw JSONL
|
||||
setRawJsonlOutput(prev => [...prev, event.payload]);
|
||||
|
||||
// Parse and display
|
||||
const message = JSON.parse(event.payload) as ClaudeStreamMessage;
|
||||
console.log('[ClaudeCodeSession] Parsed message:', message);
|
||||
|
||||
setMessages(prev => {
|
||||
console.log('[ClaudeCodeSession] Adding message to state. Previous count:', prev.length);
|
||||
return [...prev, message];
|
||||
});
|
||||
|
||||
// Extract session info from system init message
|
||||
if (message.type === "system" && message.subtype === "init" && message.session_id && !extractedSessionInfo) {
|
||||
console.log('[ClaudeCodeSession] Extracting session info from init message');
|
||||
// Extract project ID from the project path
|
||||
const projectId = projectPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
setExtractedSessionInfo({
|
||||
sessionId: message.session_id,
|
||||
projectId: projectId
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse message:", err, event.payload);
|
||||
}
|
||||
});
|
||||
|
||||
const errorUnlisten = await listen<string>("claude-error", (event) => {
|
||||
console.error("Claude error:", event.payload);
|
||||
setError(event.payload);
|
||||
});
|
||||
|
||||
const completeUnlisten = await listen<boolean>("claude-complete", async (event) => {
|
||||
console.log('[ClaudeCodeSession] Received claude-complete:', event.payload);
|
||||
setIsLoading(false);
|
||||
hasActiveSessionRef.current = false;
|
||||
if (!event.payload) {
|
||||
setError("Claude execution failed");
|
||||
}
|
||||
|
||||
// Track all messages at once after completion (batch operation)
|
||||
if (effectiveSession && rawJsonlOutput.length > 0) {
|
||||
console.log('[ClaudeCodeSession] Tracking all messages in batch:', rawJsonlOutput.length);
|
||||
api.trackSessionMessages(
|
||||
effectiveSession.id,
|
||||
effectiveSession.project_id,
|
||||
projectPath,
|
||||
rawJsonlOutput
|
||||
).catch(err => {
|
||||
console.error("Failed to track session messages:", err);
|
||||
});
|
||||
}
|
||||
|
||||
// Check if we should auto-checkpoint
|
||||
if (effectiveSession && messages.length > 0) {
|
||||
try {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const shouldCheckpoint = await api.checkAutoCheckpoint(
|
||||
effectiveSession.id,
|
||||
effectiveSession.project_id,
|
||||
projectPath,
|
||||
JSON.stringify(lastMessage)
|
||||
);
|
||||
|
||||
if (shouldCheckpoint) {
|
||||
await api.createCheckpoint(
|
||||
effectiveSession.id,
|
||||
effectiveSession.project_id,
|
||||
projectPath,
|
||||
messages.length - 1,
|
||||
"Auto-checkpoint after tool use"
|
||||
);
|
||||
console.log("Auto-checkpoint created");
|
||||
// Trigger timeline reload if it's currently visible
|
||||
setTimelineVersion((v) => v + 1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check/create auto-checkpoint:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up listeners after completion
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
unlistenRefs.current = [];
|
||||
});
|
||||
|
||||
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
|
||||
|
||||
// Execute the appropriate command
|
||||
if (isFirstPrompt && !session) {
|
||||
// New session
|
||||
await api.executeClaudeCode(projectPath, prompt, model);
|
||||
setIsFirstPrompt(false);
|
||||
} else if (session && isFirstPrompt) {
|
||||
// Resuming a session
|
||||
await api.resumeClaudeCode(projectPath, session.id, prompt, model);
|
||||
setIsFirstPrompt(false);
|
||||
} else {
|
||||
// Continuing conversation
|
||||
await api.continueClaudeCode(projectPath, prompt, model);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send prompt:", err);
|
||||
setError("Failed to execute Claude Code");
|
||||
setIsLoading(false);
|
||||
hasActiveSessionRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyAsJsonl = async () => {
|
||||
const jsonl = rawJsonlOutput.join('\n');
|
||||
await navigator.clipboard.writeText(jsonl);
|
||||
setCopyPopoverOpen(false);
|
||||
};
|
||||
|
||||
const handleCopyAsMarkdown = async () => {
|
||||
let markdown = `# Claude Code Session\n\n`;
|
||||
markdown += `**Project:** ${projectPath}\n`;
|
||||
markdown += `**Date:** ${new Date().toISOString()}\n\n`;
|
||||
markdown += `---\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") {
|
||||
const textContent = typeof content.text === 'string'
|
||||
? content.text
|
||||
: (content.text?.text || JSON.stringify(content.text || content));
|
||||
markdown += `${textContent}\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") {
|
||||
const textContent = typeof content.text === 'string'
|
||||
? content.text
|
||||
: (content.text?.text || JSON.stringify(content.text));
|
||||
markdown += `${textContent}\n\n`;
|
||||
} else if (content.type === "tool_result") {
|
||||
markdown += `### Tool Result\n\n`;
|
||||
let contentText = '';
|
||||
if (typeof content.content === 'string') {
|
||||
contentText = content.content;
|
||||
} else if (content.content && typeof content.content === 'object') {
|
||||
if (content.content.text) {
|
||||
contentText = content.content.text;
|
||||
} else if (Array.isArray(content.content)) {
|
||||
contentText = content.content
|
||||
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
|
||||
.join('\n');
|
||||
} else {
|
||||
contentText = JSON.stringify(content.content, null, 2);
|
||||
}
|
||||
}
|
||||
markdown += `\`\`\`\n${contentText}\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);
|
||||
};
|
||||
|
||||
const handleCheckpointSelect = async () => {
|
||||
// Reload messages from the checkpoint
|
||||
await loadSessionHistory();
|
||||
// Ensure timeline reloads to highlight current checkpoint
|
||||
setTimelineVersion((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleFork = (checkpointId: string) => {
|
||||
setForkCheckpointId(checkpointId);
|
||||
setForkSessionName(`Fork-${new Date().toISOString().slice(0, 10)}`);
|
||||
setShowForkDialog(true);
|
||||
};
|
||||
|
||||
const handleConfirmFork = async () => {
|
||||
if (!forkCheckpointId || !forkSessionName.trim() || !effectiveSession) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const newSessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
await api.forkFromCheckpoint(
|
||||
forkCheckpointId,
|
||||
effectiveSession.id,
|
||||
effectiveSession.project_id,
|
||||
projectPath,
|
||||
newSessionId,
|
||||
forkSessionName
|
||||
);
|
||||
|
||||
// Open the new forked session
|
||||
// You would need to implement navigation to the new session
|
||||
console.log("Forked to new session:", newSessionId);
|
||||
|
||||
setShowForkDialog(false);
|
||||
setForkCheckpointId(null);
|
||||
setForkSessionName("");
|
||||
} catch (err) {
|
||||
console.error("Failed to fork checkpoint:", err);
|
||||
setError("Failed to fork checkpoint");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up listeners on component unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unlistenRefs.current.forEach(unlisten => unlisten());
|
||||
// Clear checkpoint manager when session ends
|
||||
if (effectiveSession) {
|
||||
api.clearCheckpointManager(effectiveSession.id).catch(err => {
|
||||
console.error("Failed to clear checkpoint manager:", err);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Claude Code Session</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{session ? `Resuming session ${session.id.slice(0, 8)}...` : 'Interactive session'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{effectiveSession && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowTimeline(!showTimeline)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Timeline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{messages.length > 0 && (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Timeline Navigator */}
|
||||
{showTimeline && effectiveSession && (
|
||||
<div className="border-b border-border">
|
||||
<div className="p-4">
|
||||
<TimelineNavigator
|
||||
sessionId={effectiveSession.id}
|
||||
projectId={effectiveSession.project_id}
|
||||
projectPath={projectPath}
|
||||
currentMessageIndex={messages.length - 1}
|
||||
onCheckpointSelect={handleCheckpointSelect}
|
||||
refreshVersion={timelineVersion}
|
||||
onFork={handleFork}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Path Selection (only for new sessions) */}
|
||||
{!session && (
|
||||
<div className="p-4 border-b border-border space-y-4">
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Project Path */}
|
||||
<div className="space-y-2">
|
||||
<Label>Project Path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={projectPath}
|
||||
onChange={(e) => setProjectPath(e.target.value)}
|
||||
placeholder="Select or enter project path"
|
||||
disabled={hasActiveSessionRef.current}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSelectPath}
|
||||
disabled={hasActiveSessionRef.current}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages Display */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 pb-40">
|
||||
{messages.length === 0 && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<Terminal className="h-16 w-16 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">Ready to Start</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{session
|
||||
? "Send a message to continue this conversation"
|
||||
: "Select a project path and send your first prompt"
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{session ? "Loading session history..." : "Initializing Claude Code..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{messages.map((message, index) => (
|
||||
<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>
|
||||
|
||||
{/* Show loading indicator when processing, even if there are messages */}
|
||||
{isLoading && messages.length > 0 && (
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Prompt Input */}
|
||||
<FloatingPromptInput
|
||||
onSend={handleSendPrompt}
|
||||
isLoading={isLoading}
|
||||
disabled={!projectPath && !session}
|
||||
defaultModel={currentModel}
|
||||
projectPath={projectPath}
|
||||
/>
|
||||
|
||||
{/* Token Counter */}
|
||||
<TokenCounter tokens={totalTokens} />
|
||||
|
||||
{/* Fork Dialog */}
|
||||
<Dialog open={showForkDialog} onOpenChange={setShowForkDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Fork Session</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new session branch from the selected checkpoint.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fork-name">New Session Name</Label>
|
||||
<Input
|
||||
id="fork-name"
|
||||
placeholder="e.g., Alternative approach"
|
||||
value={forkSessionName}
|
||||
onChange={(e) => setForkSessionName(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter" && !isLoading) {
|
||||
handleConfirmFork();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowForkDialog(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmFork}
|
||||
disabled={isLoading || !forkSessionName.trim()}
|
||||
>
|
||||
Create Fork
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Settings Dialog */}
|
||||
{showSettings && effectiveSession && (
|
||||
<Dialog open={showSettings} onOpenChange={setShowSettings}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<CheckpointSettings
|
||||
sessionId={effectiveSession.id}
|
||||
projectId={effectiveSession.project_id}
|
||||
projectPath={projectPath}
|
||||
onClose={() => setShowSettings(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
179
src/components/ClaudeFileEditor.tsx
Normal file
179
src/components/ClaudeFileEditor.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { api, type ClaudeMdFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ClaudeFileEditorProps {
|
||||
/**
|
||||
* The CLAUDE.md file to edit
|
||||
*/
|
||||
file: ClaudeMdFile;
|
||||
/**
|
||||
* Callback to go back to the previous view
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeFileEditor component for editing project-specific CLAUDE.md files
|
||||
*
|
||||
* @example
|
||||
* <ClaudeFileEditor
|
||||
* file={claudeMdFile}
|
||||
* onBack={() => setEditingFile(null)}
|
||||
* />
|
||||
*/
|
||||
export const ClaudeFileEditor: React.FC<ClaudeFileEditorProps> = ({
|
||||
file,
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const hasChanges = content !== originalContent;
|
||||
|
||||
// Load the file content on mount
|
||||
useEffect(() => {
|
||||
loadFileContent();
|
||||
}, [file.absolute_path]);
|
||||
|
||||
const loadFileContent = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fileContent = await api.readClaudeMdFile(file.absolute_path);
|
||||
setContent(fileContent);
|
||||
setOriginalContent(fileContent);
|
||||
} catch (err) {
|
||||
console.error("Failed to load file:", err);
|
||||
setError("Failed to load CLAUDE.md file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setToast(null);
|
||||
await api.saveClaudeMdFile(file.absolute_path, content);
|
||||
setOriginalContent(content);
|
||||
setToast({ message: "File saved successfully", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("Failed to save file:", err);
|
||||
setError("Failed to save CLAUDE.md file");
|
||||
setToast({ message: "Failed to save file", type: "error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (hasChanges) {
|
||||
const confirmLeave = window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to leave?"
|
||||
);
|
||||
if (!confirmLeave) return;
|
||||
}
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold truncate">{file.relative_path}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Edit project-specific Claude Code system prompt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
size="sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || "")}
|
||||
preview="edit"
|
||||
height="100%"
|
||||
visibleDragbar={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
158
src/components/ClaudeMemoriesDropdown.tsx
Normal file
158
src/components/ClaudeMemoriesDropdown.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ChevronDown, Edit2, FileText, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api, type ClaudeMdFile } from "@/lib/api";
|
||||
import { formatUnixTimestamp } from "@/lib/date-utils";
|
||||
|
||||
interface ClaudeMemoriesDropdownProps {
|
||||
/**
|
||||
* The project path to search for CLAUDE.md files
|
||||
*/
|
||||
projectPath: string;
|
||||
/**
|
||||
* Callback when an edit button is clicked
|
||||
*/
|
||||
onEditFile: (file: ClaudeMdFile) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeMemoriesDropdown component - Shows all CLAUDE.md files in a project
|
||||
*
|
||||
* @example
|
||||
* <ClaudeMemoriesDropdown
|
||||
* projectPath="/Users/example/project"
|
||||
* onEditFile={(file) => console.log('Edit file:', file)}
|
||||
* />
|
||||
*/
|
||||
export const ClaudeMemoriesDropdown: React.FC<ClaudeMemoriesDropdownProps> = ({
|
||||
projectPath,
|
||||
onEditFile,
|
||||
className,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [files, setFiles] = useState<ClaudeMdFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load CLAUDE.md files when dropdown opens
|
||||
useEffect(() => {
|
||||
if (isOpen && files.length === 0) {
|
||||
loadClaudeMdFiles();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadClaudeMdFiles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const foundFiles = await api.findClaudeMdFiles(projectPath);
|
||||
setFiles(foundFiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load CLAUDE.md files:", err);
|
||||
setError("Failed to load CLAUDE.md files");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Card className="overflow-hidden">
|
||||
{/* Dropdown Header */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-3 hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">CLAUDE.md Memories</span>
|
||||
{files.length > 0 && !loading && (
|
||||
<span className="text-xs text-muted-foreground">({files.length})</span>
|
||||
)}
|
||||
</div>
|
||||
<motion.div
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</motion.div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Content */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="border-t border-border">
|
||||
{loading ? (
|
||||
<div className="p-4 flex items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-3 text-xs text-destructive">{error}</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
No CLAUDE.md files found in this project
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{files.map((file, index) => (
|
||||
<motion.div
|
||||
key={file.absolute_path}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="flex items-center justify-between p-3 hover:bg-accent/50 transition-colors border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-2">
|
||||
<p className="text-xs font-mono truncate">{file.relative_path}</p>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Modified {formatUnixTimestamp(file.modified)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditFile(file);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
359
src/components/CreateAgent.tsx
Normal file
359
src/components/CreateAgent.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { AgentSandboxSettings } from "./AgentSandboxSettings";
|
||||
|
||||
interface CreateAgentProps {
|
||||
/**
|
||||
* Optional agent to edit (if provided, component is in edit mode)
|
||||
*/
|
||||
agent?: Agent;
|
||||
/**
|
||||
* Callback to go back to the agents list
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Callback when agent is created/updated
|
||||
*/
|
||||
onAgentCreated: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* CreateAgent component for creating or editing a CC agent
|
||||
*
|
||||
* @example
|
||||
* <CreateAgent onBack={() => setView('list')} onAgentCreated={handleCreated} />
|
||||
*/
|
||||
export const CreateAgent: React.FC<CreateAgentProps> = ({
|
||||
agent,
|
||||
onBack,
|
||||
onAgentCreated,
|
||||
className,
|
||||
}) => {
|
||||
const [name, setName] = useState(agent?.name || "");
|
||||
const [selectedIcon, setSelectedIcon] = useState<AgentIconName>((agent?.icon as AgentIconName) || "bot");
|
||||
const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt || "");
|
||||
const [defaultTask, setDefaultTask] = useState(agent?.default_task || "");
|
||||
const [model, setModel] = useState(agent?.model || "sonnet");
|
||||
const [sandboxEnabled, setSandboxEnabled] = useState(agent?.sandbox_enabled ?? true);
|
||||
const [enableFileRead, setEnableFileRead] = useState(agent?.enable_file_read ?? true);
|
||||
const [enableFileWrite, setEnableFileWrite] = useState(agent?.enable_file_write ?? true);
|
||||
const [enableNetwork, setEnableNetwork] = useState(agent?.enable_network ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const isEditMode = !!agent;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) {
|
||||
setError("Agent name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!systemPrompt.trim()) {
|
||||
setError("System prompt is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
if (isEditMode && agent.id) {
|
||||
await api.updateAgent(
|
||||
agent.id,
|
||||
name,
|
||||
selectedIcon,
|
||||
systemPrompt,
|
||||
defaultTask || undefined,
|
||||
model,
|
||||
sandboxEnabled,
|
||||
enableFileRead,
|
||||
enableFileWrite,
|
||||
enableNetwork
|
||||
);
|
||||
} else {
|
||||
await api.createAgent(
|
||||
name,
|
||||
selectedIcon,
|
||||
systemPrompt,
|
||||
defaultTask || undefined,
|
||||
model,
|
||||
sandboxEnabled,
|
||||
enableFileRead,
|
||||
enableFileWrite,
|
||||
enableNetwork
|
||||
);
|
||||
}
|
||||
|
||||
onAgentCreated();
|
||||
} catch (err) {
|
||||
console.error("Failed to save agent:", err);
|
||||
setError(isEditMode ? "Failed to update agent" : "Failed to create agent");
|
||||
setToast({
|
||||
message: isEditMode ? "Failed to update agent" : "Failed to create agent",
|
||||
type: "error"
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if ((name !== (agent?.name || "") ||
|
||||
selectedIcon !== (agent?.icon || "bot") ||
|
||||
systemPrompt !== (agent?.system_prompt || "") ||
|
||||
defaultTask !== (agent?.default_task || "") ||
|
||||
model !== (agent?.model || "sonnet") ||
|
||||
sandboxEnabled !== (agent?.sandbox_enabled ?? true) ||
|
||||
enableFileRead !== (agent?.enable_file_read ?? true) ||
|
||||
enableFileWrite !== (agent?.enable_file_write ?? true) ||
|
||||
enableNetwork !== (agent?.enable_network ?? false)) &&
|
||||
!confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
||||
return;
|
||||
}
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isEditMode ? "Edit CC Agent" : "Create CC Agent"}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isEditMode ? "Update your Claude Code agent" : "Create a new Claude Code agent"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim() || !systemPrompt.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
85
src/components/ErrorBoundary.tsx
Normal file
85
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React, { Component, ReactNode } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Boundary component to catch and display React rendering errors
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
// Update state so the next render will show the fallback UI
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
// Log the error to console
|
||||
console.error("Error caught by boundary:", error, errorInfo);
|
||||
}
|
||||
|
||||
reset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError && this.state.error) {
|
||||
// Use custom fallback if provided
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.reset);
|
||||
}
|
||||
|
||||
// Default error UI
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px] p-4">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-8 w-8 text-destructive flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="text-lg font-semibold">Something went wrong</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
An error occurred while rendering this component.
|
||||
</p>
|
||||
{this.state.error.message && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Error details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-muted p-2 rounded overflow-auto">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<Button
|
||||
onClick={this.reset}
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
102
src/components/ExecutionControlBar.tsx
Normal file
102
src/components/ExecutionControlBar.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { StopCircle, Clock, Hash } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ExecutionControlBarProps {
|
||||
isExecuting: boolean;
|
||||
onStop: () => void;
|
||||
totalTokens?: number;
|
||||
elapsedTime?: number; // in seconds
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating control bar shown during agent execution
|
||||
* Provides stop functionality and real-time statistics
|
||||
*/
|
||||
export const ExecutionControlBar: React.FC<ExecutionControlBarProps> = ({
|
||||
isExecuting,
|
||||
onStop,
|
||||
totalTokens = 0,
|
||||
elapsedTime = 0,
|
||||
className
|
||||
}) => {
|
||||
// Format elapsed time
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
if (mins > 0) {
|
||||
return `${mins}m ${secs.toFixed(0)}s`;
|
||||
}
|
||||
return `${secs.toFixed(1)}s`;
|
||||
};
|
||||
|
||||
// Format token count
|
||||
const formatTokens = (tokens: number) => {
|
||||
if (tokens >= 1000) {
|
||||
return `${(tokens / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return tokens.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isExecuting && (
|
||||
<motion.div
|
||||
initial={{ y: 100, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: 100, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className={cn(
|
||||
"fixed bottom-6 left-1/2 -translate-x-1/2 z-50",
|
||||
"bg-background/95 backdrop-blur-md border rounded-full shadow-lg",
|
||||
"px-6 py-3 flex items-center gap-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Rotating symbol indicator */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="rotating-symbol text-primary"></div>
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<span className="text-sm font-medium">Executing...</span>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
{/* Time */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>{formatTime(elapsedTime)}</span>
|
||||
</div>
|
||||
|
||||
{/* Tokens */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span>{formatTokens(totalTokens)} tokens</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Stop button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={onStop}
|
||||
className="gap-2"
|
||||
>
|
||||
<StopCircle className="h-3.5 w-3.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
492
src/components/FilePicker.tsx
Normal file
492
src/components/FilePicker.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
X,
|
||||
Folder,
|
||||
File,
|
||||
ArrowLeft,
|
||||
FileCode,
|
||||
FileText,
|
||||
FileImage,
|
||||
Search,
|
||||
ChevronRight
|
||||
} from "lucide-react";
|
||||
import type { FileEntry } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Global caches that persist across component instances
|
||||
const globalDirectoryCache = new Map<string, FileEntry[]>();
|
||||
const globalSearchCache = new Map<string, FileEntry[]>();
|
||||
|
||||
// Note: These caches persist for the lifetime of the application.
|
||||
// In a production app, you might want to:
|
||||
// 1. Add TTL (time-to-live) to expire old entries
|
||||
// 2. Implement LRU (least recently used) eviction
|
||||
// 3. Clear caches when the working directory changes
|
||||
// 4. Add a maximum cache size limit
|
||||
|
||||
interface FilePickerProps {
|
||||
/**
|
||||
* The base directory path to browse
|
||||
*/
|
||||
basePath: string;
|
||||
/**
|
||||
* Callback when a file/directory is selected
|
||||
*/
|
||||
onSelect: (entry: FileEntry) => void;
|
||||
/**
|
||||
* Callback to close the picker
|
||||
*/
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Initial search query
|
||||
*/
|
||||
initialQuery?: string;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// File icon mapping based on extension
|
||||
const getFileIcon = (entry: FileEntry) => {
|
||||
if (entry.is_directory) return Folder;
|
||||
|
||||
const ext = entry.extension?.toLowerCase();
|
||||
if (!ext) return File;
|
||||
|
||||
// Code files
|
||||
if (['ts', 'tsx', 'js', 'jsx', 'py', 'rs', 'go', 'java', 'cpp', 'c', 'h'].includes(ext)) {
|
||||
return FileCode;
|
||||
}
|
||||
|
||||
// Text/Markdown files
|
||||
if (['md', 'txt', 'json', 'yaml', 'yml', 'toml', 'xml', 'html', 'css'].includes(ext)) {
|
||||
return FileText;
|
||||
}
|
||||
|
||||
// Image files
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico'].includes(ext)) {
|
||||
return FileImage;
|
||||
}
|
||||
|
||||
return File;
|
||||
};
|
||||
|
||||
// Format file size to human readable
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* FilePicker component - File browser with fuzzy search
|
||||
*
|
||||
* @example
|
||||
* <FilePicker
|
||||
* basePath="/Users/example/project"
|
||||
* onSelect={(entry) => console.log('Selected:', entry)}
|
||||
* onClose={() => setShowPicker(false)}
|
||||
* />
|
||||
*/
|
||||
export const FilePicker: React.FC<FilePickerProps> = ({
|
||||
basePath,
|
||||
onSelect,
|
||||
onClose,
|
||||
initialQuery = "",
|
||||
className,
|
||||
}) => {
|
||||
const searchQuery = initialQuery;
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(basePath);
|
||||
const [entries, setEntries] = useState<FileEntry[]>(() =>
|
||||
searchQuery.trim() ? [] : globalDirectoryCache.get(basePath) || []
|
||||
);
|
||||
const [searchResults, setSearchResults] = useState<FileEntry[]>(() => {
|
||||
if (searchQuery.trim()) {
|
||||
const cacheKey = `${basePath}:${searchQuery}`;
|
||||
return globalSearchCache.get(cacheKey) || [];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pathHistory, setPathHistory] = useState<string[]>([basePath]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isShowingCached, setIsShowingCached] = useState(() => {
|
||||
// Check if we're showing cached data on mount
|
||||
if (searchQuery.trim()) {
|
||||
const cacheKey = `${basePath}:${searchQuery}`;
|
||||
return globalSearchCache.has(cacheKey);
|
||||
}
|
||||
return globalDirectoryCache.has(basePath);
|
||||
});
|
||||
|
||||
const searchDebounceRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const fileListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Computed values
|
||||
const displayEntries = searchQuery.trim() ? searchResults : entries;
|
||||
const canGoBack = pathHistory.length > 1;
|
||||
|
||||
// Get relative path for display
|
||||
const relativePath = currentPath.startsWith(basePath)
|
||||
? currentPath.slice(basePath.length) || '/'
|
||||
: currentPath;
|
||||
|
||||
// Load directory contents
|
||||
useEffect(() => {
|
||||
loadDirectory(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const cacheKey = `${basePath}:${searchQuery}`;
|
||||
|
||||
// Immediately show cached results if available
|
||||
if (globalSearchCache.has(cacheKey)) {
|
||||
console.log('[FilePicker] Immediately showing cached search results for:', searchQuery);
|
||||
setSearchResults(globalSearchCache.get(cacheKey) || []);
|
||||
setIsShowingCached(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Schedule fresh search after debounce
|
||||
searchDebounceRef.current = setTimeout(() => {
|
||||
performSearch(searchQuery);
|
||||
}, 300);
|
||||
} else {
|
||||
setSearchResults([]);
|
||||
setIsShowingCached(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (searchDebounceRef.current) {
|
||||
clearTimeout(searchDebounceRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchQuery, basePath]);
|
||||
|
||||
// Reset selected index when entries change
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [entries, searchResults]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const displayEntries = searchQuery.trim() ? searchResults : entries;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
// Enter always selects the current item (file or directory)
|
||||
if (displayEntries.length > 0 && selectedIndex < displayEntries.length) {
|
||||
onSelect(displayEntries[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.max(0, prev - 1));
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => Math.min(displayEntries.length - 1, prev + 1));
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
// Right arrow enters directories
|
||||
if (displayEntries.length > 0 && selectedIndex < displayEntries.length) {
|
||||
const entry = displayEntries[selectedIndex];
|
||||
if (entry.is_directory) {
|
||||
navigateToDirectory(entry.path);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
// Left arrow goes back to parent directory
|
||||
if (canGoBack) {
|
||||
navigateBack();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [entries, searchResults, selectedIndex, searchQuery, canGoBack]);
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (fileListRef.current) {
|
||||
const selectedElement = fileListRef.current.querySelector(`[data-index="${selectedIndex}"]`);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
const loadDirectory = async (path: string) => {
|
||||
try {
|
||||
console.log('[FilePicker] Loading directory:', path);
|
||||
|
||||
// Check cache first and show immediately
|
||||
if (globalDirectoryCache.has(path)) {
|
||||
console.log('[FilePicker] Showing cached contents for:', path);
|
||||
setEntries(globalDirectoryCache.get(path) || []);
|
||||
setIsShowingCached(true);
|
||||
setError(null);
|
||||
} else {
|
||||
// Only show loading if we don't have cached data
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Always fetch fresh data in background
|
||||
const contents = await api.listDirectoryContents(path);
|
||||
console.log('[FilePicker] Loaded fresh contents:', contents.length, 'items');
|
||||
|
||||
// Cache the results
|
||||
globalDirectoryCache.set(path, contents);
|
||||
|
||||
// Update with fresh data
|
||||
setEntries(contents);
|
||||
setIsShowingCached(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[FilePicker] Failed to load directory:', path, err);
|
||||
console.error('[FilePicker] Error details:', err);
|
||||
// Only set error if we don't have cached data to show
|
||||
if (!globalDirectoryCache.has(path)) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load directory');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = async (query: string) => {
|
||||
try {
|
||||
console.log('[FilePicker] Searching for:', query, 'in:', basePath);
|
||||
|
||||
// Create cache key that includes both query and basePath
|
||||
const cacheKey = `${basePath}:${query}`;
|
||||
|
||||
// Check cache first and show immediately
|
||||
if (globalSearchCache.has(cacheKey)) {
|
||||
console.log('[FilePicker] Showing cached search results for:', query);
|
||||
setSearchResults(globalSearchCache.get(cacheKey) || []);
|
||||
setIsShowingCached(true);
|
||||
setError(null);
|
||||
} else {
|
||||
// Only show loading if we don't have cached data
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Always fetch fresh results in background
|
||||
const results = await api.searchFiles(basePath, query);
|
||||
console.log('[FilePicker] Fresh search results:', results.length, 'items');
|
||||
|
||||
// Cache the results
|
||||
globalSearchCache.set(cacheKey, results);
|
||||
|
||||
// Update with fresh results
|
||||
setSearchResults(results);
|
||||
setIsShowingCached(false);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[FilePicker] Search failed:', query, err);
|
||||
// Only set error if we don't have cached data to show
|
||||
const cacheKey = `${basePath}:${query}`;
|
||||
if (!globalSearchCache.has(cacheKey)) {
|
||||
setError(err instanceof Error ? err.message : 'Search failed');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToDirectory = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
setPathHistory(prev => [...prev, path]);
|
||||
};
|
||||
|
||||
const navigateBack = () => {
|
||||
if (pathHistory.length > 1) {
|
||||
const newHistory = [...pathHistory];
|
||||
newHistory.pop(); // Remove current
|
||||
const previousPath = newHistory[newHistory.length - 1];
|
||||
|
||||
// Don't go beyond the base path
|
||||
if (previousPath.startsWith(basePath) || previousPath === basePath) {
|
||||
setCurrentPath(previousPath);
|
||||
setPathHistory(newHistory);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryClick = (entry: FileEntry) => {
|
||||
// Single click always selects (file or directory)
|
||||
onSelect(entry);
|
||||
};
|
||||
|
||||
const handleEntryDoubleClick = (entry: FileEntry) => {
|
||||
// Double click navigates into directories
|
||||
if (entry.is_directory) {
|
||||
navigateToDirectory(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className={cn(
|
||||
"absolute bottom-full mb-2 left-0 z-50",
|
||||
"w-[500px] h-[400px]",
|
||||
"bg-background border border-border rounded-lg shadow-lg",
|
||||
"flex flex-col overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={navigateBack}
|
||||
disabled={!canGoBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm font-mono text-muted-foreground truncate max-w-[300px]">
|
||||
{relativePath}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="flex-1 overflow-y-auto relative">
|
||||
{/* Show loading only if no cached data */}
|
||||
{isLoading && displayEntries.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show subtle indicator when displaying cached data while fetching fresh */}
|
||||
{isShowingCached && isLoading && displayEntries.length > 0 && (
|
||||
<div className="absolute top-1 right-2 text-xs text-muted-foreground/50 italic">
|
||||
updating...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && displayEntries.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && displayEntries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<Search className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{searchQuery.trim() ? 'No files found' : 'Empty directory'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayEntries.length > 0 && (
|
||||
<div className="p-2 space-y-0.5" ref={fileListRef}>
|
||||
{displayEntries.map((entry, index) => {
|
||||
const Icon = getFileIcon(entry);
|
||||
const isSearching = searchQuery.trim() !== '';
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.path}
|
||||
data-index={index}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
onDoubleClick={() => handleEntryDoubleClick(entry)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md",
|
||||
"hover:bg-accent transition-colors",
|
||||
"text-left text-sm",
|
||||
isSelected && "bg-accent"
|
||||
)}
|
||||
title={entry.is_directory ? "Click to select • Double-click to enter" : "Click to select"}
|
||||
>
|
||||
<Icon className={cn(
|
||||
"h-4 w-4 flex-shrink-0",
|
||||
entry.is_directory ? "text-blue-500" : "text-muted-foreground"
|
||||
)} />
|
||||
|
||||
<span className="flex-1 truncate">
|
||||
{entry.name}
|
||||
</span>
|
||||
|
||||
{!entry.is_directory && entry.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatFileSize(entry.size)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{entry.is_directory && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{isSearching && (
|
||||
<span className="text-xs text-muted-foreground font-mono truncate max-w-[150px]">
|
||||
{entry.path.replace(basePath, '').replace(/^\//, '')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-border p-2">
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
↑↓ Navigate • Enter Select • → Enter Directory • ← Go Back • Esc Close
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
387
src/components/FloatingPromptInput.tsx
Normal file
387
src/components/FloatingPromptInput.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Send,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ChevronUp,
|
||||
Sparkles,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FilePicker } from "./FilePicker";
|
||||
import { type FileEntry } from "@/lib/api";
|
||||
|
||||
interface FloatingPromptInputProps {
|
||||
/**
|
||||
* Callback when prompt is sent
|
||||
*/
|
||||
onSend: (prompt: string, model: "sonnet" | "opus") => void;
|
||||
/**
|
||||
* Whether the input is loading
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* Whether the input is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Default model to select
|
||||
*/
|
||||
defaultModel?: "sonnet" | "opus";
|
||||
/**
|
||||
* Project path for file picker
|
||||
*/
|
||||
projectPath?: string;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type Model = {
|
||||
id: "sonnet" | "opus";
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
const MODELS: Model[] = [
|
||||
{
|
||||
id: "sonnet",
|
||||
name: "Claude 4 Sonnet",
|
||||
description: "Faster, efficient for most tasks",
|
||||
icon: <Zap className="h-4 w-4" />
|
||||
},
|
||||
{
|
||||
id: "opus",
|
||||
name: "Claude 4 Opus",
|
||||
description: "More capable, better for complex tasks",
|
||||
icon: <Sparkles className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* FloatingPromptInput component - Fixed position prompt input with model picker
|
||||
*
|
||||
* @example
|
||||
* <FloatingPromptInput
|
||||
* onSend={(prompt, model) => console.log('Send:', prompt, model)}
|
||||
* isLoading={false}
|
||||
* />
|
||||
*/
|
||||
export const FloatingPromptInput: React.FC<FloatingPromptInputProps> = ({
|
||||
onSend,
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
defaultModel = "sonnet",
|
||||
projectPath,
|
||||
className,
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState<"sonnet" | "opus">(defaultModel);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [modelPickerOpen, setModelPickerOpen] = useState(false);
|
||||
const [showFilePicker, setShowFilePicker] = useState(false);
|
||||
const [filePickerQuery, setFilePickerQuery] = useState("");
|
||||
const [cursorPosition, setCursorPosition] = useState(0);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const expandedTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus the appropriate textarea when expanded state changes
|
||||
if (isExpanded && expandedTextareaRef.current) {
|
||||
expandedTextareaRef.current.focus();
|
||||
} else if (!isExpanded && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (prompt.trim() && !isLoading && !disabled) {
|
||||
onSend(prompt.trim(), selectedModel);
|
||||
setPrompt("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const newCursorPosition = e.target.selectionStart || 0;
|
||||
|
||||
// Check if @ was just typed
|
||||
if (projectPath?.trim() && newValue.length > prompt.length && newValue[newCursorPosition - 1] === '@') {
|
||||
console.log('[FloatingPromptInput] @ detected, projectPath:', projectPath);
|
||||
setShowFilePicker(true);
|
||||
setFilePickerQuery("");
|
||||
setCursorPosition(newCursorPosition);
|
||||
}
|
||||
|
||||
// Check if we're typing after @ (for search query)
|
||||
if (showFilePicker && newCursorPosition >= cursorPosition) {
|
||||
// Find the @ position before cursor
|
||||
let atPosition = -1;
|
||||
for (let i = newCursorPosition - 1; i >= 0; i--) {
|
||||
if (newValue[i] === '@') {
|
||||
atPosition = i;
|
||||
break;
|
||||
}
|
||||
// Stop if we hit whitespace (new word)
|
||||
if (newValue[i] === ' ' || newValue[i] === '\n') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (atPosition !== -1) {
|
||||
const query = newValue.substring(atPosition + 1, newCursorPosition);
|
||||
setFilePickerQuery(query);
|
||||
} else {
|
||||
// @ was removed or cursor moved away
|
||||
setShowFilePicker(false);
|
||||
setFilePickerQuery("");
|
||||
}
|
||||
}
|
||||
|
||||
setPrompt(newValue);
|
||||
setCursorPosition(newCursorPosition);
|
||||
};
|
||||
|
||||
const handleFileSelect = (entry: FileEntry) => {
|
||||
if (textareaRef.current) {
|
||||
// Replace the @ and partial query with the selected path (file or directory)
|
||||
const textarea = textareaRef.current;
|
||||
const beforeAt = prompt.substring(0, cursorPosition - 1);
|
||||
const afterCursor = prompt.substring(cursorPosition + filePickerQuery.length);
|
||||
const relativePath = entry.path.startsWith(projectPath || '')
|
||||
? entry.path.slice((projectPath || '').length + 1)
|
||||
: entry.path;
|
||||
|
||||
const newPrompt = `${beforeAt}@${relativePath} ${afterCursor}`;
|
||||
setPrompt(newPrompt);
|
||||
setShowFilePicker(false);
|
||||
setFilePickerQuery("");
|
||||
|
||||
// Focus back on textarea and set cursor position after the inserted path
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = beforeAt.length + relativePath.length + 2; // +2 for @ and space
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilePickerClose = () => {
|
||||
setShowFilePicker(false);
|
||||
setFilePickerQuery("");
|
||||
// Return focus to textarea
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showFilePicker && e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowFilePicker(false);
|
||||
setFilePickerQuery("");
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" && !e.shiftKey && !isExpanded && !showFilePicker) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModelData = MODELS.find(m => m.id === selectedModel) || MODELS[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Expanded Modal */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-background border border-border rounded-lg shadow-lg w-full max-w-2xl p-4 space-y-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Compose your prompt</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Minimize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
ref={expandedTextareaRef}
|
||||
value={prompt}
|
||||
onChange={handleTextChange}
|
||||
placeholder="Type your prompt here..."
|
||||
className="min-h-[200px] resize-none"
|
||||
disabled={isLoading || disabled}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Model:</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setModelPickerOpen(!modelPickerOpen)}
|
||||
className="gap-2"
|
||||
>
|
||||
{selectedModelData.icon}
|
||||
{selectedModelData.name}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!prompt.trim() || isLoading || disabled}
|
||||
size="sm"
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="rotating-symbol text-primary-foreground"></div>
|
||||
) : (
|
||||
<>
|
||||
<Send className="mr-2 h-4 w-4" />
|
||||
Send
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Fixed Position Input Bar */}
|
||||
<div className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-40 bg-background border-t border-border",
|
||||
className
|
||||
)}>
|
||||
<div className="max-w-5xl mx-auto p-4">
|
||||
<div className="flex items-end gap-3">
|
||||
{/* Model Picker */}
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
disabled={isLoading || disabled}
|
||||
className="gap-2 min-w-[180px] justify-start"
|
||||
>
|
||||
{selectedModelData.icon}
|
||||
<span className="flex-1 text-left">{selectedModelData.name}</span>
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
}
|
||||
content={
|
||||
<div className="w-[300px] p-1">
|
||||
{MODELS.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
setSelectedModel(model.id);
|
||||
setModelPickerOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 p-3 rounded-md transition-colors text-left",
|
||||
"hover:bg-accent",
|
||||
selectedModel === model.id && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="mt-0.5">{model.icon}</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="font-medium text-sm">{model.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
open={modelPickerOpen}
|
||||
onOpenChange={setModelPickerOpen}
|
||||
align="start"
|
||||
side="top"
|
||||
/>
|
||||
|
||||
{/* Prompt Input */}
|
||||
<div className="flex-1 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask Claude anything..."
|
||||
disabled={isLoading || disabled}
|
||||
className="min-h-[44px] max-h-[120px] resize-none pr-10"
|
||||
rows={1}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
disabled={isLoading || disabled}
|
||||
className="absolute right-1 bottom-1 h-8 w-8"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* File Picker */}
|
||||
<AnimatePresence>
|
||||
{showFilePicker && projectPath && projectPath.trim() && (
|
||||
<FilePicker
|
||||
basePath={projectPath.trim()}
|
||||
onSelect={handleFileSelect}
|
||||
onClose={handleFilePickerClose}
|
||||
initialQuery={filePickerQuery}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!prompt.trim() || isLoading || disabled}
|
||||
size="default"
|
||||
className="min-w-[60px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="rotating-symbol text-primary-foreground"></div>
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
Press Enter to send, Shift+Enter for new line{projectPath?.trim() && ", @ to mention files"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
449
src/components/MCPAddServer.tsx
Normal file
449
src/components/MCPAddServer.tsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import React, { useState } from "react";
|
||||
import { Plus, Terminal, Globe, Trash2, Info, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { SelectComponent } from "@/components/ui/select";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface MCPAddServerProps {
|
||||
/**
|
||||
* Callback when a server is successfully added
|
||||
*/
|
||||
onServerAdded: () => void;
|
||||
/**
|
||||
* Callback for error messages
|
||||
*/
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
interface EnvironmentVariable {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for adding new MCP servers
|
||||
* Supports both stdio and SSE transport types
|
||||
*/
|
||||
export const MCPAddServer: React.FC<MCPAddServerProps> = ({
|
||||
onServerAdded,
|
||||
onError,
|
||||
}) => {
|
||||
const [transport, setTransport] = useState<"stdio" | "sse">("stdio");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Stdio server state
|
||||
const [stdioName, setStdioName] = useState("");
|
||||
const [stdioCommand, setStdioCommand] = useState("");
|
||||
const [stdioArgs, setStdioArgs] = useState("");
|
||||
const [stdioScope, setStdioScope] = useState("local");
|
||||
const [stdioEnvVars, setStdioEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||
|
||||
// SSE server state
|
||||
const [sseName, setSseName] = useState("");
|
||||
const [sseUrl, setSseUrl] = useState("");
|
||||
const [sseScope, setSseScope] = useState("local");
|
||||
const [sseEnvVars, setSseEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||
|
||||
/**
|
||||
* Adds a new environment variable
|
||||
*/
|
||||
const addEnvVar = (type: "stdio" | "sse") => {
|
||||
const newVar: EnvironmentVariable = {
|
||||
id: `env-${Date.now()}`,
|
||||
key: "",
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (type === "stdio") {
|
||||
setStdioEnvVars(prev => [...prev, newVar]);
|
||||
} else {
|
||||
setSseEnvVars(prev => [...prev, newVar]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an environment variable
|
||||
*/
|
||||
const updateEnvVar = (type: "stdio" | "sse", id: string, field: "key" | "value", value: string) => {
|
||||
if (type === "stdio") {
|
||||
setStdioEnvVars(prev => prev.map(v =>
|
||||
v.id === id ? { ...v, [field]: value } : v
|
||||
));
|
||||
} else {
|
||||
setSseEnvVars(prev => prev.map(v =>
|
||||
v.id === id ? { ...v, [field]: value } : v
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an environment variable
|
||||
*/
|
||||
const removeEnvVar = (type: "stdio" | "sse", id: string) => {
|
||||
if (type === "stdio") {
|
||||
setStdioEnvVars(prev => prev.filter(v => v.id !== id));
|
||||
} else {
|
||||
setSseEnvVars(prev => prev.filter(v => v.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and adds a stdio server
|
||||
*/
|
||||
const handleAddStdioServer = async () => {
|
||||
if (!stdioName.trim()) {
|
||||
onError("Server name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdioCommand.trim()) {
|
||||
onError("Command is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Parse arguments
|
||||
const args = stdioArgs.trim() ? stdioArgs.split(/\s+/) : [];
|
||||
|
||||
// Convert env vars to object
|
||||
const env = stdioEnvVars.reduce((acc, { key, value }) => {
|
||||
if (key.trim() && value.trim()) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const result = await api.mcpAdd(
|
||||
stdioName,
|
||||
"stdio",
|
||||
stdioCommand,
|
||||
args,
|
||||
env,
|
||||
undefined,
|
||||
stdioScope
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Reset form
|
||||
setStdioName("");
|
||||
setStdioCommand("");
|
||||
setStdioArgs("");
|
||||
setStdioEnvVars([]);
|
||||
setStdioScope("local");
|
||||
onServerAdded();
|
||||
} else {
|
||||
onError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
onError("Failed to add server");
|
||||
console.error("Failed to add stdio server:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and adds an SSE server
|
||||
*/
|
||||
const handleAddSseServer = async () => {
|
||||
if (!sseName.trim()) {
|
||||
onError("Server name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sseUrl.trim()) {
|
||||
onError("URL is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Convert env vars to object
|
||||
const env = sseEnvVars.reduce((acc, { key, value }) => {
|
||||
if (key.trim() && value.trim()) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const result = await api.mcpAdd(
|
||||
sseName,
|
||||
"sse",
|
||||
undefined,
|
||||
[],
|
||||
env,
|
||||
sseUrl,
|
||||
sseScope
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// Reset form
|
||||
setSseName("");
|
||||
setSseUrl("");
|
||||
setSseEnvVars([]);
|
||||
setSseScope("local");
|
||||
onServerAdded();
|
||||
} else {
|
||||
onError(result.message);
|
||||
}
|
||||
} catch (error) {
|
||||
onError("Failed to add server");
|
||||
console.error("Failed to add SSE server:", error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders environment variable inputs
|
||||
*/
|
||||
const renderEnvVars = (type: "stdio" | "sse", envVars: EnvironmentVariable[]) => {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Environment Variables</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addEnvVar(type)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{envVars.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{envVars.map((envVar) => (
|
||||
<div key={envVar.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(type, envVar.id, "key", e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(type, envVar.id, "value", e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEnvVar(type, envVar.id)}
|
||||
className="h-8 w-8 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Add MCP Server</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Configure a new Model Context Protocol server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={transport} onValueChange={(v) => setTransport(v as "stdio" | "sse")}>
|
||||
<TabsList className="grid w-full grid-cols-2 max-w-sm mb-6">
|
||||
<TabsTrigger value="stdio" className="gap-2">
|
||||
<Terminal className="h-4 w-4 text-amber-500" />
|
||||
Stdio
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sse" className="gap-2">
|
||||
<Globe className="h-4 w-4 text-emerald-500" />
|
||||
SSE
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Stdio Server */}
|
||||
<TabsContent value="stdio" className="space-y-6">
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stdio-name">Server Name</Label>
|
||||
<Input
|
||||
id="stdio-name"
|
||||
placeholder="my-server"
|
||||
value={stdioName}
|
||||
onChange={(e) => setStdioName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A unique name to identify this server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stdio-command">Command</Label>
|
||||
<Input
|
||||
id="stdio-command"
|
||||
placeholder="/path/to/server"
|
||||
value={stdioCommand}
|
||||
onChange={(e) => setStdioCommand(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The command to execute the server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stdio-args">Arguments (optional)</Label>
|
||||
<Input
|
||||
id="stdio-args"
|
||||
placeholder="arg1 arg2 arg3"
|
||||
value={stdioArgs}
|
||||
onChange={(e) => setStdioArgs(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Space-separated command arguments
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="stdio-scope">Scope</Label>
|
||||
<SelectComponent
|
||||
value={stdioScope}
|
||||
onValueChange={(value: string) => setStdioScope(value)}
|
||||
options={[
|
||||
{ value: "local", label: "Local (this project only)" },
|
||||
{ value: "project", label: "Project (shared via .mcp.json)" },
|
||||
{ value: "user", label: "User (all projects)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderEnvVars("stdio", stdioEnvVars)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleAddStdioServer}
|
||||
disabled={saving}
|
||||
className="w-full gap-2 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Adding Server...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Stdio Server
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* SSE Server */}
|
||||
<TabsContent value="sse" className="space-y-6">
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sse-name">Server Name</Label>
|
||||
<Input
|
||||
id="sse-name"
|
||||
placeholder="sse-server"
|
||||
value={sseName}
|
||||
onChange={(e) => setSseName(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A unique name to identify this server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sse-url">URL</Label>
|
||||
<Input
|
||||
id="sse-url"
|
||||
placeholder="https://example.com/sse-endpoint"
|
||||
value={sseUrl}
|
||||
onChange={(e) => setSseUrl(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The SSE endpoint URL
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sse-scope">Scope</Label>
|
||||
<SelectComponent
|
||||
value={sseScope}
|
||||
onValueChange={(value: string) => setSseScope(value)}
|
||||
options={[
|
||||
{ value: "local", label: "Local (this project only)" },
|
||||
{ value: "project", label: "Project (shared via .mcp.json)" },
|
||||
{ value: "user", label: "User (all projects)" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderEnvVars("sse", sseEnvVars)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={handleAddSseServer}
|
||||
disabled={saving}
|
||||
className="w-full gap-2 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Adding Server...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add SSE Server
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Example */}
|
||||
<Card className="p-4 bg-muted/30">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<span>Example Commands</span>
|
||||
</div>
|
||||
<div className="space-y-2 text-xs text-muted-foreground">
|
||||
<div className="font-mono bg-background p-2 rounded">
|
||||
<p>• Postgres: /path/to/postgres-mcp-server --connection-string "postgresql://..."</p>
|
||||
<p>• Weather API: /usr/local/bin/weather-cli --api-key ABC123</p>
|
||||
<p>• SSE Server: https://api.example.com/mcp/stream</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
369
src/components/MCPImportExport.tsx
Normal file
369
src/components/MCPImportExport.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import React, { useState } from "react";
|
||||
import { Download, Upload, FileText, Loader2, Info, Network, Settings2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { SelectComponent } from "@/components/ui/select";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
interface MCPImportExportProps {
|
||||
/**
|
||||
* Callback when import is completed
|
||||
*/
|
||||
onImportCompleted: (imported: number, failed: number) => void;
|
||||
/**
|
||||
* Callback for error messages
|
||||
*/
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for importing and exporting MCP server configurations
|
||||
*/
|
||||
export const MCPImportExport: React.FC<MCPImportExportProps> = ({
|
||||
onImportCompleted,
|
||||
onError,
|
||||
}) => {
|
||||
const [importingDesktop, setImportingDesktop] = useState(false);
|
||||
const [importingJson, setImportingJson] = useState(false);
|
||||
const [importScope, setImportScope] = useState("local");
|
||||
|
||||
/**
|
||||
* Imports servers from Claude Desktop
|
||||
*/
|
||||
const handleImportFromDesktop = async () => {
|
||||
try {
|
||||
setImportingDesktop(true);
|
||||
// Always use "user" scope for Claude Desktop imports (was previously "global")
|
||||
const result = await api.mcpAddFromClaudeDesktop("user");
|
||||
|
||||
// Show detailed results if available
|
||||
if (result.servers && result.servers.length > 0) {
|
||||
const successfulServers = result.servers.filter(s => s.success);
|
||||
const failedServers = result.servers.filter(s => !s.success);
|
||||
|
||||
if (successfulServers.length > 0) {
|
||||
const successMessage = `Successfully imported: ${successfulServers.map(s => s.name).join(", ")}`;
|
||||
onImportCompleted(result.imported_count, result.failed_count);
|
||||
// Show success details
|
||||
if (failedServers.length === 0) {
|
||||
onError(successMessage);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedServers.length > 0) {
|
||||
const failureDetails = failedServers
|
||||
.map(s => `${s.name}: ${s.error || "Unknown error"}`)
|
||||
.join("\n");
|
||||
onError(`Failed to import some servers:\n${failureDetails}`);
|
||||
}
|
||||
} else {
|
||||
onImportCompleted(result.imported_count, result.failed_count);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to import from Claude Desktop:", error);
|
||||
onError(error.toString() || "Failed to import from Claude Desktop");
|
||||
} finally {
|
||||
setImportingDesktop(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles JSON file import
|
||||
*/
|
||||
const handleJsonFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setImportingJson(true);
|
||||
const content = await file.text();
|
||||
|
||||
// Parse the JSON to validate it
|
||||
let jsonData;
|
||||
try {
|
||||
jsonData = JSON.parse(content);
|
||||
} catch (e) {
|
||||
onError("Invalid JSON file. Please check the format.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a single server or multiple servers
|
||||
if (jsonData.mcpServers) {
|
||||
// Multiple servers format
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const [name, config] of Object.entries(jsonData.mcpServers)) {
|
||||
try {
|
||||
const serverConfig = {
|
||||
type: "stdio",
|
||||
command: (config as any).command,
|
||||
args: (config as any).args || [],
|
||||
env: (config as any).env || {}
|
||||
};
|
||||
|
||||
const result = await api.mcpAddJson(name, JSON.stringify(serverConfig), importScope);
|
||||
if (result.success) {
|
||||
imported++;
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
} catch (e) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
onImportCompleted(imported, failed);
|
||||
} else if (jsonData.type && jsonData.command) {
|
||||
// Single server format
|
||||
const name = prompt("Enter a name for this server:");
|
||||
if (!name) return;
|
||||
|
||||
const result = await api.mcpAddJson(name, content, importScope);
|
||||
if (result.success) {
|
||||
onImportCompleted(1, 0);
|
||||
} else {
|
||||
onError(result.message);
|
||||
}
|
||||
} else {
|
||||
onError("Unrecognized JSON format. Expected MCP server configuration.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import JSON:", error);
|
||||
onError("Failed to import JSON file");
|
||||
} finally {
|
||||
setImportingJson(false);
|
||||
// Reset the input
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles exporting servers (placeholder)
|
||||
*/
|
||||
const handleExport = () => {
|
||||
// TODO: Implement export functionality
|
||||
onError("Export functionality coming soon!");
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts Claude Code as MCP server
|
||||
*/
|
||||
const handleStartMCPServer = async () => {
|
||||
try {
|
||||
await api.mcpServe();
|
||||
onError("Claude Code MCP server started. You can now connect to it from other applications.");
|
||||
} catch (error) {
|
||||
console.error("Failed to start MCP server:", error);
|
||||
onError("Failed to start Claude Code as MCP server");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Import & Export</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Import MCP servers from other sources or export your configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Import Scope Selection */}
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Settings2 className="h-4 w-4 text-slate-500" />
|
||||
<Label className="text-sm font-medium">Import Scope</Label>
|
||||
</div>
|
||||
<SelectComponent
|
||||
value={importScope}
|
||||
onValueChange={(value: string) => setImportScope(value)}
|
||||
options={[
|
||||
{ value: "local", label: "Local (this project only)" },
|
||||
{ value: "project", label: "Project (shared via .mcp.json)" },
|
||||
{ value: "user", label: "User (all projects)" },
|
||||
]}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose where to save imported servers from JSON files
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Import from Claude Desktop */}
|
||||
<Card className="p-4 hover:bg-accent/5 transition-colors">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-blue-500/10 rounded-lg">
|
||||
<Download className="h-5 w-5 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">Import from Claude Desktop</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Automatically imports all MCP servers from Claude Desktop. Installs to user scope (available across all projects).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleImportFromDesktop}
|
||||
disabled={importingDesktop}
|
||||
className="w-full gap-2 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{importingDesktop ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Import from Claude Desktop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Import from JSON */}
|
||||
<Card className="p-4 hover:bg-accent/5 transition-colors">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-purple-500/10 rounded-lg">
|
||||
<FileText className="h-5 w-5 text-purple-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">Import from JSON</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Import server configuration from a JSON file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleJsonFileSelect}
|
||||
disabled={importingJson}
|
||||
className="hidden"
|
||||
id="json-file-input"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => document.getElementById("json-file-input")?.click()}
|
||||
disabled={importingJson}
|
||||
className="w-full gap-2"
|
||||
variant="outline"
|
||||
>
|
||||
{importingJson ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Importing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileText className="h-4 w-4" />
|
||||
Choose JSON File
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Export (Coming Soon) */}
|
||||
<Card className="p-4 opacity-60">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-muted rounded-lg">
|
||||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">Export Configuration</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Export your MCP server configuration
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
disabled={true}
|
||||
variant="secondary"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Export (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Serve as MCP */}
|
||||
<Card className="p-4 border-primary/20 bg-primary/5 hover:bg-primary/10 transition-colors">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2.5 bg-green-500/20 rounded-lg">
|
||||
<Network className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium">Use Claude Code as MCP Server</h4>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Start Claude Code as an MCP server that other applications can connect to
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleStartMCPServer}
|
||||
variant="outline"
|
||||
className="w-full gap-2 border-green-500/20 hover:bg-green-500/10 hover:text-green-600 hover:border-green-500/50"
|
||||
>
|
||||
<Network className="h-4 w-4" />
|
||||
Start MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card className="p-4 bg-muted/30">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Info className="h-4 w-4 text-primary" />
|
||||
<span>JSON Format Examples</span>
|
||||
</div>
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground mb-1">Single server:</p>
|
||||
<pre className="bg-background p-3 rounded-lg overflow-x-auto">
|
||||
{`{
|
||||
"type": "stdio",
|
||||
"command": "/path/to/server",
|
||||
"args": ["--arg1", "value"],
|
||||
"env": { "KEY": "value" }
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-muted-foreground mb-1">Multiple servers (.mcp.json format):</p>
|
||||
<pre className="bg-background p-3 rounded-lg overflow-x-auto">
|
||||
{`{
|
||||
"mcpServers": {
|
||||
"server1": {
|
||||
"command": "/path/to/server1",
|
||||
"args": [],
|
||||
"env": {}
|
||||
},
|
||||
"server2": {
|
||||
"command": "/path/to/server2",
|
||||
"args": ["--port", "8080"],
|
||||
"env": { "API_KEY": "..." }
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
215
src/components/MCPManager.tsx
Normal file
215
src/components/MCPManager.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { ArrowLeft, Network, Plus, Download, AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { api, type MCPServer } from "@/lib/api";
|
||||
import { MCPServerList } from "./MCPServerList";
|
||||
import { MCPAddServer } from "./MCPAddServer";
|
||||
import { MCPImportExport } from "./MCPImportExport";
|
||||
|
||||
interface MCPManagerProps {
|
||||
/**
|
||||
* Callback to go back to the main view
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main component for managing MCP (Model Context Protocol) servers
|
||||
* Provides a comprehensive UI for adding, configuring, and managing MCP servers
|
||||
*/
|
||||
export const MCPManager: React.FC<MCPManagerProps> = ({
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("servers");
|
||||
const [servers, setServers] = useState<MCPServer[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
// Load servers on mount
|
||||
useEffect(() => {
|
||||
loadServers();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Loads all MCP servers
|
||||
*/
|
||||
const loadServers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.log("MCPManager: Loading servers...");
|
||||
const serverList = await api.mcpList();
|
||||
console.log("MCPManager: Received server list:", serverList);
|
||||
console.log("MCPManager: Server count:", serverList.length);
|
||||
setServers(serverList);
|
||||
} catch (err) {
|
||||
console.error("MCPManager: Failed to load MCP servers:", err);
|
||||
setError("Failed to load MCP servers. Make sure Claude Code is installed.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles server added event
|
||||
*/
|
||||
const handleServerAdded = () => {
|
||||
loadServers();
|
||||
setToast({ message: "MCP server added successfully!", type: "success" });
|
||||
setActiveTab("servers");
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles server removed event
|
||||
*/
|
||||
const handleServerRemoved = (name: string) => {
|
||||
setServers(prev => prev.filter(s => s.name !== name));
|
||||
setToast({ message: `Server "${name}" removed successfully!`, type: "success" });
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles import completed event
|
||||
*/
|
||||
const handleImportCompleted = (imported: number, failed: number) => {
|
||||
loadServers();
|
||||
if (failed === 0) {
|
||||
setToast({
|
||||
message: `Successfully imported ${imported} server${imported > 1 ? 's' : ''}!`,
|
||||
type: "success"
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
message: `Imported ${imported} server${imported > 1 ? 's' : ''}, ${failed} failed`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full bg-background text-foreground ${className || ""}`}>
|
||||
<div className="max-w-5xl mx-auto w-full flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Network className="h-5 w-5 text-blue-500" />
|
||||
MCP Servers
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Manage Model Context Protocol servers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mx-4 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/50 flex items-center gap-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main Content */}
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-3">
|
||||
<TabsTrigger value="servers" className="gap-2">
|
||||
<Network className="h-4 w-4 text-blue-500" />
|
||||
Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="add" className="gap-2">
|
||||
<Plus className="h-4 w-4 text-green-500" />
|
||||
Add Server
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="import" className="gap-2">
|
||||
<Download className="h-4 w-4 text-purple-500" />
|
||||
Import/Export
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Servers Tab */}
|
||||
<TabsContent value="servers" className="mt-6">
|
||||
<Card>
|
||||
<MCPServerList
|
||||
servers={servers}
|
||||
loading={false}
|
||||
onServerRemoved={handleServerRemoved}
|
||||
onRefresh={loadServers}
|
||||
/>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Add Server Tab */}
|
||||
<TabsContent value="add" className="mt-6">
|
||||
<Card>
|
||||
<MCPAddServer
|
||||
onServerAdded={handleServerAdded}
|
||||
onError={(message: string) => setToast({ message, type: "error" })}
|
||||
/>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Import/Export Tab */}
|
||||
<TabsContent value="import" className="mt-6">
|
||||
<Card className="overflow-hidden">
|
||||
<MCPImportExport
|
||||
onImportCompleted={handleImportCompleted}
|
||||
onError={(message: string) => setToast({ message, type: "error" })}
|
||||
/>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
407
src/components/MCPServerList.tsx
Normal file
407
src/components/MCPServerList.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Network,
|
||||
Globe,
|
||||
Terminal,
|
||||
Trash2,
|
||||
Play,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
FolderOpen,
|
||||
User,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api, type MCPServer } from "@/lib/api";
|
||||
|
||||
interface MCPServerListProps {
|
||||
/**
|
||||
* List of MCP servers to display
|
||||
*/
|
||||
servers: MCPServer[];
|
||||
/**
|
||||
* Whether the list is loading
|
||||
*/
|
||||
loading: boolean;
|
||||
/**
|
||||
* Callback when a server is removed
|
||||
*/
|
||||
onServerRemoved: (name: string) => void;
|
||||
/**
|
||||
* Callback to refresh the server list
|
||||
*/
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying a list of MCP servers
|
||||
* Shows servers grouped by scope with status indicators
|
||||
*/
|
||||
export const MCPServerList: React.FC<MCPServerListProps> = ({
|
||||
servers,
|
||||
loading,
|
||||
onServerRemoved,
|
||||
onRefresh,
|
||||
}) => {
|
||||
const [removingServer, setRemovingServer] = useState<string | null>(null);
|
||||
const [testingServer, setTestingServer] = useState<string | null>(null);
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
const [copiedServer, setCopiedServer] = useState<string | null>(null);
|
||||
|
||||
// Group servers by scope
|
||||
const serversByScope = servers.reduce((acc, server) => {
|
||||
const scope = server.scope || "local";
|
||||
if (!acc[scope]) acc[scope] = [];
|
||||
acc[scope].push(server);
|
||||
return acc;
|
||||
}, {} as Record<string, MCPServer[]>);
|
||||
|
||||
/**
|
||||
* Toggles expanded state for a server
|
||||
*/
|
||||
const toggleExpanded = (serverName: string) => {
|
||||
setExpandedServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serverName)) {
|
||||
next.delete(serverName);
|
||||
} else {
|
||||
next.add(serverName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Copies command to clipboard
|
||||
*/
|
||||
const copyCommand = async (command: string, serverName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopiedServer(serverName);
|
||||
setTimeout(() => setCopiedServer(null), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy command:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a server
|
||||
*/
|
||||
const handleRemoveServer = async (name: string) => {
|
||||
try {
|
||||
setRemovingServer(name);
|
||||
await api.mcpRemove(name);
|
||||
onServerRemoved(name);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove server:", error);
|
||||
} finally {
|
||||
setRemovingServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Tests connection to a server
|
||||
*/
|
||||
const handleTestConnection = async (name: string) => {
|
||||
try {
|
||||
setTestingServer(name);
|
||||
const result = await api.mcpTestConnection(name);
|
||||
// TODO: Show result in a toast or modal
|
||||
console.log("Test result:", result);
|
||||
} catch (error) {
|
||||
console.error("Failed to test connection:", error);
|
||||
} finally {
|
||||
setTestingServer(null);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets icon for transport type
|
||||
*/
|
||||
const getTransportIcon = (transport: string) => {
|
||||
switch (transport) {
|
||||
case "stdio":
|
||||
return <Terminal className="h-4 w-4 text-amber-500" />;
|
||||
case "sse":
|
||||
return <Globe className="h-4 w-4 text-emerald-500" />;
|
||||
default:
|
||||
return <Network className="h-4 w-4 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets icon for scope
|
||||
*/
|
||||
const getScopeIcon = (scope: string) => {
|
||||
switch (scope) {
|
||||
case "local":
|
||||
return <User className="h-3 w-3 text-slate-500" />;
|
||||
case "project":
|
||||
return <FolderOpen className="h-3 w-3 text-orange-500" />;
|
||||
case "user":
|
||||
return <FileText className="h-3 w-3 text-purple-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets scope display name
|
||||
*/
|
||||
const getScopeDisplayName = (scope: string) => {
|
||||
switch (scope) {
|
||||
case "local":
|
||||
return "Local (Project-specific)";
|
||||
case "project":
|
||||
return "Project (Shared via .mcp.json)";
|
||||
case "user":
|
||||
return "User (All projects)";
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a single server item
|
||||
*/
|
||||
const renderServerItem = (server: MCPServer) => {
|
||||
const isExpanded = expandedServers.has(server.name);
|
||||
const isCopied = copiedServer === server.name;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={server.name}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
className="group p-4 rounded-lg border border-border bg-card hover:bg-accent/5 hover:border-primary/20 transition-all overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 bg-primary/10 rounded">
|
||||
{getTransportIcon(server.transport)}
|
||||
</div>
|
||||
<h4 className="font-medium truncate">{server.name}</h4>
|
||||
{server.status?.running && (
|
||||
<Badge variant="outline" className="gap-1 flex-shrink-0 border-green-500/50 text-green-600 bg-green-500/10">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{server.command && !isExpanded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate pl-9 flex-1" title={server.command}>
|
||||
{server.command}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpanded(server.name)}
|
||||
className="h-6 px-2 text-xs hover:bg-primary/10"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3 mr-1" />
|
||||
Show full
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.transport === "sse" && server.url && !isExpanded && (
|
||||
<div className="overflow-hidden">
|
||||
<p className="text-xs text-muted-foreground font-mono truncate pl-9" title={server.url}>
|
||||
{server.url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(server.env).length > 0 && !isExpanded && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground pl-9">
|
||||
<span>Environment variables: {Object.keys(server.env).length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(server.name)}
|
||||
disabled={testingServer === server.name}
|
||||
className="hover:bg-green-500/10 hover:text-green-600"
|
||||
>
|
||||
{testingServer === server.name ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveServer(server.name)}
|
||||
disabled={removingServer === server.name}
|
||||
className="hover:bg-destructive/10 hover:text-destructive"
|
||||
>
|
||||
{removingServer === server.name ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="pl-9 space-y-3 pt-2 border-t border-border/50"
|
||||
>
|
||||
{server.command && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium text-muted-foreground">Command</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyCommand(server.command!, server.name)}
|
||||
className="h-6 px-2 text-xs hover:bg-primary/10"
|
||||
>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
{isCopied ? "Copied!" : "Copy"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleExpanded(server.name)}
|
||||
className="h-6 px-2 text-xs hover:bg-primary/10"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3 mr-1" />
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-mono bg-muted/50 p-2 rounded break-all">
|
||||
{server.command}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.args && server.args.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Arguments</p>
|
||||
<div className="text-xs font-mono bg-muted/50 p-2 rounded space-y-1">
|
||||
{server.args.map((arg, idx) => (
|
||||
<div key={idx} className="break-all">
|
||||
<span className="text-muted-foreground mr-2">[{idx}]</span>
|
||||
{arg}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{server.transport === "sse" && server.url && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">URL</p>
|
||||
<p className="text-xs font-mono bg-muted/50 p-2 rounded break-all">
|
||||
{server.url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(server.env).length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Environment Variables</p>
|
||||
<div className="text-xs font-mono bg-muted/50 p-2 rounded space-y-1">
|
||||
{Object.entries(server.env).map(([key, value]) => (
|
||||
<div key={key} className="break-all">
|
||||
<span className="text-primary">{key}</span>
|
||||
<span className="text-muted-foreground mx-1">=</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Configured Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRefresh}
|
||||
className="gap-2 hover:bg-primary/10 hover:text-primary hover:border-primary/50"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server List */}
|
||||
{servers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="p-4 bg-primary/10 rounded-full mb-4">
|
||||
<Network className="h-12 w-12 text-primary" />
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-2 font-medium">No MCP servers configured</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add a server to get started with Model Context Protocol
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(serversByScope).map(([scope, scopeServers]) => (
|
||||
<div key={scope} className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
{getScopeIcon(scope)}
|
||||
<span className="font-medium">{getScopeDisplayName(scope)}</span>
|
||||
<span className="text-muted-foreground/60">({scopeServers.length})</span>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
<div className="space-y-2">
|
||||
{scopeServers.map(renderServerItem)}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
171
src/components/MarkdownEditor.tsx
Normal file
171
src/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import MDEditor from "@uiw/react-md-editor";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeft, Save, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
/**
|
||||
* Callback to go back to the main view
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* MarkdownEditor component for editing the CLAUDE.md system prompt
|
||||
*
|
||||
* @example
|
||||
* <MarkdownEditor onBack={() => setView('main')} />
|
||||
*/
|
||||
export const MarkdownEditor: React.FC<MarkdownEditorProps> = ({
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [originalContent, setOriginalContent] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const hasChanges = content !== originalContent;
|
||||
|
||||
// Load the system prompt on mount
|
||||
useEffect(() => {
|
||||
loadSystemPrompt();
|
||||
}, []);
|
||||
|
||||
const loadSystemPrompt = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const prompt = await api.getSystemPrompt();
|
||||
setContent(prompt);
|
||||
setOriginalContent(prompt);
|
||||
} catch (err) {
|
||||
console.error("Failed to load system prompt:", err);
|
||||
setError("Failed to load CLAUDE.md file");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setToast(null);
|
||||
await api.saveSystemPrompt(content);
|
||||
setOriginalContent(content);
|
||||
setToast({ message: "CLAUDE.md saved successfully", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("Failed to save system prompt:", err);
|
||||
setError("Failed to save CLAUDE.md file");
|
||||
setToast({ message: "Failed to save CLAUDE.md", type: "error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (hasChanges) {
|
||||
const confirmLeave = window.confirm(
|
||||
"You have unsaved changes. Are you sure you want to leave?"
|
||||
);
|
||||
if (!confirmLeave) return;
|
||||
}
|
||||
onBack();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background", className)}>
|
||||
<div className="w-full max-w-5xl mx-auto flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">CLAUDE.md</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Edit your Claude Code system prompt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving}
|
||||
size="sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-xs text-destructive"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Editor */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full rounded-lg border border-border overflow-hidden shadow-sm" data-color-mode="dark">
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || "")}
|
||||
preview="edit"
|
||||
height="100%"
|
||||
visibleDragbar={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
297
src/components/NFOCredits.tsx
Normal file
297
src/components/NFOCredits.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Volume2, VolumeX, Github } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import asteriskLogo from "@/assets/nfo/asterisk-logo.png";
|
||||
import keygennMusic from "@/assets/nfo/claudia-nfo.ogg";
|
||||
|
||||
interface NFOCreditsProps {
|
||||
/**
|
||||
* Callback when the NFO window is closed
|
||||
*/
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* NFO Credits component - Displays a keygen/crack style credits window
|
||||
* with auto-scrolling text, retro fonts, and background music
|
||||
*
|
||||
* @example
|
||||
* <NFOCredits onClose={() => setShowNFO(false)} />
|
||||
*/
|
||||
export const NFOCredits: React.FC<NFOCreditsProps> = ({ onClose }) => {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [scrollPosition, setScrollPosition] = useState(0);
|
||||
|
||||
// Initialize and autoplay audio muted then unmute
|
||||
useEffect(() => {
|
||||
const audio = new Audio(keygennMusic);
|
||||
audio.loop = true;
|
||||
audio.volume = 0.7;
|
||||
// Start muted to satisfy autoplay policy
|
||||
audio.muted = true;
|
||||
audioRef.current = audio;
|
||||
// Attempt to play
|
||||
audio.play().then(() => {
|
||||
// Unmute after autoplay
|
||||
audio.muted = false;
|
||||
}).catch(err => {
|
||||
console.error("Audio autoplay failed:", err);
|
||||
});
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle mute toggle
|
||||
const toggleMute = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.muted = !isMuted;
|
||||
setIsMuted(!isMuted);
|
||||
}
|
||||
};
|
||||
|
||||
// Start auto-scrolling
|
||||
useEffect(() => {
|
||||
const scrollInterval = setInterval(() => {
|
||||
setScrollPosition(prev => prev + 1);
|
||||
}, 30); // Smooth scrolling speed
|
||||
|
||||
return () => clearInterval(scrollInterval);
|
||||
}, []);
|
||||
|
||||
// Apply scroll position
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const maxScroll = scrollRef.current.scrollHeight - scrollRef.current.clientHeight;
|
||||
if (scrollPosition >= maxScroll) {
|
||||
// Reset to beginning when reaching the end
|
||||
setScrollPosition(0);
|
||||
scrollRef.current.scrollTop = 0;
|
||||
} else {
|
||||
scrollRef.current.scrollTop = scrollPosition;
|
||||
}
|
||||
}
|
||||
}, [scrollPosition]);
|
||||
|
||||
// Credits content
|
||||
const creditsContent = [
|
||||
{ type: "header", text: "CLAUDIA v0.1.0" },
|
||||
{ type: "subheader", text: "[ A STRATEGIC PROJECT BY ASTERISK ]" },
|
||||
{ type: "spacer" },
|
||||
{ type: "section", title: "━━━ CREDITS ━━━" },
|
||||
{ type: "credit", role: "POWERED BY", name: "Anthropic Claude 4" },
|
||||
{ type: "credit", role: "CLAUDE CODE", name: "The Ultimate Coding Assistant" },
|
||||
{ type: "credit", role: "MCP PROTOCOL", name: "Model Context Protocol" },
|
||||
{ type: "spacer" },
|
||||
{ type: "section", title: "━━━ DEPENDENCIES ━━━" },
|
||||
{ type: "credit", role: "RUNTIME", name: "Tauri Framework" },
|
||||
{ type: "credit", role: "UI FRAMEWORK", name: "React + TypeScript" },
|
||||
{ type: "credit", role: "STYLING", name: "Tailwind CSS + shadcn/ui" },
|
||||
{ type: "credit", role: "ANIMATIONS", name: "Framer Motion" },
|
||||
{ type: "credit", role: "BUILD TOOL", name: "Vite" },
|
||||
{ type: "credit", role: "PACKAGE MANAGER", name: "Bun" },
|
||||
{ type: "spacer" },
|
||||
{ type: "section", title: "━━━ SPECIAL THANKS ━━━" },
|
||||
{ type: "text", content: "To the open source community" },
|
||||
{ type: "text", content: "To all the beta testers" },
|
||||
{ type: "text", content: "To everyone who believed in this project" },
|
||||
{ type: "spacer" },
|
||||
{ type: "ascii", content: `
|
||||
▄▄▄· .▄▄ · ▄▄▄▄▄▄▄▄ .▄▄▄ ▪ .▄▄ · ▄ •▄
|
||||
▐█ ▀█ ▐█ ▀. •██ ▀▄.▀·▀▄ █·██ ▐█ ▀. █▌▄▌▪
|
||||
▄█▀▀█ ▄▀▀▀█▄ ▐█.▪▐▀▀▪▄▐▀▀▄ ▐█·▄▀▀▀█▄▐▀▀▄·
|
||||
▐█ ▪▐▌▐█▄▪▐█ ▐█▌·▐█▄▄▌▐█•█▌▐█▌▐█▄▪▐█▐█.█▌
|
||||
▀ ▀ ▀▀▀▀ ▀▀▀ ▀▀▀ .▀ ▀▀▀▀ ▀▀▀▀ ·▀ ▀
|
||||
` },
|
||||
{ type: "spacer" },
|
||||
{ type: "text", content: "Remember: Sharing is caring!" },
|
||||
{ type: "text", content: "Support the developers!" },
|
||||
{ type: "spacer" },
|
||||
{ type: "spacer" },
|
||||
{ type: "spacer" },
|
||||
];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
{/* Backdrop with blur */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/80 backdrop-blur-md"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* NFO Window */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
className="relative z-10"
|
||||
>
|
||||
<Card className="w-[600px] h-[500px] bg-background border-border shadow-2xl overflow-hidden">
|
||||
{/* Window Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-card border-b border-border">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-sm font-bold tracking-wider font-mono text-foreground">
|
||||
CLAUDIA.NFO
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await openUrl("https://github.com/getAsterisk/claudia/issues/new");
|
||||
}}
|
||||
className="flex items-center gap-1 h-auto px-2 py-1"
|
||||
title="File a bug"
|
||||
>
|
||||
<Github className="h-3 w-3" />
|
||||
<span className="text-xs">File a bug</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NFO Content */}
|
||||
<div className="relative h-[calc(100%-40px)] bg-background overflow-hidden">
|
||||
{/* Asterisk Logo Section (Fixed at top) */}
|
||||
<div className="absolute top-0 left-0 right-0 bg-background z-10 pb-4 text-center">
|
||||
<button
|
||||
className="inline-block mt-4 hover:scale-110 transition-transform cursor-pointer"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await openUrl("https://asterisk.so");
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={asteriskLogo}
|
||||
alt="Asterisk"
|
||||
className="h-20 w-auto mx-auto filter brightness-0 invert opacity-90"
|
||||
/>
|
||||
</button>
|
||||
<div className="text-muted-foreground text-sm font-mono mt-2 tracking-wider">
|
||||
A strategic project by Asterisk
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrolling Credits */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="absolute inset-0 top-32 overflow-hidden"
|
||||
style={{ fontFamily: "'Courier New', monospace" }}
|
||||
>
|
||||
<div className="px-8 pb-32">
|
||||
{creditsContent.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case "header":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-foreground text-3xl font-bold text-center mb-2 tracking-widest"
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
case "subheader":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-muted-foreground text-lg text-center mb-8 tracking-wide"
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
);
|
||||
case "section":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-foreground text-xl font-bold text-center my-6 tracking-wider"
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
);
|
||||
case "credit":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex justify-between items-center mb-2 text-foreground"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">{item.role}:</span>
|
||||
<span className="text-base tracking-wide">{item.name}</span>
|
||||
</div>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="text-muted-foreground text-center text-sm mb-2"
|
||||
>
|
||||
{item.content}
|
||||
</div>
|
||||
);
|
||||
case "ascii":
|
||||
return (
|
||||
<pre
|
||||
key={index}
|
||||
className="text-foreground text-xs text-center my-6 leading-tight opacity-80"
|
||||
>
|
||||
{item.content}
|
||||
</pre>
|
||||
);
|
||||
case "spacer":
|
||||
return <div key={index} className="h-8" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtle Scanlines Effect */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-foreground/[0.02] to-transparent animate-scanlines" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
102
src/components/ProjectList.tsx
Normal file
102
src/components/ProjectList.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { FolderOpen, ChevronRight, Clock } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixTimestamp } from "@/lib/date-utils";
|
||||
import type { Project } from "@/lib/api";
|
||||
|
||||
interface ProjectListProps {
|
||||
/**
|
||||
* Array of projects to display
|
||||
*/
|
||||
projects: Project[];
|
||||
/**
|
||||
* Callback when a project is clicked
|
||||
*/
|
||||
onProjectClick: (project: Project) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
/**
|
||||
* ProjectList component - Displays a paginated list of projects with hover animations
|
||||
*
|
||||
* @example
|
||||
* <ProjectList
|
||||
* projects={projects}
|
||||
* onProjectClick={(project) => console.log('Selected:', project)}
|
||||
* />
|
||||
*/
|
||||
export const ProjectList: React.FC<ProjectListProps> = ({
|
||||
projects,
|
||||
onProjectClick,
|
||||
className,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentProjects = projects.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to page 1 if projects change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [projects.length]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className="space-y-2">
|
||||
{currentProjects.map((project, index) => (
|
||||
<motion.div
|
||||
key={project.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="transition-all hover:shadow-md hover:scale-[1.02] active:scale-[0.98] cursor-pointer"
|
||||
onClick={() => onProjectClick(project)}
|
||||
>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{project.path}</p>
|
||||
<div className="flex items-center space-x-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{project.sessions.length} session{project.sessions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatUnixTimestamp(project.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
281
src/components/RunningSessionsView.tsx
Normal file
281
src/components/RunningSessionsView.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Play, Square, Clock, Cpu, RefreshCw, Eye, ArrowLeft, Bot } 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 { SessionOutputViewer } from './SessionOutputViewer';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AgentRun } from '@/lib/api';
|
||||
|
||||
interface RunningSessionsViewProps {
|
||||
className?: string;
|
||||
showBackButton?: boolean;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function RunningSessionsView({ className, showBackButton = false, onBack }: RunningSessionsViewProps) {
|
||||
const [runningSessions, setRunningSessions] = useState<AgentRun[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentRun | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const loadRunningSessions = async () => {
|
||||
try {
|
||||
const sessions = await api.listRunningAgentSessions();
|
||||
setRunningSessions(sessions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load running sessions:', error);
|
||||
setToast({ message: 'Failed to load running sessions', type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshSessions = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// First cleanup finished processes
|
||||
await api.cleanupFinishedProcesses();
|
||||
// Then reload the list
|
||||
await loadRunningSessions();
|
||||
setToast({ message: 'Running sessions list has been updated', type: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh sessions:', error);
|
||||
setToast({ message: 'Failed to refresh sessions', type: 'error' });
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const killSession = async (runId: number, agentName: string) => {
|
||||
try {
|
||||
const success = await api.killAgentSession(runId);
|
||||
if (success) {
|
||||
setToast({ message: `${agentName} session has been stopped`, type: 'success' });
|
||||
// Refresh the list after killing
|
||||
await loadRunningSessions();
|
||||
} else {
|
||||
setToast({ message: 'Session may have already finished', type: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to kill session:', error);
|
||||
setToast({ message: 'Failed to terminate session', type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startTime: string) => {
|
||||
const start = new Date(startTime);
|
||||
const now = new Date();
|
||||
const durationMs = now.getTime() - start.getTime();
|
||||
const minutes = Math.floor(durationMs / (1000 * 60));
|
||||
const seconds = Math.floor((durationMs % (1000 * 60)) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Badge variant="default" className="bg-green-100 text-green-800 border-green-200">Running</Badge>;
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">Pending</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRunningSessions();
|
||||
|
||||
// Set up auto-refresh every 5 seconds
|
||||
const interval = setInterval(() => {
|
||||
if (!refreshing) {
|
||||
loadRunningSessions();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [refreshing]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
<span>Loading running sessions...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{showBackButton && onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Play className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">Running Agent Sessions</h2>
|
||||
<Badge variant="secondary">{runningSessions.length}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshSessions}
|
||||
disabled={refreshing}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{runningSessions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<div className="text-center space-y-2">
|
||||
<Clock className="h-8 w-8 mx-auto text-muted-foreground" />
|
||||
<p className="text-muted-foreground">No agent sessions are currently running</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{runningSessions.map((session) => (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 bg-blue-100 rounded-full">
|
||||
<Bot className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{session.agent_name}</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{getStatusBadge(session.status)}
|
||||
{session.pid && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Cpu className="h-3 w-3 mr-1" />
|
||||
PID {session.pid}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedSession(session)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>View Output</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => session.id && killSession(session.id, session.agent_name)}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
<span>Stop</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Task</p>
|
||||
<p className="text-sm font-medium truncate">{session.task}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Model</p>
|
||||
<p className="font-medium">{session.model}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">
|
||||
{session.process_started_at
|
||||
? formatDuration(session.process_started_at)
|
||||
: 'Unknown'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Project Path</p>
|
||||
<p className="text-xs font-mono bg-muted px-2 py-1 rounded truncate">
|
||||
{session.project_path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{session.session_id && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Session ID</p>
|
||||
<p className="text-xs font-mono bg-muted px-2 py-1 rounded truncate">
|
||||
{session.session_id}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session Output Viewer */}
|
||||
<AnimatePresence>
|
||||
{selectedSession && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="w-full max-w-4xl h-full max-h-[90vh]">
|
||||
<SessionOutputViewer
|
||||
session={selectedSession}
|
||||
onClose={() => setSelectedSession(null)}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
198
src/components/SessionList.tsx
Normal file
198
src/components/SessionList.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { FileText, ArrowLeft, Calendar, Clock, MessageSquare } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination } from "@/components/ui/pagination";
|
||||
import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatUnixTimestamp, formatISOTimestamp, truncateText, getFirstLine } from "@/lib/date-utils";
|
||||
import type { Session, ClaudeMdFile } from "@/lib/api";
|
||||
|
||||
interface SessionListProps {
|
||||
/**
|
||||
* Array of sessions to display
|
||||
*/
|
||||
sessions: Session[];
|
||||
/**
|
||||
* The current project path being viewed
|
||||
*/
|
||||
projectPath: string;
|
||||
/**
|
||||
* Callback to go back to project list
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Callback when a session is clicked
|
||||
*/
|
||||
onSessionClick?: (session: Session) => void;
|
||||
/**
|
||||
* Callback when a CLAUDE.md file should be edited
|
||||
*/
|
||||
onEditClaudeFile?: (file: ClaudeMdFile) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 5;
|
||||
|
||||
/**
|
||||
* SessionList component - Displays paginated sessions for a specific project
|
||||
*
|
||||
* @example
|
||||
* <SessionList
|
||||
* sessions={sessions}
|
||||
* projectPath="/Users/example/project"
|
||||
* onBack={() => setSelectedProject(null)}
|
||||
* onSessionClick={(session) => console.log('Selected session:', session)}
|
||||
* />
|
||||
*/
|
||||
export const SessionList: React.FC<SessionListProps> = ({
|
||||
sessions,
|
||||
projectPath,
|
||||
onBack,
|
||||
onSessionClick,
|
||||
onEditClaudeFile,
|
||||
className,
|
||||
}) => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// Calculate pagination
|
||||
const totalPages = Math.ceil(sessions.length / ITEMS_PER_PAGE);
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
const currentSessions = sessions.slice(startIndex, endIndex);
|
||||
|
||||
// Reset to page 1 if sessions change
|
||||
React.useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [sessions.length]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center space-x-3"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-base font-medium truncate">{projectPath}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sessions.length} session{sessions.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* CLAUDE.md Memories Dropdown */}
|
||||
{onEditClaudeFile && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<ClaudeMemoriesDropdown
|
||||
projectPath={projectPath}
|
||||
onEditFile={onEditClaudeFile}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="popLayout">
|
||||
<div className="space-y-2">
|
||||
{currentSessions.map((session, index) => (
|
||||
<motion.div
|
||||
key={session.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],
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"transition-all hover:shadow-md hover:scale-[1.01] active:scale-[0.99] cursor-pointer",
|
||||
session.todo_data && "border-l-4 border-l-primary"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Emit a special event for Claude Code session navigation
|
||||
const event = new CustomEvent('claude-session-selected', {
|
||||
detail: { session, projectPath }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
onSessionClick?.(session);
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1 min-w-0">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<p className="font-mono text-xs text-muted-foreground">{session.id}</p>
|
||||
|
||||
{/* First message preview */}
|
||||
{session.first_message && (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center space-x-1 text-xs text-muted-foreground">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
<span>First message:</span>
|
||||
</div>
|
||||
<p className="text-xs line-clamp-2 text-foreground/80">
|
||||
{truncateText(getFirstLine(session.first_message), 100)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center space-x-3 text-xs text-muted-foreground">
|
||||
{/* Message timestamp if available, otherwise file creation time */}
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{session.message_timestamp
|
||||
? formatISOTimestamp(session.message_timestamp)
|
||||
: formatUnixTimestamp(session.created_at)
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{session.todo_data && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>Has todo</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</AnimatePresence>
|
||||
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
591
src/components/SessionOutputViewer.tsx
Normal file
591
src/components/SessionOutputViewer.tsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Maximize2, Minimize2, Copy, RefreshCw, RotateCcw, ChevronDown } 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 } from '@/lib/api';
|
||||
import { useOutputCache } from '@/lib/outputCache';
|
||||
import type { AgentRun } from '@/lib/api';
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { StreamMessage } from './StreamMessage';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
interface SessionOutputViewerProps {
|
||||
session: AgentRun;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Use the same message interface as AgentExecution for consistency
|
||||
export interface ClaudeStreamMessage {
|
||||
type: "system" | "assistant" | "user" | "result";
|
||||
subtype?: string;
|
||||
message?: {
|
||||
content?: any[];
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
};
|
||||
usage?: {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function SessionOutputViewer({ session, onClose, className }: SessionOutputViewerProps) {
|
||||
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 similar to AgentExecution
|
||||
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 (!session.id) return;
|
||||
|
||||
try {
|
||||
// Check cache first if not skipping cache
|
||||
if (!skipCache) {
|
||||
const cached = getCachedOutput(session.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 && session.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const rawOutput = await api.getSessionOutput(session.id);
|
||||
|
||||
// Parse JSONL output into messages using AgentExecution style
|
||||
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(session.id, {
|
||||
output: rawOutput,
|
||||
messages: parsedMessages,
|
||||
lastUpdated: Date.now(),
|
||||
status: session.status
|
||||
});
|
||||
|
||||
// Set up live event listeners for running sessions
|
||||
if (session.status === 'running') {
|
||||
setupLiveEventListeners();
|
||||
|
||||
try {
|
||||
await api.streamSessionOutput(session.id);
|
||||
} catch (streamError) {
|
||||
console.warn('Failed to start streaming, will poll instead:', streamError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load session output:', error);
|
||||
setToast({ message: 'Failed to load session 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 similar to AgentExecution
|
||||
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' });
|
||||
// Don't set status here as the parent component should handle it
|
||||
});
|
||||
|
||||
unlistenRefs.current = [outputUnlisten, errorUnlisten, completeUnlisten];
|
||||
} catch (error) {
|
||||
console.error('Failed to set up live event listeners:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy functionality similar to AgentExecution
|
||||
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 Session: ${session.agent_name}\n\n`;
|
||||
markdown += `**Status:** ${session.status}\n`;
|
||||
if (session.task) markdown += `**Task:** ${session.task}\n`;
|
||||
if (session.model) markdown += `**Model:** ${session.model}\n`;
|
||||
markdown += `**Date:** ${new Date().toISOString()}\n\n`;
|
||||
markdown += `---\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);
|
||||
try {
|
||||
await loadOutput(true); // Skip cache when manually refreshing
|
||||
setToast({ message: 'Output refreshed', type: 'success' });
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh output:', error);
|
||||
setToast({ message: 'Failed to refresh output', type: 'error' });
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Load output on mount and check cache first
|
||||
useEffect(() => {
|
||||
if (!session.id) return;
|
||||
|
||||
// Check cache immediately for instant display
|
||||
const cached = getCachedOutput(session.id);
|
||||
if (cached) {
|
||||
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
|
||||
setRawJsonlOutput(cachedJsonlLines);
|
||||
setMessages(cached.messages);
|
||||
}
|
||||
|
||||
// Then load fresh data
|
||||
loadOutput();
|
||||
}, [session.id]);
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={`${isFullscreen ? 'fixed inset-0 z-50 bg-background' : ''} ${className}`}
|
||||
>
|
||||
<Card className={`h-full ${isFullscreen ? 'rounded-none border-0' : ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-2xl">{session.agent_icon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{session.agent_name} - Output</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<Badge variant={session.status === 'running' ? 'default' : 'secondary'}>
|
||||
{session.status}
|
||||
</Badge>
|
||||
{session.status === 'running' && (
|
||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
|
||||
<div className="w-1.5 h-1.5 bg-green-500 rounded-full animate-pulse mr-1"></div>
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{messages.length} messages
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{messages.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title="Fullscreen"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshOutput}
|
||||
disabled={refreshing}
|
||||
title="Refresh output"
|
||||
>
|
||||
<RotateCcw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={`${isFullscreen ? 'h-[calc(100vh-120px)]' : 'h-96'} p-0`}>
|
||||
{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>
|
||||
) : (
|
||||
<div
|
||||
className="h-full overflow-y-auto p-6 space-y-3"
|
||||
ref={scrollAreaRef}
|
||||
onScroll={() => {
|
||||
// Mark that user has scrolled manually
|
||||
if (!hasUserScrolled) {
|
||||
setHasUserScrolled(true);
|
||||
}
|
||||
|
||||
// If user scrolls back to bottom, re-enable auto-scroll
|
||||
if (isAtBottom()) {
|
||||
setHasUserScrolled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
{session.status === 'running' ? (
|
||||
<>
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">Waiting for output...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent is running but no output received yet
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground">No output available</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshOutput}
|
||||
className="mt-2"
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? <RefreshCw className="h-4 w-4 animate-spin mr-2" /> : <RotateCcw className="h-4 w-4 mr-2" />}
|
||||
Refresh
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{messages.map((message, index) => (
|
||||
<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 z-50 bg-background flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl">{session.agent_icon}</div>
|
||||
<h2 className="text-lg font-semibold">{session.agent_name} - Output</h2>
|
||||
{session.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>
|
||||
<div className="flex items-center gap-2">
|
||||
{messages.length > 0 && (
|
||||
<Popover
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Output
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</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"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="flex-1 overflow-hidden p-6">
|
||||
<div
|
||||
ref={fullscreenScrollRef}
|
||||
className="h-full overflow-y-auto space-y-3"
|
||||
onScroll={() => {
|
||||
// Mark that user has scrolled manually
|
||||
if (!hasUserScrolled) {
|
||||
setHasUserScrolled(true);
|
||||
}
|
||||
|
||||
// If user scrolls back to bottom, re-enable auto-scroll
|
||||
if (isAtBottom()) {
|
||||
setHasUserScrolled(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
{session.status === 'running' ? (
|
||||
<>
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground mb-2" />
|
||||
<p className="text-muted-foreground">Waiting for output...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Agent is running but no output received yet
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground">No output available</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{messages.map((message, index) => (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
649
src/components/Settings.tsx
Normal file
649
src/components/Settings.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
AlertCircle,
|
||||
Shield,
|
||||
Code,
|
||||
Settings2,
|
||||
Terminal,
|
||||
Loader2
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
api,
|
||||
type ClaudeSettings
|
||||
} from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||
|
||||
interface SettingsProps {
|
||||
/**
|
||||
* Callback to go back to the main view
|
||||
*/
|
||||
onBack: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface PermissionRule {
|
||||
id: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface EnvironmentVariable {
|
||||
id: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprehensive Settings UI for managing Claude Code settings
|
||||
* Provides a no-code interface for editing the settings.json file
|
||||
*/
|
||||
export const Settings: React.FC<SettingsProps> = ({
|
||||
onBack,
|
||||
className,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [settings, setSettings] = useState<ClaudeSettings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
// Permission rules state
|
||||
const [allowRules, setAllowRules] = useState<PermissionRule[]>([]);
|
||||
const [denyRules, setDenyRules] = useState<PermissionRule[]>([]);
|
||||
|
||||
// Environment variables state
|
||||
const [envVars, setEnvVars] = useState<EnvironmentVariable[]>([]);
|
||||
|
||||
|
||||
// Load settings on mount
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Loads the current Claude settings
|
||||
*/
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadedSettings = await api.getClaudeSettings();
|
||||
|
||||
// Ensure loadedSettings is an object
|
||||
if (!loadedSettings || typeof loadedSettings !== 'object') {
|
||||
console.warn("Loaded settings is not an object:", loadedSettings);
|
||||
setSettings({});
|
||||
return;
|
||||
}
|
||||
|
||||
setSettings(loadedSettings);
|
||||
|
||||
// Parse permissions
|
||||
if (loadedSettings.permissions && typeof loadedSettings.permissions === 'object') {
|
||||
if (Array.isArray(loadedSettings.permissions.allow)) {
|
||||
setAllowRules(
|
||||
loadedSettings.permissions.allow.map((rule: string, index: number) => ({
|
||||
id: `allow-${index}`,
|
||||
value: rule,
|
||||
}))
|
||||
);
|
||||
}
|
||||
if (Array.isArray(loadedSettings.permissions.deny)) {
|
||||
setDenyRules(
|
||||
loadedSettings.permissions.deny.map((rule: string, index: number) => ({
|
||||
id: `deny-${index}`,
|
||||
value: rule,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse environment variables
|
||||
if (loadedSettings.env && typeof loadedSettings.env === 'object' && !Array.isArray(loadedSettings.env)) {
|
||||
setEnvVars(
|
||||
Object.entries(loadedSettings.env).map(([key, value], index) => ({
|
||||
id: `env-${index}`,
|
||||
key,
|
||||
value: value as string,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load settings:", err);
|
||||
setError("Failed to load settings. Please ensure ~/.claude directory exists.");
|
||||
setSettings({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Saves the current settings
|
||||
*/
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setToast(null);
|
||||
|
||||
// Build the settings object
|
||||
const updatedSettings: ClaudeSettings = {
|
||||
...settings,
|
||||
permissions: {
|
||||
allow: allowRules.map(rule => rule.value).filter(v => v.trim()),
|
||||
deny: denyRules.map(rule => rule.value).filter(v => v.trim()),
|
||||
},
|
||||
env: envVars.reduce((acc, { key, value }) => {
|
||||
if (key.trim() && value.trim()) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>),
|
||||
};
|
||||
|
||||
await api.saveClaudeSettings(updatedSettings);
|
||||
setSettings(updatedSettings);
|
||||
setToast({ message: "Settings saved successfully!", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("Failed to save settings:", err);
|
||||
setError("Failed to save settings.");
|
||||
setToast({ message: "Failed to save settings", type: "error" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a simple setting value
|
||||
*/
|
||||
const updateSetting = (key: string, value: any) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new permission rule
|
||||
*/
|
||||
const addPermissionRule = (type: "allow" | "deny") => {
|
||||
const newRule: PermissionRule = {
|
||||
id: `${type}-${Date.now()}`,
|
||||
value: "",
|
||||
};
|
||||
|
||||
if (type === "allow") {
|
||||
setAllowRules(prev => [...prev, newRule]);
|
||||
} else {
|
||||
setDenyRules(prev => [...prev, newRule]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates a permission rule
|
||||
*/
|
||||
const updatePermissionRule = (type: "allow" | "deny", id: string, value: string) => {
|
||||
if (type === "allow") {
|
||||
setAllowRules(prev => prev.map(rule =>
|
||||
rule.id === id ? { ...rule, value } : rule
|
||||
));
|
||||
} else {
|
||||
setDenyRules(prev => prev.map(rule =>
|
||||
rule.id === id ? { ...rule, value } : rule
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a permission rule
|
||||
*/
|
||||
const removePermissionRule = (type: "allow" | "deny", id: string) => {
|
||||
if (type === "allow") {
|
||||
setAllowRules(prev => prev.filter(rule => rule.id !== id));
|
||||
} else {
|
||||
setDenyRules(prev => prev.filter(rule => rule.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new environment variable
|
||||
*/
|
||||
const addEnvVar = () => {
|
||||
const newVar: EnvironmentVariable = {
|
||||
id: `env-${Date.now()}`,
|
||||
key: "",
|
||||
value: "",
|
||||
};
|
||||
setEnvVars(prev => [...prev, newVar]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates an environment variable
|
||||
*/
|
||||
const updateEnvVar = (id: string, field: "key" | "value", value: string) => {
|
||||
setEnvVars(prev => prev.map(envVar =>
|
||||
envVar.id === id ? { ...envVar, [field]: value } : envVar
|
||||
));
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an environment variable
|
||||
*/
|
||||
const removeEnvVar = (id: string) => {
|
||||
setEnvVars(prev => prev.filter(envVar => envVar.id !== id));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-background text-foreground", className)}>
|
||||
<div className="max-w-4xl mx-auto w-full flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between p-4 border-b border-border"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Settings</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure Claude Code preferences
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={saveSettings}
|
||||
disabled={saving || loading}
|
||||
size="sm"
|
||||
className="gap-2 bg-primary hover:bg-primary/90"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mx-4 mt-4 p-3 rounded-lg bg-destructive/10 border border-destructive/50 flex items-center gap-2 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="general" className="gap-2">
|
||||
<Settings2 className="h-4 w-4 text-slate-500" />
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="permissions" className="gap-2">
|
||||
<Shield className="h-4 w-4 text-amber-500" />
|
||||
Permissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="environment" className="gap-2">
|
||||
<Terminal className="h-4 w-4 text-blue-500" />
|
||||
Environment
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="advanced" className="gap-2">
|
||||
<Code className="h-4 w-4 text-purple-500" />
|
||||
Advanced
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General Settings */}
|
||||
<TabsContent value="general" className="space-y-6">
|
||||
<Card className="p-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">General Settings</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Include Co-authored By */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label htmlFor="coauthored">Include "Co-authored by Claude"</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Add Claude attribution to git commits and pull requests
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="coauthored"
|
||||
checked={settings?.includeCoAuthoredBy !== false}
|
||||
onCheckedChange={(checked) => updateSetting("includeCoAuthoredBy", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Verbose Output */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5 flex-1">
|
||||
<Label htmlFor="verbose">Verbose Output</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Show full bash and command outputs
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="verbose"
|
||||
checked={settings?.verbose === true}
|
||||
onCheckedChange={(checked) => updateSetting("verbose", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cleanup Period */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cleanup">Chat Transcript Retention (days)</Label>
|
||||
<Input
|
||||
id="cleanup"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="30"
|
||||
value={settings?.cleanupPeriodDays || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
||||
updateSetting("cleanupPeriodDays", value);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How long to retain chat transcripts locally (default: 30 days)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Permissions Settings */}
|
||||
<TabsContent value="permissions" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-2">Permission Rules</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control which tools Claude Code can use without manual approval
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Allow Rules */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-green-500">Allow Rules</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addPermissionRule("allow")}
|
||||
className="gap-2 hover:border-green-500/50 hover:text-green-500"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{allowRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
No allow rules configured. Claude will ask for approval for all tools.
|
||||
</p>
|
||||
) : (
|
||||
allowRules.map((rule) => (
|
||||
<motion.div
|
||||
key={rule.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g., Bash(npm run test:*)"
|
||||
value={rule.value}
|
||||
onChange={(e) => updatePermissionRule("allow", rule.id, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePermissionRule("allow", rule.id)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deny Rules */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium text-red-500">Deny Rules</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => addPermissionRule("deny")}
|
||||
className="gap-2 hover:border-red-500/50 hover:text-red-500"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{denyRules.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
No deny rules configured.
|
||||
</p>
|
||||
) : (
|
||||
denyRules.map((rule) => (
|
||||
<motion.div
|
||||
key={rule.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
placeholder="e.g., Bash(curl:*)"
|
||||
value={rule.value}
|
||||
onChange={(e) => updatePermissionRule("deny", rule.id, e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePermissionRule("deny", rule.id)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Examples:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash</code> - Allow all bash commands</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash(npm run build)</code> - Allow exact command</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Bash(npm run test:*)</code> - Allow commands with prefix</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Read(~/.zshrc)</code> - Allow reading specific file</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">Edit(docs/**)</code> - Allow editing files in docs directory</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<TabsContent value="environment" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">Environment Variables</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Environment variables applied to every Claude Code session
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addEnvVar}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{envVars.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">
|
||||
No environment variables configured.
|
||||
</p>
|
||||
) : (
|
||||
envVars.map((envVar) => (
|
||||
<motion.div
|
||||
key={envVar.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(envVar.id, "key", e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground">=</span>
|
||||
<Input
|
||||
placeholder="value"
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(envVar.id, "value", e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeEnvVar(envVar.id)}
|
||||
className="h-8 w-8 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Common variables:</strong>
|
||||
</p>
|
||||
<ul className="text-xs text-muted-foreground space-y-1 ml-4">
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">CLAUDE_CODE_ENABLE_TELEMETRY</code> - Enable/disable telemetry (0 or 1)</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">ANTHROPIC_MODEL</code> - Custom model name</li>
|
||||
<li>• <code className="px-1 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400">DISABLE_COST_WARNINGS</code> - Disable cost warnings (1)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/* Advanced Settings */}
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">Advanced Settings</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Additional configuration options for advanced users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* API Key Helper */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="apiKeyHelper">API Key Helper Script</Label>
|
||||
<Input
|
||||
id="apiKeyHelper"
|
||||
placeholder="/path/to/generate_api_key.sh"
|
||||
value={settings?.apiKeyHelper || ""}
|
||||
onChange={(e) => updateSetting("apiKeyHelper", e.target.value || undefined)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom script to generate auth values for API requests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Raw JSON Editor */}
|
||||
<div className="space-y-2">
|
||||
<Label>Raw Settings (JSON)</Label>
|
||||
<div className="p-3 rounded-md bg-muted font-mono text-xs overflow-x-auto whitespace-pre-wrap">
|
||||
<pre>{JSON.stringify(settings, null, 2)}</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This shows the raw JSON that will be saved to ~/.claude/settings.json
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toast Notification */}
|
||||
<ToastContainer>
|
||||
{toast && (
|
||||
<Toast
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onDismiss={() => setToast(null)}
|
||||
/>
|
||||
)}
|
||||
</ToastContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
600
src/components/StreamMessage.tsx
Normal file
600
src/components/StreamMessage.tsx
Normal file
@@ -0,0 +1,600 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Terminal,
|
||||
User,
|
||||
Bot,
|
||||
AlertCircle,
|
||||
CheckCircle2
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { claudeSyntaxTheme } from "@/lib/claudeSyntaxTheme";
|
||||
import type { ClaudeStreamMessage } from "./AgentExecution";
|
||||
import {
|
||||
TodoWidget,
|
||||
LSWidget,
|
||||
ReadWidget,
|
||||
ReadResultWidget,
|
||||
GlobWidget,
|
||||
BashWidget,
|
||||
WriteWidget,
|
||||
GrepWidget,
|
||||
EditWidget,
|
||||
EditResultWidget,
|
||||
MCPWidget,
|
||||
CommandWidget,
|
||||
CommandOutputWidget,
|
||||
SummaryWidget,
|
||||
MultiEditWidget,
|
||||
MultiEditResultWidget,
|
||||
SystemReminderWidget,
|
||||
SystemInitializedWidget,
|
||||
TaskWidget,
|
||||
LSResultWidget
|
||||
} from "./ToolWidgets";
|
||||
|
||||
interface StreamMessageProps {
|
||||
message: ClaudeStreamMessage;
|
||||
className?: string;
|
||||
streamMessages: ClaudeStreamMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to render a single Claude Code stream message
|
||||
*/
|
||||
export const StreamMessage: React.FC<StreamMessageProps> = ({ message, className, streamMessages }) => {
|
||||
try {
|
||||
// Skip rendering for meta messages that don't have meaningful content
|
||||
if (message.isMeta && !message.leafUuid && !message.summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle summary messages
|
||||
if (message.leafUuid && message.summary && (message as any).type === "summary") {
|
||||
return <SummaryWidget summary={message.summary} leafUuid={message.leafUuid} />;
|
||||
}
|
||||
|
||||
// System initialization message
|
||||
if (message.type === "system" && message.subtype === "init") {
|
||||
return (
|
||||
<SystemInitializedWidget
|
||||
sessionId={message.session_id}
|
||||
model={message.model}
|
||||
cwd={message.cwd}
|
||||
tools={message.tools}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant message
|
||||
if (message.type === "assistant" && message.message) {
|
||||
const msg = message.message;
|
||||
return (
|
||||
<Card className={cn("border-primary/20 bg-primary/5", className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Bot className="h-5 w-5 text-primary mt-0.5" />
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
{msg.content && Array.isArray(msg.content) && msg.content.map((content: any, idx: number) => {
|
||||
// Text content - render as markdown
|
||||
if (content.type === "text") {
|
||||
// Ensure we have a string to render
|
||||
const textContent = typeof content.text === 'string'
|
||||
? content.text
|
||||
: (content.text?.text || JSON.stringify(content.text || content));
|
||||
|
||||
return (
|
||||
<div key={idx} className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={claudeSyntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{textContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool use - render custom widgets based on tool name
|
||||
if (content.type === "tool_use") {
|
||||
const toolName = content.name?.toLowerCase();
|
||||
const input = content.input;
|
||||
|
||||
// Task tool - for sub-agent tasks
|
||||
if (toolName === "task" && input) {
|
||||
return <TaskWidget key={idx} description={input.description} prompt={input.prompt} />;
|
||||
}
|
||||
|
||||
// Edit tool
|
||||
if (toolName === "edit" && input?.file_path) {
|
||||
return <EditWidget key={idx} {...input} />;
|
||||
}
|
||||
|
||||
// MultiEdit tool
|
||||
if (toolName === "multiedit" && input?.file_path && input?.edits) {
|
||||
return <MultiEditWidget key={idx} {...input} />;
|
||||
}
|
||||
|
||||
// MCP tools (starting with mcp__)
|
||||
if (content.name?.startsWith("mcp__")) {
|
||||
return <MCPWidget key={idx} toolName={content.name} input={input} />;
|
||||
}
|
||||
|
||||
// TodoWrite tool
|
||||
if (toolName === "todowrite" && input?.todos) {
|
||||
return <TodoWidget key={idx} todos={input.todos} />;
|
||||
}
|
||||
|
||||
// LS tool
|
||||
if (toolName === "ls" && input?.path) {
|
||||
return <LSWidget key={idx} path={input.path} />;
|
||||
}
|
||||
|
||||
// Read tool
|
||||
if (toolName === "read" && input?.file_path) {
|
||||
return <ReadWidget key={idx} filePath={input.file_path} />;
|
||||
}
|
||||
|
||||
// Glob tool
|
||||
if (toolName === "glob" && input?.pattern) {
|
||||
return <GlobWidget key={idx} pattern={input.pattern} />;
|
||||
}
|
||||
|
||||
// Bash tool
|
||||
if (toolName === "bash" && input?.command) {
|
||||
return <BashWidget key={idx} command={input.command} description={input.description} />;
|
||||
}
|
||||
|
||||
// Write tool
|
||||
if (toolName === "write" && input?.file_path && input?.content) {
|
||||
return <WriteWidget key={idx} filePath={input.file_path} content={input.content} />;
|
||||
}
|
||||
|
||||
// Grep tool
|
||||
if (toolName === "grep" && input?.pattern) {
|
||||
return <GrepWidget key={idx} pattern={input.pattern} include={input.include} path={input.path} exclude={input.exclude} />;
|
||||
}
|
||||
|
||||
// Default tool display
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
Using tool: <code className="font-mono">{content.name}</code>
|
||||
</span>
|
||||
</div>
|
||||
{content.input && (
|
||||
<div className="ml-6 p-2 bg-background rounded-md border">
|
||||
<pre className="text-xs font-mono overflow-x-auto">
|
||||
{JSON.stringify(content.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{msg.usage && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
Tokens: {msg.usage.input_tokens} in, {msg.usage.output_tokens} out
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// User message
|
||||
if (message.type === "user" && message.message) {
|
||||
// Don't render meta messages, which are for system use
|
||||
if (message.isMeta) return null;
|
||||
|
||||
const msg = message.message;
|
||||
|
||||
// Skip empty user messages
|
||||
if (!msg.content || (Array.isArray(msg.content) && msg.content.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("border-muted-foreground/20 bg-muted/20", className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1 space-y-2 min-w-0">
|
||||
{/* Handle content that is a simple string (e.g. from user commands) */}
|
||||
{typeof msg.content === 'string' && (
|
||||
(() => {
|
||||
const contentStr = msg.content as string;
|
||||
|
||||
// Check if it's a command message
|
||||
const commandMatch = contentStr.match(/<command-name>(.+?)<\/command-name>[\s\S]*?<command-message>(.+?)<\/command-message>[\s\S]*?<command-args>(.*?)<\/command-args>/);
|
||||
if (commandMatch) {
|
||||
const [, commandName, commandMessage, commandArgs] = commandMatch;
|
||||
return (
|
||||
<CommandWidget
|
||||
commandName={commandName.trim()}
|
||||
commandMessage={commandMessage.trim()}
|
||||
commandArgs={commandArgs?.trim()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's command output
|
||||
const stdoutMatch = contentStr.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
||||
if (stdoutMatch) {
|
||||
const [, output] = stdoutMatch;
|
||||
return <CommandOutputWidget output={output} />;
|
||||
}
|
||||
|
||||
// Otherwise render as plain text
|
||||
return (
|
||||
<pre className="text-sm font-mono whitespace-pre-wrap text-muted-foreground">
|
||||
{contentStr}
|
||||
</pre>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Handle content that is an array of parts */}
|
||||
{Array.isArray(msg.content) && msg.content.map((content: any, idx: number) => {
|
||||
// Tool result
|
||||
if (content.type === "tool_result") {
|
||||
// Extract the actual content string
|
||||
let contentText = '';
|
||||
if (typeof content.content === 'string') {
|
||||
contentText = content.content;
|
||||
} else if (content.content && typeof content.content === 'object') {
|
||||
// Handle object with text property
|
||||
if (content.content.text) {
|
||||
contentText = content.content.text;
|
||||
} else if (Array.isArray(content.content)) {
|
||||
// Handle array of content blocks
|
||||
contentText = content.content
|
||||
.map((c: any) => (typeof c === 'string' ? c : c.text || JSON.stringify(c)))
|
||||
.join('\n');
|
||||
} else {
|
||||
// Fallback to JSON stringify
|
||||
contentText = JSON.stringify(content.content, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for system-reminder tags
|
||||
const reminderMatch = contentText.match(/<system-reminder>(.*?)<\/system-reminder>/s);
|
||||
if (reminderMatch) {
|
||||
const reminderMessage = reminderMatch[1].trim();
|
||||
const beforeReminder = contentText.substring(0, reminderMatch.index || 0).trim();
|
||||
const afterReminder = contentText.substring((reminderMatch.index || 0) + reminderMatch[0].length).trim();
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">Tool Result</span>
|
||||
</div>
|
||||
|
||||
{beforeReminder && (
|
||||
<div className="ml-6 p-2 bg-background rounded-md border">
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{beforeReminder}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ml-6">
|
||||
<SystemReminderWidget message={reminderMessage} />
|
||||
</div>
|
||||
|
||||
{afterReminder && (
|
||||
<div className="ml-6 p-2 bg-background rounded-md border">
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{afterReminder}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is an Edit tool result
|
||||
const isEditResult = contentText.includes("has been updated. Here's the result of running `cat -n`");
|
||||
|
||||
if (isEditResult) {
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">Edit Result</span>
|
||||
</div>
|
||||
<EditResultWidget content={contentText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a MultiEdit tool result
|
||||
const isMultiEditResult = contentText.includes("has been updated with multiple edits") ||
|
||||
contentText.includes("MultiEdit completed successfully") ||
|
||||
contentText.includes("Applied multiple edits to");
|
||||
|
||||
if (isMultiEditResult) {
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">MultiEdit Result</span>
|
||||
</div>
|
||||
<MultiEditResultWidget content={contentText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is an LS tool result (directory tree structure)
|
||||
const isLSResult = (() => {
|
||||
if (!content.tool_use_id || typeof contentText !== 'string') return false;
|
||||
|
||||
// Check if this result came from an LS tool by looking for the tool call
|
||||
let isFromLSTool = false;
|
||||
|
||||
// Search in previous assistant messages for the matching tool_use
|
||||
if (streamMessages) {
|
||||
for (let i = streamMessages.length - 1; i >= 0; i--) {
|
||||
const prevMsg = streamMessages[i];
|
||||
// Only check assistant messages
|
||||
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 &&
|
||||
c.name?.toLowerCase() === 'ls'
|
||||
);
|
||||
if (toolUse) {
|
||||
isFromLSTool = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only proceed if this is from an LS tool
|
||||
if (!isFromLSTool) return false;
|
||||
|
||||
// Additional validation: check for tree structure pattern
|
||||
const lines = contentText.split('\n');
|
||||
const hasTreeStructure = lines.some(line => /^\s*-\s+/.test(line));
|
||||
const hasNoteAtEnd = lines.some(line => line.trim().startsWith('NOTE: do any of the files'));
|
||||
|
||||
return hasTreeStructure || hasNoteAtEnd;
|
||||
})();
|
||||
|
||||
if (isLSResult) {
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">Directory Contents</span>
|
||||
</div>
|
||||
<LSResultWidget content={contentText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a Read tool result (contains line numbers with arrow separator)
|
||||
const isReadResult = content.tool_use_id && typeof contentText === 'string' &&
|
||||
/^\s*\d+→/.test(contentText);
|
||||
|
||||
if (isReadResult) {
|
||||
// Try to find the corresponding Read tool call to get the file path
|
||||
let filePath: string | undefined;
|
||||
|
||||
// Search in previous assistant messages for the matching tool_use
|
||||
if (streamMessages) {
|
||||
for (let i = streamMessages.length - 1; i >= 0; i--) {
|
||||
const prevMsg = streamMessages[i];
|
||||
// Only check assistant messages
|
||||
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 &&
|
||||
c.name?.toLowerCase() === 'read'
|
||||
);
|
||||
if (toolUse?.input?.file_path) {
|
||||
filePath = toolUse.input.file_path;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">Read Result</span>
|
||||
</div>
|
||||
<ReadResultWidget content={contentText} filePath={filePath} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle empty tool results
|
||||
if (!contentText || contentText.trim() === '') {
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">Tool Result</span>
|
||||
</div>
|
||||
<div className="ml-6 p-3 bg-muted/50 rounded-md border text-sm text-muted-foreground italic">
|
||||
Tool did not return any output
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{content.is_error ? (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
<span className="text-sm font-medium">Tool Result</span>
|
||||
</div>
|
||||
<div className="ml-6 p-2 bg-background rounded-md border">
|
||||
<pre className="text-xs font-mono overflow-x-auto whitespace-pre-wrap">
|
||||
{contentText}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Text content
|
||||
if (content.type === "text") {
|
||||
// Handle both string and object formats
|
||||
const textContent = typeof content.text === 'string'
|
||||
? content.text
|
||||
: (content.text?.text || JSON.stringify(content.text));
|
||||
|
||||
return (
|
||||
<div key={idx} className="text-sm">
|
||||
{textContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Result message - render with markdown
|
||||
if (message.type === "result") {
|
||||
const isError = message.is_error || message.subtype?.includes("error");
|
||||
|
||||
return (
|
||||
<Card className={cn(
|
||||
isError ? "border-destructive/20 bg-destructive/5" : "border-green-500/20 bg-green-500/5",
|
||||
className
|
||||
)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{isError ? (
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 space-y-2">
|
||||
<h4 className="font-semibold text-sm">
|
||||
{isError ? "Execution Failed" : "Execution Complete"}
|
||||
</h4>
|
||||
|
||||
{message.result && (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({ node, inline, className, children, ...props }: any) {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={claudeSyntaxTheme}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{message.result}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.error && (
|
||||
<div className="text-sm text-destructive">{message.error}</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1 mt-2">
|
||||
{message.cost_usd !== undefined && (
|
||||
<div>Cost: ${message.cost_usd.toFixed(4)} USD</div>
|
||||
)}
|
||||
{message.duration_ms !== undefined && (
|
||||
<div>Duration: {(message.duration_ms / 1000).toFixed(2)}s</div>
|
||||
)}
|
||||
{message.num_turns !== undefined && (
|
||||
<div>Turns: {message.num_turns}</div>
|
||||
)}
|
||||
{message.usage && (
|
||||
<div>
|
||||
Total tokens: {message.usage.input_tokens + message.usage.output_tokens}
|
||||
({message.usage.input_tokens} in, {message.usage.output_tokens} out)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Skip rendering if no meaningful content
|
||||
return null;
|
||||
} catch (error) {
|
||||
// If any error occurs during rendering, show a safe error message
|
||||
console.error("Error rendering stream message:", error, message);
|
||||
return (
|
||||
<Card className={cn("border-destructive/20 bg-destructive/5", className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Error rendering message</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{error instanceof Error ? error.message : 'Unknown error'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
};
|
583
src/components/TimelineNavigator.tsx
Normal file
583
src/components/TimelineNavigator.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
GitBranch,
|
||||
Save,
|
||||
RotateCcw,
|
||||
GitFork,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Hash,
|
||||
FileCode,
|
||||
Diff
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api, type Checkpoint, type TimelineNode, type SessionTimeline, type CheckpointDiff } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface TimelineNavigatorProps {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
currentMessageIndex: number;
|
||||
onCheckpointSelect: (checkpoint: Checkpoint) => void;
|
||||
onFork: (checkpointId: string) => void;
|
||||
/**
|
||||
* Incrementing value provided by parent to force timeline reload when checkpoints
|
||||
* are created elsewhere (e.g., auto-checkpoint after tool execution).
|
||||
*/
|
||||
refreshVersion?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual timeline navigator for checkpoint management
|
||||
*/
|
||||
export const TimelineNavigator: React.FC<TimelineNavigatorProps> = ({
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
currentMessageIndex,
|
||||
onCheckpointSelect,
|
||||
onFork,
|
||||
refreshVersion = 0,
|
||||
className
|
||||
}) => {
|
||||
const [timeline, setTimeline] = useState<SessionTimeline | null>(null);
|
||||
const [selectedCheckpoint, setSelectedCheckpoint] = useState<Checkpoint | null>(null);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showDiffDialog, setShowDiffDialog] = useState(false);
|
||||
const [checkpointDescription, setCheckpointDescription] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [diff, setDiff] = useState<CheckpointDiff | null>(null);
|
||||
const [compareCheckpoint, setCompareCheckpoint] = useState<Checkpoint | null>(null);
|
||||
|
||||
// Load timeline on mount and whenever refreshVersion bumps
|
||||
useEffect(() => {
|
||||
loadTimeline();
|
||||
}, [sessionId, projectId, projectPath, refreshVersion]);
|
||||
|
||||
const loadTimeline = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const timelineData = await api.getSessionTimeline(sessionId, projectId, projectPath);
|
||||
setTimeline(timelineData);
|
||||
|
||||
// Auto-expand nodes with current checkpoint
|
||||
if (timelineData.currentCheckpointId && timelineData.rootNode) {
|
||||
const pathToNode = findPathToCheckpoint(timelineData.rootNode, timelineData.currentCheckpointId);
|
||||
setExpandedNodes(new Set(pathToNode));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load timeline:", err);
|
||||
setError("Failed to load timeline");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const findPathToCheckpoint = (node: TimelineNode, checkpointId: string, path: string[] = []): string[] => {
|
||||
if (node.checkpoint.id === checkpointId) {
|
||||
return path;
|
||||
}
|
||||
|
||||
for (const child of node.children) {
|
||||
const childPath = findPathToCheckpoint(child, checkpointId, [...path, node.checkpoint.id]);
|
||||
if (childPath.length > path.length) {
|
||||
return childPath;
|
||||
}
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
const handleCreateCheckpoint = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await api.createCheckpoint(
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
currentMessageIndex,
|
||||
checkpointDescription || undefined
|
||||
);
|
||||
|
||||
setCheckpointDescription("");
|
||||
setShowCreateDialog(false);
|
||||
await loadTimeline();
|
||||
} catch (err) {
|
||||
console.error("Failed to create checkpoint:", err);
|
||||
setError("Failed to create checkpoint");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreCheckpoint = async (checkpoint: Checkpoint) => {
|
||||
if (!confirm(`Restore to checkpoint "${checkpoint.description || checkpoint.id.slice(0, 8)}"? Current state will be saved as a new checkpoint.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First create a checkpoint of current state
|
||||
await api.createCheckpoint(
|
||||
sessionId,
|
||||
projectId,
|
||||
projectPath,
|
||||
currentMessageIndex,
|
||||
"Auto-save before restore"
|
||||
);
|
||||
|
||||
// Then restore
|
||||
await api.restoreCheckpoint(checkpoint.id, sessionId, projectId, projectPath);
|
||||
|
||||
await loadTimeline();
|
||||
onCheckpointSelect(checkpoint);
|
||||
} catch (err) {
|
||||
console.error("Failed to restore checkpoint:", err);
|
||||
setError("Failed to restore checkpoint");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFork = async (checkpoint: Checkpoint) => {
|
||||
onFork(checkpoint.id);
|
||||
};
|
||||
|
||||
const handleCompare = async (checkpoint: Checkpoint) => {
|
||||
if (!selectedCheckpoint) {
|
||||
setSelectedCheckpoint(checkpoint);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const diffData = await api.getCheckpointDiff(
|
||||
selectedCheckpoint.id,
|
||||
checkpoint.id,
|
||||
sessionId,
|
||||
projectId
|
||||
);
|
||||
|
||||
setDiff(diffData);
|
||||
setCompareCheckpoint(checkpoint);
|
||||
setShowDiffDialog(true);
|
||||
} catch (err) {
|
||||
console.error("Failed to get diff:", err);
|
||||
setError("Failed to compare checkpoints");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNodeExpansion = (nodeId: string) => {
|
||||
const newExpanded = new Set(expandedNodes);
|
||||
if (newExpanded.has(nodeId)) {
|
||||
newExpanded.delete(nodeId);
|
||||
} else {
|
||||
newExpanded.add(nodeId);
|
||||
}
|
||||
setExpandedNodes(newExpanded);
|
||||
};
|
||||
|
||||
const renderTimelineNode = (node: TimelineNode, depth: number = 0) => {
|
||||
const isExpanded = expandedNodes.has(node.checkpoint.id);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isCurrent = timeline?.currentCheckpointId === node.checkpoint.id;
|
||||
const isSelected = selectedCheckpoint?.id === node.checkpoint.id;
|
||||
|
||||
return (
|
||||
<div key={node.checkpoint.id} className="relative">
|
||||
{/* Connection line */}
|
||||
{depth > 0 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 w-6 h-6 border-l-2 border-b-2 border-muted-foreground/30"
|
||||
style={{
|
||||
left: `${(depth - 1) * 24}px`,
|
||||
borderBottomLeftRadius: '8px'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Node content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2, delay: depth * 0.05 }}
|
||||
className={cn(
|
||||
"flex items-start gap-2 py-2",
|
||||
depth > 0 && "ml-6"
|
||||
)}
|
||||
style={{ paddingLeft: `${depth * 24}px` }}
|
||||
>
|
||||
{/* Expand/collapse button */}
|
||||
{hasChildren && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 -ml-1"
|
||||
onClick={() => toggleNodeExpansion(node.checkpoint.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Checkpoint card */}
|
||||
<Card
|
||||
className={cn(
|
||||
"flex-1 cursor-pointer transition-all hover:shadow-md",
|
||||
isCurrent && "border-primary ring-2 ring-primary/20",
|
||||
isSelected && "border-blue-500 bg-blue-500/5",
|
||||
!hasChildren && "ml-5"
|
||||
)}
|
||||
onClick={() => setSelectedCheckpoint(node.checkpoint)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{isCurrent && (
|
||||
<Badge variant="default" className="text-xs">Current</Badge>
|
||||
)}
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{node.checkpoint.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(node.checkpoint.timestamp), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{node.checkpoint.description && (
|
||||
<p className="text-sm font-medium mb-1">{node.checkpoint.description}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{node.checkpoint.metadata.userPrompt || "No prompt"}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Hash className="h-3 w-3" />
|
||||
{node.checkpoint.metadata.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileCode className="h-3 w-3" />
|
||||
{node.checkpoint.metadata.fileChanges} files
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRestoreCheckpoint(node.checkpoint);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Restore to this checkpoint</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFork(node.checkpoint);
|
||||
}}
|
||||
>
|
||||
<GitFork className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Fork from this checkpoint</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCompare(node.checkpoint);
|
||||
}}
|
||||
>
|
||||
<Diff className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Compare with another checkpoint</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && hasChildren && (
|
||||
<div className="relative">
|
||||
{/* Vertical line for children */}
|
||||
{node.children.length > 1 && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-0.5 bg-muted-foreground/30"
|
||||
style={{ left: `${(depth + 1) * 24 - 1}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{node.children.map((child) =>
|
||||
renderTimelineNode(child, depth + 1)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* Experimental Feature Warning */}
|
||||
<div className="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 mt-0.5" />
|
||||
<div className="text-xs">
|
||||
<p className="font-medium text-yellow-600">Experimental Feature</p>
|
||||
<p className="text-yellow-600/80">
|
||||
Checkpointing may affect directory structure or cause data loss. Use with caution.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-medium">Timeline</h3>
|
||||
{timeline && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{timeline.totalCheckpoints} checkpoints
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Save className="h-3 w-3 mr-1" />
|
||||
Checkpoint
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline tree */}
|
||||
{timeline?.rootNode ? (
|
||||
<div className="relative overflow-x-auto">
|
||||
{renderTimelineNode(timeline.rootNode)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
{isLoading ? "Loading timeline..." : "No checkpoints yet"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create checkpoint dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Checkpoint</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save the current state of your session with an optional description.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (optional)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
placeholder="e.g., Before major refactoring"
|
||||
value={checkpointDescription}
|
||||
onChange={(e) => setCheckpointDescription(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter" && !isLoading) {
|
||||
handleCreateCheckpoint();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateCheckpoint}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Create Checkpoint
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Diff dialog */}
|
||||
<Dialog open={showDiffDialog} onOpenChange={setShowDiffDialog}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Checkpoint Comparison</DialogTitle>
|
||||
<DialogDescription>
|
||||
Changes between "{selectedCheckpoint?.description || selectedCheckpoint?.id.slice(0, 8)}"
|
||||
and "{compareCheckpoint?.description || compareCheckpoint?.id.slice(0, 8)}"
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{diff && (
|
||||
<div className="space-y-4 py-4 max-h-[60vh] overflow-y-auto">
|
||||
{/* Summary */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Modified Files</div>
|
||||
<div className="text-2xl font-bold">{diff.modifiedFiles.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Added Files</div>
|
||||
<div className="text-2xl font-bold text-green-600">{diff.addedFiles.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
<div className="text-xs text-muted-foreground">Deleted Files</div>
|
||||
<div className="text-2xl font-bold text-red-600">{diff.deletedFiles.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Token delta */}
|
||||
<div className="flex items-center justify-center">
|
||||
<Badge variant={diff.tokenDelta > 0 ? "default" : "secondary"}>
|
||||
{diff.tokenDelta > 0 ? "+" : ""}{diff.tokenDelta.toLocaleString()} tokens
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* File lists */}
|
||||
{diff.modifiedFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Modified Files</h4>
|
||||
<div className="space-y-1">
|
||||
{diff.modifiedFiles.map((file) => (
|
||||
<div key={file.path} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono">{file.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-green-600">+{file.additions}</span>
|
||||
<span className="text-red-600">-{file.deletions}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff.addedFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Added Files</h4>
|
||||
<div className="space-y-1">
|
||||
{diff.addedFiles.map((file) => (
|
||||
<div key={file} className="text-xs font-mono text-green-600">
|
||||
+ {file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{diff.deletedFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Deleted Files</h4>
|
||||
<div className="space-y-1">
|
||||
{diff.deletedFiles.map((file) => (
|
||||
<div key={file} className="text-xs font-mono text-red-600">
|
||||
- {file}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowDiffDialog(false);
|
||||
setDiff(null);
|
||||
setCompareCheckpoint(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
54
src/components/TokenCounter.tsx
Normal file
54
src/components/TokenCounter.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Hash } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TokenCounterProps {
|
||||
/**
|
||||
* Total number of tokens
|
||||
*/
|
||||
tokens: number;
|
||||
/**
|
||||
* Whether to show the counter
|
||||
*/
|
||||
show?: boolean;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TokenCounter component - Displays a floating token count
|
||||
*
|
||||
* @example
|
||||
* <TokenCounter tokens={1234} show={true} />
|
||||
*/
|
||||
export const TokenCounter: React.FC<TokenCounterProps> = ({
|
||||
tokens,
|
||||
show = true,
|
||||
className,
|
||||
}) => {
|
||||
if (!show || tokens === 0) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className={cn(
|
||||
"fixed bottom-20 right-4 z-30",
|
||||
"bg-background/90 backdrop-blur-sm",
|
||||
"border border-border rounded-full",
|
||||
"px-3 py-1.5 shadow-lg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<Hash className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="font-mono">{tokens.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">tokens</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
1677
src/components/ToolWidgets.tsx
Normal file
1677
src/components/ToolWidgets.tsx
Normal file
File diff suppressed because it is too large
Load Diff
213
src/components/Topbar.tsx
Normal file
213
src/components/Topbar.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Circle, FileText, Settings, ExternalLink, BarChart3, Network, Info } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover } from "@/components/ui/popover";
|
||||
import { api, type ClaudeVersionStatus } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TopbarProps {
|
||||
/**
|
||||
* Callback when CLAUDE.md is clicked
|
||||
*/
|
||||
onClaudeClick: () => void;
|
||||
/**
|
||||
* Callback when Settings is clicked
|
||||
*/
|
||||
onSettingsClick: () => void;
|
||||
/**
|
||||
* Callback when Usage Dashboard is clicked
|
||||
*/
|
||||
onUsageClick: () => void;
|
||||
/**
|
||||
* Callback when MCP is clicked
|
||||
*/
|
||||
onMCPClick: () => void;
|
||||
/**
|
||||
* Callback when Info is clicked
|
||||
*/
|
||||
onInfoClick: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topbar component with status indicator and navigation buttons
|
||||
*
|
||||
* @example
|
||||
* <Topbar
|
||||
* onClaudeClick={() => setView('editor')}
|
||||
* onSettingsClick={() => setView('settings')}
|
||||
* onUsageClick={() => setView('usage-dashboard')}
|
||||
* onMCPClick={() => setView('mcp')}
|
||||
* />
|
||||
*/
|
||||
export const Topbar: React.FC<TopbarProps> = ({
|
||||
onClaudeClick,
|
||||
onSettingsClick,
|
||||
onUsageClick,
|
||||
onMCPClick,
|
||||
onInfoClick,
|
||||
className,
|
||||
}) => {
|
||||
const [versionStatus, setVersionStatus] = useState<ClaudeVersionStatus | null>(null);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
// Check Claude version on mount
|
||||
useEffect(() => {
|
||||
checkVersion();
|
||||
}, []);
|
||||
|
||||
const checkVersion = async () => {
|
||||
try {
|
||||
setChecking(true);
|
||||
const status = await api.checkClaudeVersion();
|
||||
setVersionStatus(status);
|
||||
|
||||
// If Claude is not installed and the error indicates it wasn't found
|
||||
if (!status.is_installed && status.output.includes("No such file or directory")) {
|
||||
// Emit an event that can be caught by the parent
|
||||
window.dispatchEvent(new CustomEvent('claude-not-found'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check Claude version:", err);
|
||||
setVersionStatus({
|
||||
is_installed: false,
|
||||
output: "Failed to check version",
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIndicator = () => {
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
<Circle className="h-3 w-3 animate-pulse text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Checking...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!versionStatus) return null;
|
||||
|
||||
const statusContent = (
|
||||
<div className="flex items-center space-x-2 text-xs">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-3 w-3",
|
||||
versionStatus.is_installed
|
||||
? "fill-green-500 text-green-500"
|
||||
: "fill-red-500 text-red-500"
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{versionStatus.is_installed && versionStatus.version
|
||||
? `Claude Code ${versionStatus.version}`
|
||||
: "Claude Code"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!versionStatus.is_installed) {
|
||||
return (
|
||||
<Popover
|
||||
trigger={statusContent}
|
||||
content={
|
||||
<div className="space-y-3 max-w-xs">
|
||||
<p className="text-sm font-medium">Claude Code not found</p>
|
||||
<div className="rounded-md bg-muted p-3">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">
|
||||
{versionStatus.output}
|
||||
</pre>
|
||||
</div>
|
||||
<a
|
||||
href="https://www.anthropic.com/claude-code"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<span>Install Claude Code</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
align="start"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return statusContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={cn(
|
||||
"flex items-center justify-between px-4 py-3 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Status Indicator */}
|
||||
<StatusIndicator />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onUsageClick}
|
||||
className="text-xs"
|
||||
>
|
||||
<BarChart3 className="mr-2 h-3 w-3" />
|
||||
Usage Dashboard
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClaudeClick}
|
||||
className="text-xs"
|
||||
>
|
||||
<FileText className="mr-2 h-3 w-3" />
|
||||
CLAUDE.md
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onMCPClick}
|
||||
className="text-xs"
|
||||
>
|
||||
<Network className="mr-2 h-3 w-3" />
|
||||
MCP
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onSettingsClick}
|
||||
className="text-xs"
|
||||
>
|
||||
<Settings className="mr-2 h-3 w-3" />
|
||||
Settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onInfoClick}
|
||||
className="h-8 w-8"
|
||||
title="About"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
537
src/components/UsageDashboard.tsx
Normal file
537
src/components/UsageDashboard.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api, type UsageStats, type ProjectUsage } from "@/lib/api";
|
||||
import {
|
||||
ArrowLeft,
|
||||
TrendingUp,
|
||||
Calendar,
|
||||
Filter,
|
||||
Loader2,
|
||||
DollarSign,
|
||||
Activity,
|
||||
FileText,
|
||||
Briefcase
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UsageDashboardProps {
|
||||
/**
|
||||
* Callback when back button is clicked
|
||||
*/
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* UsageDashboard component - Displays Claude API usage statistics and costs
|
||||
*
|
||||
* @example
|
||||
* <UsageDashboard onBack={() => setView('welcome')} />
|
||||
*/
|
||||
export const UsageDashboard: React.FC<UsageDashboardProps> = ({ onBack }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<UsageStats | null>(null);
|
||||
const [sessionStats, setSessionStats] = useState<ProjectUsage[] | null>(null);
|
||||
const [selectedDateRange, setSelectedDateRange] = useState<"all" | "7d" | "30d">("all");
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
useEffect(() => {
|
||||
loadUsageStats();
|
||||
}, [selectedDateRange]);
|
||||
|
||||
const loadUsageStats = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
let statsData: UsageStats;
|
||||
let sessionData: ProjectUsage[];
|
||||
|
||||
if (selectedDateRange === "all") {
|
||||
statsData = await api.getUsageStats();
|
||||
sessionData = await api.getSessionStats();
|
||||
} else {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
const days = selectedDateRange === "7d" ? 7 : 30;
|
||||
startDate.setDate(startDate.getDate() - days);
|
||||
|
||||
const formatDateForApi = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}${month}${day}`;
|
||||
}
|
||||
|
||||
statsData = await api.getUsageByDateRange(
|
||||
startDate.toISOString(),
|
||||
endDate.toISOString()
|
||||
);
|
||||
sessionData = await api.getSessionStats(
|
||||
formatDateForApi(startDate),
|
||||
formatDateForApi(endDate),
|
||||
'desc'
|
||||
);
|
||||
}
|
||||
|
||||
setStats(statsData);
|
||||
setSessionStats(sessionData);
|
||||
} catch (err) {
|
||||
console.error("Failed to load usage stats:", err);
|
||||
setError("Failed to load usage statistics. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat('en-US').format(num);
|
||||
};
|
||||
|
||||
const formatTokens = (num: number): string => {
|
||||
if (num >= 1_000_000) {
|
||||
return `${(num / 1_000_000).toFixed(2)}M`;
|
||||
} else if (num >= 1_000) {
|
||||
return `${(num / 1_000).toFixed(1)}K`;
|
||||
}
|
||||
return formatNumber(num);
|
||||
};
|
||||
|
||||
const getModelDisplayName = (model: string): string => {
|
||||
const modelMap: Record<string, string> = {
|
||||
"claude-4-opus": "Opus 4",
|
||||
"claude-4-sonnet": "Sonnet 4",
|
||||
"claude-3.5-sonnet": "Sonnet 3.5",
|
||||
"claude-3-opus": "Opus 3",
|
||||
};
|
||||
return modelMap[model] || model;
|
||||
};
|
||||
|
||||
const getModelColor = (model: string): string => {
|
||||
if (model.includes("opus")) return "text-purple-500";
|
||||
if (model.includes("sonnet")) return "text-blue-500";
|
||||
return "text-gray-500";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Usage Dashboard</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Track your Claude Code usage and costs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex space-x-1">
|
||||
{(["all", "30d", "7d"] as const).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
variant={selectedDateRange === range ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setSelectedDateRange(range)}
|
||||
className="text-xs"
|
||||
>
|
||||
{range === "all" ? "All Time" : range === "7d" ? "Last 7 Days" : "Last 30 Days"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">Loading usage statistics...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-md">
|
||||
<p className="text-sm text-destructive mb-4">{error}</p>
|
||||
<Button onClick={loadUsageStats} size="sm">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : stats ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-6xl mx-auto space-y-6"
|
||||
>
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Total Cost Card */}
|
||||
<Card className="p-4 shimmer-hover">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Cost</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatCurrency(stats.total_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-muted-foreground/20 rotating-symbol" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Total Sessions Card */}
|
||||
<Card className="p-4 shimmer-hover">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Sessions</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatNumber(stats.total_sessions)}
|
||||
</p>
|
||||
</div>
|
||||
<FileText className="h-8 w-8 text-muted-foreground/20 rotating-symbol" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Total Tokens Card */}
|
||||
<Card className="p-4 shimmer-hover">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Total Tokens</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatTokens(stats.total_tokens)}
|
||||
</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-muted-foreground/20 rotating-symbol" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Average Cost per Session Card */}
|
||||
<Card className="p-4 shimmer-hover">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Avg Cost/Session</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{formatCurrency(
|
||||
stats.total_sessions > 0
|
||||
? stats.total_cost / stats.total_sessions
|
||||
: 0
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-muted-foreground/20 rotating-symbol" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs for different views */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="models">By Model</TabsTrigger>
|
||||
<TabsTrigger value="projects">By Project</TabsTrigger>
|
||||
<TabsTrigger value="sessions">By Session</TabsTrigger>
|
||||
<TabsTrigger value="timeline">Timeline</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Overview Tab */}
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Token Breakdown</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Input Tokens</p>
|
||||
<p className="text-lg font-semibold">{formatTokens(stats.total_input_tokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Output Tokens</p>
|
||||
<p className="text-lg font-semibold">{formatTokens(stats.total_output_tokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Cache Write</p>
|
||||
<p className="text-lg font-semibold">{formatTokens(stats.total_cache_creation_tokens)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Cache Read</p>
|
||||
<p className="text-lg font-semibold">{formatTokens(stats.total_cache_read_tokens)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Most Used Models</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.by_model.slice(0, 3).map((model) => (
|
||||
<div key={model.model} className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge variant="outline" className={cn("text-xs", getModelColor(model.model))}>
|
||||
{getModelDisplayName(model.model)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{model.session_count} sessions
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{formatCurrency(model.total_cost)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Top Projects</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.by_project.slice(0, 3).map((project) => (
|
||||
<div key={project.project_path} className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium truncate max-w-[200px]" title={project.project_path}>
|
||||
{project.project_path}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.session_count} sessions
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{formatCurrency(project.total_cost)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Models Tab */}
|
||||
<TabsContent value="models">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Usage by Model</h3>
|
||||
<div className="space-y-4">
|
||||
{stats.by_model.map((model) => (
|
||||
<div key={model.model} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("text-xs", getModelColor(model.model))}
|
||||
>
|
||||
{getModelDisplayName(model.model)}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{model.session_count} sessions
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold">
|
||||
{formatCurrency(model.total_cost)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Input: </span>
|
||||
<span className="font-medium">{formatTokens(model.input_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Output: </span>
|
||||
<span className="font-medium">{formatTokens(model.output_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache W: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_creation_tokens)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cache R: </span>
|
||||
<span className="font-medium">{formatTokens(model.cache_read_tokens)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Projects Tab */}
|
||||
<TabsContent value="projects">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Usage by Project</h3>
|
||||
<div className="space-y-3">
|
||||
{stats.by_project.map((project) => (
|
||||
<div key={project.project_path} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex flex-col truncate">
|
||||
<span className="text-sm font-medium truncate" title={project.project_path}>
|
||||
{project.project_path}
|
||||
</span>
|
||||
<div className="flex items-center space-x-3 mt-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.session_count} sessions
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTokens(project.total_tokens)} tokens
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatCurrency(project.total_cost)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(project.total_cost / project.session_count)}/session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Sessions Tab */}
|
||||
<TabsContent value="sessions">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-4">Usage by Session</h3>
|
||||
<div className="space-y-3">
|
||||
{sessionStats?.map((session) => (
|
||||
<div key={`${session.project_path}-${session.project_name}`} className="flex items-center justify-between py-2 border-b border-border last:border-0">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[200px]" title={session.project_path}>
|
||||
{session.project_path.split('/').slice(-2).join('/')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium mt-1">
|
||||
{session.project_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatCurrency(session.total_cost)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(session.last_used).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline Tab */}
|
||||
<TabsContent value="timeline">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-sm font-semibold mb-6 flex items-center space-x-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>Daily Usage</span>
|
||||
</h3>
|
||||
{stats.by_date.length > 0 ? (() => {
|
||||
const maxCost = Math.max(...stats.by_date.map(d => d.total_cost), 0);
|
||||
const halfMaxCost = maxCost / 2;
|
||||
|
||||
return (
|
||||
<div className="relative pl-8 pr-4">
|
||||
{/* Y-axis labels */}
|
||||
<div className="absolute left-0 top-0 bottom-8 flex flex-col justify-between text-xs text-muted-foreground">
|
||||
<span>{formatCurrency(maxCost)}</span>
|
||||
<span>{formatCurrency(halfMaxCost)}</span>
|
||||
<span>{formatCurrency(0)}</span>
|
||||
</div>
|
||||
|
||||
{/* Chart container */}
|
||||
<div className="flex items-end space-x-2 h-64 border-l border-b border-border pl-4">
|
||||
{stats.by_date.slice().reverse().map((day) => {
|
||||
const heightPercent = maxCost > 0 ? (day.total_cost / maxCost) * 100 : 0;
|
||||
const date = new Date(day.date.replace(/-/g, '/'));
|
||||
const formattedDate = date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={day.date} className="flex-1 h-full flex flex-col items-center justify-end group relative">
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none z-10">
|
||||
<div className="bg-background border border-border rounded-lg shadow-lg p-3 whitespace-nowrap">
|
||||
<p className="text-sm font-semibold">{formattedDate}</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Cost: {formatCurrency(day.total_cost)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatTokens(day.total_tokens)} tokens
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{day.models_used.length} model{day.models_used.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 -mt-1">
|
||||
<div className="border-4 border-transparent border-t-border"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bar */}
|
||||
<div
|
||||
className="w-full bg-[#d97757] hover:opacity-80 transition-opacity rounded-t cursor-pointer"
|
||||
style={{ height: `${heightPercent}%` }}
|
||||
/>
|
||||
|
||||
{/* X-axis label – absolutely positioned below the bar so it doesn't affect bar height */}
|
||||
<div
|
||||
className="absolute left-1/2 top-full mt-1 -translate-x-1/2 text-xs text-muted-foreground -rotate-45 origin-top-left whitespace-nowrap pointer-events-none"
|
||||
>
|
||||
{date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* X-axis label */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
Daily Usage Over Time
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})() : (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No usage data available for the selected period
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
4
src/components/index.ts
Normal file
4
src/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./AgentExecutionDemo";
|
||||
export * from "./StreamMessage";
|
||||
export * from "./ToolWidgets";
|
||||
export * from "./NFOCredits";
|
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
65
src/components/ui/button.tsx
Normal file
65
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button component with multiple variants and sizes
|
||||
*
|
||||
* @example
|
||||
* <Button variant="outline" size="lg" onClick={() => console.log('clicked')}>
|
||||
* Click me
|
||||
* </Button>
|
||||
*/
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
112
src/components/ui/card.tsx
Normal file
112
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Card component - A container with consistent styling and sections
|
||||
*
|
||||
* @example
|
||||
* <Card>
|
||||
* <CardHeader>
|
||||
* <CardTitle>Card Title</CardTitle>
|
||||
* <CardDescription>Card description</CardDescription>
|
||||
* </CardHeader>
|
||||
* <CardContent>
|
||||
* Content goes here
|
||||
* </CardContent>
|
||||
* <CardFooter>
|
||||
* Footer content
|
||||
* </CardFooter>
|
||||
* </Card>
|
||||
*/
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border shadow-xs",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
borderColor: "var(--color-border)",
|
||||
backgroundColor: "var(--color-card)",
|
||||
color: "var(--color-card-foreground)"
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
/**
|
||||
* CardHeader component - Top section of a card
|
||||
*/
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
/**
|
||||
* CardTitle component - Main title within CardHeader
|
||||
*/
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
/**
|
||||
* CardDescription component - Descriptive text within CardHeader
|
||||
*/
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
/**
|
||||
* CardContent component - Main content area of a card
|
||||
*/
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
/**
|
||||
* CardFooter component - Bottom section of a card
|
||||
*/
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
119
src/components/ui/dialog.tsx
Normal file
119
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...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">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
39
src/components/ui/input.tsx
Normal file
39
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
/**
|
||||
* Input component for text/number inputs
|
||||
*
|
||||
* @example
|
||||
* <Input type="text" placeholder="Enter value..." />
|
||||
*/
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors",
|
||||
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
||||
"focus-visible:outline-none focus-visible:ring-1",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
borderColor: "var(--color-input)",
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--color-foreground)"
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
28
src/components/ui/label.tsx
Normal file
28
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
/**
|
||||
* Label component for form fields
|
||||
*
|
||||
* @example
|
||||
* <Label htmlFor="input-id">Field Label</Label>
|
||||
*/
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
Label.displayName = "Label";
|
||||
|
||||
export { Label };
|
72
src/components/ui/pagination.tsx
Normal file
72
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PaginationProps {
|
||||
/**
|
||||
* Current page number (1-indexed)
|
||||
*/
|
||||
currentPage: number;
|
||||
/**
|
||||
* Total number of pages
|
||||
*/
|
||||
totalPages: number;
|
||||
/**
|
||||
* Callback when page changes
|
||||
*/
|
||||
onPageChange: (page: number) => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination component for navigating through paginated content
|
||||
*
|
||||
* @example
|
||||
* <Pagination
|
||||
* currentPage={1}
|
||||
* totalPages={5}
|
||||
* onPageChange={(page) => setCurrentPage(page)}
|
||||
* />
|
||||
*/
|
||||
export const Pagination: React.FC<PaginationProps> = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className,
|
||||
}) => {
|
||||
if (totalPages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center space-x-2", className)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
134
src/components/ui/popover.tsx
Normal file
134
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PopoverProps {
|
||||
/**
|
||||
* The trigger element
|
||||
*/
|
||||
trigger: React.ReactNode;
|
||||
/**
|
||||
* The content to display in the popover
|
||||
*/
|
||||
content: React.ReactNode;
|
||||
/**
|
||||
* Whether the popover is open
|
||||
*/
|
||||
open?: boolean;
|
||||
/**
|
||||
* Callback when the open state changes
|
||||
*/
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/**
|
||||
* Optional className for the content
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Alignment of the popover relative to the trigger
|
||||
*/
|
||||
align?: "start" | "center" | "end";
|
||||
/**
|
||||
* Side of the trigger to display the popover
|
||||
*/
|
||||
side?: "top" | "bottom";
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover component for displaying floating content
|
||||
*
|
||||
* @example
|
||||
* <Popover
|
||||
* trigger={<Button>Click me</Button>}
|
||||
* content={<div>Popover content</div>}
|
||||
* side="top"
|
||||
* />
|
||||
*/
|
||||
export const Popover: React.FC<PopoverProps> = ({
|
||||
trigger,
|
||||
content,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
align = "center",
|
||||
side = "bottom",
|
||||
}) => {
|
||||
const [internalOpen, setInternalOpen] = React.useState(false);
|
||||
const open = controlledOpen !== undefined ? controlledOpen : internalOpen;
|
||||
const setOpen = onOpenChange || setInternalOpen;
|
||||
|
||||
const triggerRef = React.useRef<HTMLDivElement>(null);
|
||||
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on click outside
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
triggerRef.current &&
|
||||
contentRef.current &&
|
||||
!triggerRef.current.contains(event.target as Node) &&
|
||||
!contentRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open, setOpen]);
|
||||
|
||||
// Close on escape
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => document.removeEventListener("keydown", handleEscape);
|
||||
}, [open, setOpen]);
|
||||
|
||||
const alignClass = {
|
||||
start: "left-0",
|
||||
center: "left-1/2 -translate-x-1/2",
|
||||
end: "right-0",
|
||||
}[align];
|
||||
|
||||
const sideClass = side === "top" ? "bottom-full mb-2" : "top-full mt-2";
|
||||
const animationY = side === "top" ? { initial: 10, exit: 10 } : { initial: -10, exit: -10 };
|
||||
|
||||
return (
|
||||
<div className="relative inline-block">
|
||||
<div
|
||||
ref={triggerRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={contentRef}
|
||||
initial={{ opacity: 0, scale: 0.95, y: animationY.initial }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: animationY.exit }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={cn(
|
||||
"absolute z-50 min-w-[200px] rounded-md border border-border bg-popover p-4 text-popover-foreground shadow-md",
|
||||
sideClass,
|
||||
alignClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
226
src/components/ui/select.tsx
Normal file
226
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
// Legacy interface for backward compatibility
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps {
|
||||
/**
|
||||
* The current value
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Callback when value changes
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
/**
|
||||
* Available options
|
||||
*/
|
||||
options: SelectOption[];
|
||||
/**
|
||||
* Placeholder text
|
||||
*/
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Whether the select is disabled
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple select dropdown component
|
||||
*
|
||||
* @example
|
||||
* <Select
|
||||
* value={selected}
|
||||
* onValueChange={setSelected}
|
||||
* options={[
|
||||
* { value: "option1", label: "Option 1" },
|
||||
* { value: "option2", label: "Option 2" }
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
const SimpleSelect: React.FC<SelectProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "Select an option",
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
SimpleSelect as SelectComponent,
|
||||
};
|
65
src/components/ui/switch.tsx
Normal file
65
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SwitchProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
/**
|
||||
* Whether the switch is checked
|
||||
*/
|
||||
checked?: boolean;
|
||||
/**
|
||||
* Callback when the switch state changes
|
||||
*/
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch component for toggling boolean values
|
||||
*
|
||||
* @example
|
||||
* <Switch checked={isEnabled} onCheckedChange={setIsEnabled} />
|
||||
*/
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
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
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: checked ? "var(--color-primary)" : "var(--color-muted)"
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0"
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: "var(--color-background)"
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
{...props}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch };
|
158
src/components/ui/tabs.tsx
Normal file
158
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
}>({
|
||||
value: "",
|
||||
onValueChange: () => {},
|
||||
});
|
||||
|
||||
export interface TabsProps {
|
||||
/**
|
||||
* The controlled value of the tab to activate
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Event handler called when the value changes
|
||||
*/
|
||||
onValueChange: (value: string) => void;
|
||||
/**
|
||||
* The tabs and their content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root tabs component
|
||||
*
|
||||
* @example
|
||||
* <Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
* <TabsList>
|
||||
* <TabsTrigger value="general">General</TabsTrigger>
|
||||
* </TabsList>
|
||||
* <TabsContent value="general">Content</TabsContent>
|
||||
* </Tabs>
|
||||
*/
|
||||
const Tabs: React.FC<TabsProps> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div className={cn("w-full", className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TabsListProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for tab triggers
|
||||
*/
|
||||
const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-start rounded-lg p-1",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: "var(--color-muted)",
|
||||
color: "var(--color-muted-foreground)"
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
export interface TabsTriggerProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual tab trigger button
|
||||
*/
|
||||
const TabsTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
TabsTriggerProps
|
||||
>(({ className, value, disabled, ...props }, ref) => {
|
||||
const { value: selectedValue, onValueChange } = React.useContext(TabsContext);
|
||||
const isSelected = selectedValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
disabled={disabled}
|
||||
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
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSelected ? "var(--color-background)" : "transparent",
|
||||
color: isSelected ? "var(--color-foreground)" : "inherit",
|
||||
boxShadow: isSelected ? "0 1px 2px rgba(0,0,0,0.1)" : "none"
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
export interface TabsContentProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab content panel
|
||||
*/
|
||||
const TabsContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TabsContentProps
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: selectedValue } = React.useContext(TabsContext);
|
||||
const isSelected = selectedValue === value;
|
||||
|
||||
if (!isSelected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
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",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
23
src/components/ui/textarea.tsx
Normal file
23
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
111
src/components/ui/toast.tsx
Normal file
111
src/components/ui/toast.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import * as React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
interface ToastProps {
|
||||
/**
|
||||
* The message to display
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* The type of toast
|
||||
*/
|
||||
type?: ToastType;
|
||||
/**
|
||||
* Duration in milliseconds before auto-dismiss
|
||||
*/
|
||||
duration?: number;
|
||||
/**
|
||||
* Callback when the toast is dismissed
|
||||
*/
|
||||
onDismiss?: () => void;
|
||||
/**
|
||||
* Optional className for styling
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toast component for showing temporary notifications
|
||||
*
|
||||
* @example
|
||||
* <Toast
|
||||
* message="File saved successfully"
|
||||
* type="success"
|
||||
* duration={3000}
|
||||
* onDismiss={() => setShowToast(false)}
|
||||
* />
|
||||
*/
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type = "info",
|
||||
duration = 3000,
|
||||
onDismiss,
|
||||
className,
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
if (duration && duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss?.();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration, onDismiss]);
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="h-4 w-4" />,
|
||||
error: <AlertCircle className="h-4 w-4" />,
|
||||
info: <Info className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: "text-green-500",
|
||||
error: "text-red-500",
|
||||
info: "text-primary",
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
"flex items-center space-x-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={colors[type]}>{icons[type]}</span>
|
||||
<span className="flex-1 text-sm">{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// Toast container for positioning
|
||||
interface ToastContainerProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ToastContainer: React.FC<ToastContainerProps> = ({ children }) => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 flex justify-center p-4 pointer-events-none">
|
||||
<div className="pointer-events-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{children}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
29
src/components/ui/tooltip.tsx
Normal file
29
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
||||
|
Reference in New Issue
Block a user