init: push source

This commit is contained in:
Mufeed VH
2025-06-19 19:24:01 +05:30
commit 8e76d016d4
136 changed files with 38177 additions and 0 deletions

406
src/App.tsx Normal file
View File

@@ -0,0 +1,406 @@
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plus, Loader2, Bot, FolderCode } from "lucide-react";
import { api, type Project, type Session, type ClaudeMdFile } from "@/lib/api";
import { OutputCacheProvider } from "@/lib/outputCache";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ProjectList } from "@/components/ProjectList";
import { SessionList } from "@/components/SessionList";
import { Topbar } from "@/components/Topbar";
import { MarkdownEditor } from "@/components/MarkdownEditor";
import { ClaudeFileEditor } from "@/components/ClaudeFileEditor";
import { Settings } from "@/components/Settings";
import { CCAgents } from "@/components/CCAgents";
import { ClaudeCodeSession } from "@/components/ClaudeCodeSession";
import { UsageDashboard } from "@/components/UsageDashboard";
import { MCPManager } from "@/components/MCPManager";
import { NFOCredits } from "@/components/NFOCredits";
import { ClaudeBinaryDialog } from "@/components/ClaudeBinaryDialog";
import { Toast, ToastContainer } from "@/components/ui/toast";
type View = "welcome" | "projects" | "agents" | "editor" | "settings" | "claude-file-editor" | "claude-code-session" | "usage-dashboard" | "mcp";
/**
* Main App component - Manages the Claude directory browser UI
*/
function App() {
const [view, setView] = useState<View>("welcome");
const [projects, setProjects] = useState<Project[]>([]);
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [editingClaudeFile, setEditingClaudeFile] = useState<ClaudeMdFile | null>(null);
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showNFO, setShowNFO] = useState(false);
const [showClaudeBinaryDialog, setShowClaudeBinaryDialog] = useState(false);
const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null);
// Load projects on mount when in projects view
useEffect(() => {
if (view === "projects") {
loadProjects();
} else if (view === "welcome") {
// Reset loading state for welcome view
setLoading(false);
}
}, [view]);
// Listen for Claude session selection events
useEffect(() => {
const handleSessionSelected = (event: CustomEvent) => {
const { session } = event.detail;
setSelectedSession(session);
setView("claude-code-session");
};
const handleClaudeNotFound = () => {
setShowClaudeBinaryDialog(true);
};
window.addEventListener('claude-session-selected', handleSessionSelected as EventListener);
window.addEventListener('claude-not-found', handleClaudeNotFound as EventListener);
return () => {
window.removeEventListener('claude-session-selected', handleSessionSelected as EventListener);
window.removeEventListener('claude-not-found', handleClaudeNotFound as EventListener);
};
}, []);
/**
* Loads all projects from the ~/.claude/projects directory
*/
const loadProjects = async () => {
try {
setLoading(true);
setError(null);
const projectList = await api.listProjects();
setProjects(projectList);
} catch (err) {
console.error("Failed to load projects:", err);
setError("Failed to load projects. Please ensure ~/.claude directory exists.");
} finally {
setLoading(false);
}
};
/**
* Handles project selection and loads its sessions
*/
const handleProjectClick = async (project: Project) => {
try {
setLoading(true);
setError(null);
const sessionList = await api.getProjectSessions(project.id);
setSessions(sessionList);
setSelectedProject(project);
} catch (err) {
console.error("Failed to load sessions:", err);
setError("Failed to load sessions for this project.");
} finally {
setLoading(false);
}
};
/**
* Opens a new Claude Code session in the interactive UI
*/
const handleNewSession = async () => {
setView("claude-code-session");
setSelectedSession(null);
};
/**
* Returns to project list view
*/
const handleBack = () => {
setSelectedProject(null);
setSessions([]);
};
/**
* Handles editing a CLAUDE.md file from a project
*/
const handleEditClaudeFile = (file: ClaudeMdFile) => {
setEditingClaudeFile(file);
setView("claude-file-editor");
};
/**
* Returns from CLAUDE.md file editor to projects view
*/
const handleBackFromClaudeFileEditor = () => {
setEditingClaudeFile(null);
setView("projects");
};
const renderContent = () => {
switch (view) {
case "welcome":
return (
<div className="flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-4xl">
{/* Welcome Header */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-12 text-center"
>
<h1 className="text-4xl font-bold tracking-tight">
<span className="rotating-symbol"></span>
Welcome to Claudia
</h1>
</motion.div>
{/* Navigation Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-2xl mx-auto">
{/* CC Agents Card */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<Card
className="h-64 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg border border-border/50 shimmer-hover"
onClick={() => setView("agents")}
>
<div className="h-full flex flex-col items-center justify-center p-8">
<Bot className="h-16 w-16 mb-4 text-primary" />
<h2 className="text-xl font-semibold">CC Agents</h2>
</div>
</Card>
</motion.div>
{/* CC Projects Card */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Card
className="h-64 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-lg border border-border/50 shimmer-hover"
onClick={() => setView("projects")}
>
<div className="h-full flex flex-col items-center justify-center p-8">
<FolderCode className="h-16 w-16 mb-4 text-primary" />
<h2 className="text-xl font-semibold">CC Projects</h2>
</div>
</Card>
</motion.div>
</div>
</div>
</div>
);
case "agents":
return (
<div className="flex-1 overflow-hidden">
<CCAgents onBack={() => setView("welcome")} />
</div>
);
case "editor":
return (
<div className="flex-1 overflow-hidden">
<MarkdownEditor onBack={() => setView("welcome")} />
</div>
);
case "settings":
return (
<div className="flex-1 flex flex-col" style={{ minHeight: 0 }}>
<Settings onBack={() => setView("welcome")} />
</div>
);
case "projects":
return (
<div className="flex-1 flex items-center justify-center p-4 overflow-y-auto">
<div className="w-full max-w-2xl">
{/* Header with back button */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6"
>
<Button
variant="ghost"
size="sm"
onClick={() => setView("welcome")}
className="mb-4"
>
Back to Home
</Button>
<div className="text-center">
<h1 className="text-3xl font-bold tracking-tight">CC Projects</h1>
<p className="mt-1 text-sm text-muted-foreground">
Browse your Claude Code sessions
</p>
</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-xs text-destructive"
>
{error}
</motion.div>
)}
{/* Loading state */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Content */}
{!loading && (
<AnimatePresence mode="wait">
{selectedProject ? (
<motion.div
key="sessions"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<SessionList
sessions={sessions}
projectPath={selectedProject.path}
onBack={handleBack}
onEditClaudeFile={handleEditClaudeFile}
/>
</motion.div>
) : (
<motion.div
key="projects"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="space-y-4"
>
{/* New session button at the top */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Button
onClick={handleNewSession}
size="default"
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
New Claude Code session
</Button>
</motion.div>
{/* Project list */}
{projects.length > 0 ? (
<ProjectList
projects={projects}
onProjectClick={handleProjectClick}
/>
) : (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No projects found in ~/.claude/projects
</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
)}
</div>
</div>
);
case "claude-file-editor":
return editingClaudeFile ? (
<ClaudeFileEditor
file={editingClaudeFile}
onBack={handleBackFromClaudeFileEditor}
/>
) : null;
case "claude-code-session":
return (
<ClaudeCodeSession
session={selectedSession || undefined}
onBack={() => {
setSelectedSession(null);
setView("projects");
}}
/>
);
case "usage-dashboard":
return (
<UsageDashboard onBack={() => setView("welcome")} />
);
case "mcp":
return (
<MCPManager onBack={() => setView("welcome")} />
);
default:
return null;
}
};
return (
<OutputCacheProvider>
<div className="min-h-screen bg-background flex flex-col">
{/* Topbar */}
<Topbar
onClaudeClick={() => setView("editor")}
onSettingsClick={() => setView("settings")}
onUsageClick={() => setView("usage-dashboard")}
onMCPClick={() => setView("mcp")}
onInfoClick={() => setShowNFO(true)}
/>
{/* Main Content */}
{renderContent()}
{/* NFO Credits Modal */}
{showNFO && <NFOCredits onClose={() => setShowNFO(false)} />}
{/* Claude Binary Dialog */}
<ClaudeBinaryDialog
open={showClaudeBinaryDialog}
onOpenChange={setShowClaudeBinaryDialog}
onSuccess={() => {
setToast({ message: "Claude binary path saved successfully", type: "success" });
// Trigger a refresh of the Claude version check
window.location.reload();
}}
onError={(message) => setToast({ message, type: "error" })}
/>
{/* Toast Container */}
<ToastContainer>
{toast && (
<Toast
message={toast.message}
type={toast.type}
onDismiss={() => setToast(null)}
/>
)}
</ToastContainer>
</div>
</OutputCacheProvider>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

155
src/assets/shimmer.css Normal file
View File

@@ -0,0 +1,155 @@
/**
* Shimmer animation styles
* Provides a sword-like shimmer effect for elements
*/
@keyframes shimmer {
0% {
transform: translateX(-100%);
opacity: 0;
}
20% {
opacity: 1;
}
40% {
transform: translateX(100%);
opacity: 0;
}
50% {
transform: translateX(-100%);
opacity: 0;
}
70% {
opacity: 1;
}
90% {
transform: translateX(100%);
opacity: 0;
}
100% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes shimmer-text {
0% {
background-position: -200% center;
}
45% {
background-position: 200% center;
}
50% {
background-position: -200% center;
}
95% {
background-position: 200% center;
}
96%, 100% {
background-position: 200% center;
-webkit-text-fill-color: currentColor;
background: none;
}
}
@keyframes symbol-rotate {
0% {
content: '◐';
opacity: 1;
transform: translateY(0) scale(1);
}
25% {
content: '◓';
opacity: 1;
transform: translateY(0) scale(1);
}
50% {
content: '◑';
opacity: 1;
transform: translateY(0) scale(1);
}
75% {
content: '◒';
opacity: 1;
transform: translateY(0) scale(1);
}
100% {
content: '◐';
opacity: 1;
transform: translateY(0) scale(1);
}
}
.shimmer-once {
position: relative;
display: inline-block;
background: linear-gradient(
105deg,
currentColor 0%,
currentColor 40%,
#d97757 50%,
currentColor 60%,
currentColor 100%
);
background-size: 200% auto;
background-position: -200% center;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer-text 1s ease-out forwards;
}
.rotating-symbol {
display: inline-block;
color: #d97757;
font-size: inherit;
margin-right: 0.5rem;
font-weight: bold;
vertical-align: text-bottom;
position: relative;
line-height: 1;
}
.rotating-symbol::before {
content: '◐';
display: inline-block;
animation: symbol-rotate 2s linear infinite;
font-size: inherit;
line-height: inherit;
vertical-align: baseline;
}
.shimmer-hover {
position: relative;
overflow: hidden;
}
.shimmer-hover::before {
content: '';
position: absolute;
top: -50%;
left: 0;
width: 100%;
height: 200%;
background: linear-gradient(
105deg,
transparent 0%,
transparent 40%,
rgba(217, 119, 87, 0.4) 50%,
transparent 60%,
transparent 100%
);
transform: translateX(-100%) rotate(-10deg);
opacity: 0;
pointer-events: none;
z-index: 1;
}
.shimmer-hover > * {
position: relative;
z-index: 2;
}
.shimmer-hover:hover::before {
animation: shimmer 1s ease-out;
}

View 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";

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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
View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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;
}
}

View 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>
);
};

View 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>
);
};

View 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>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
}

View 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>
);
};

View 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
View 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>
);
};

View 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>
);
}
};

View 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>
);
};

View 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>
);
};

File diff suppressed because it is too large Load Diff

213
src/components/Topbar.tsx Normal file
View 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>
);
};

View 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
View File

@@ -0,0 +1,4 @@
export * from "./AgentExecutionDemo";
export * from "./StreamMessage";
export * from "./ToolWidgets";
export * from "./NFOCredits";

View 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 }

View 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
View 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 };

View 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,
}

View 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 };

View 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 };

View 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>
);
};

View 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>
);
};

View 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,
};

View 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
View 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 };

View 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
View 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>
);
};

View 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 }

1763
src/lib/api.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
/**
* Claude-themed syntax highlighting theme
* Features orange, purple, and violet colors to match Claude's aesthetic
*/
export const claudeSyntaxTheme: any = {
'code[class*="language-"]': {
color: '#e3e8f0',
background: 'transparent',
textShadow: 'none',
fontFamily: 'var(--font-mono)',
fontSize: '0.875em',
textAlign: 'left',
whiteSpace: 'pre',
wordSpacing: 'normal',
wordBreak: 'normal',
wordWrap: 'normal',
lineHeight: '1.5',
MozTabSize: '4',
OTabSize: '4',
tabSize: '4',
WebkitHyphens: 'none',
MozHyphens: 'none',
msHyphens: 'none',
hyphens: 'none',
},
'pre[class*="language-"]': {
color: '#e3e8f0',
background: 'transparent',
textShadow: 'none',
fontFamily: 'var(--font-mono)',
fontSize: '0.875em',
textAlign: 'left',
whiteSpace: 'pre',
wordSpacing: 'normal',
wordBreak: 'normal',
wordWrap: 'normal',
lineHeight: '1.5',
MozTabSize: '4',
OTabSize: '4',
tabSize: '4',
WebkitHyphens: 'none',
MozHyphens: 'none',
msHyphens: 'none',
hyphens: 'none',
padding: '1em',
margin: '0',
overflow: 'auto',
},
':not(pre) > code[class*="language-"]': {
background: 'rgba(139, 92, 246, 0.1)',
padding: '0.1em 0.3em',
borderRadius: '0.3em',
whiteSpace: 'normal',
},
'comment': {
color: '#6b7280',
fontStyle: 'italic',
},
'prolog': {
color: '#6b7280',
},
'doctype': {
color: '#6b7280',
},
'cdata': {
color: '#6b7280',
},
'punctuation': {
color: '#9ca3af',
},
'namespace': {
opacity: '0.7',
},
'property': {
color: '#f59e0b', // Amber/Orange
},
'tag': {
color: '#8b5cf6', // Violet
},
'boolean': {
color: '#f59e0b', // Amber/Orange
},
'number': {
color: '#f59e0b', // Amber/Orange
},
'constant': {
color: '#f59e0b', // Amber/Orange
},
'symbol': {
color: '#f59e0b', // Amber/Orange
},
'deleted': {
color: '#ef4444',
},
'selector': {
color: '#a78bfa', // Light Purple
},
'attr-name': {
color: '#a78bfa', // Light Purple
},
'string': {
color: '#10b981', // Emerald Green
},
'char': {
color: '#10b981', // Emerald Green
},
'builtin': {
color: '#8b5cf6', // Violet
},
'url': {
color: '#10b981', // Emerald Green
},
'inserted': {
color: '#10b981', // Emerald Green
},
'entity': {
color: '#a78bfa', // Light Purple
cursor: 'help',
},
'atrule': {
color: '#c084fc', // Light Violet
},
'attr-value': {
color: '#10b981', // Emerald Green
},
'keyword': {
color: '#c084fc', // Light Violet
},
'function': {
color: '#818cf8', // Indigo
},
'class-name': {
color: '#f59e0b', // Amber/Orange
},
'regex': {
color: '#06b6d4', // Cyan
},
'important': {
color: '#f59e0b', // Amber/Orange
fontWeight: 'bold',
},
'variable': {
color: '#a78bfa', // Light Purple
},
'bold': {
fontWeight: 'bold',
},
'italic': {
fontStyle: 'italic',
},
'operator': {
color: '#9ca3af',
},
'script': {
color: '#e3e8f0',
},
'parameter': {
color: '#fbbf24', // Yellow
},
'method': {
color: '#818cf8', // Indigo
},
'field': {
color: '#f59e0b', // Amber/Orange
},
'annotation': {
color: '#6b7280',
},
'type': {
color: '#a78bfa', // Light Purple
},
'module': {
color: '#8b5cf6', // Violet
},
};

106
src/lib/date-utils.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* Formats a Unix timestamp to a human-readable date string
* @param timestamp - Unix timestamp in seconds
* @returns Formatted date string
*
* @example
* formatUnixTimestamp(1735555200) // "Dec 30, 2024"
*/
export function formatUnixTimestamp(timestamp: number): string {
const date = new Date(timestamp * 1000);
const now = new Date();
// If it's today, show time
if (isToday(date)) {
return formatTime(date);
}
// If it's yesterday
if (isYesterday(date)) {
return `Yesterday, ${formatTime(date)}`;
}
// If it's within the last week, show day of week
if (isWithinWeek(date)) {
return `${getDayName(date)}, ${formatTime(date)}`;
}
// If it's this year, don't show year
if (date.getFullYear() === now.getFullYear()) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
// Otherwise show full date
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
/**
* Formats an ISO timestamp string to a human-readable date
* @param isoString - ISO timestamp string
* @returns Formatted date string
*
* @example
* formatISOTimestamp("2025-01-04T10:13:29.000Z") // "Jan 4, 2025"
*/
export function formatISOTimestamp(isoString: string): string {
const date = new Date(isoString);
return formatUnixTimestamp(Math.floor(date.getTime() / 1000));
}
/**
* Truncates text to a specified length with ellipsis
* @param text - Text to truncate
* @param maxLength - Maximum length
* @returns Truncated text
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
/**
* Gets the first line of text
* @param text - Text to process
* @returns First line of text
*/
export function getFirstLine(text: string): string {
const lines = text.split('\n');
return lines[0] || '';
}
// Helper functions
function formatTime(date: Date): string {
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
function isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
function isYesterday(date: Date): boolean {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return date.toDateString() === yesterday.toDateString();
}
function isWithinWeek(date: Date): boolean {
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return date > weekAgo;
}
function getDayName(date: Date): string {
return date.toLocaleDateString('en-US', { weekday: 'long' });
}

195
src/lib/outputCache.tsx Normal file
View File

@@ -0,0 +1,195 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import { api } from './api';
// 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;
}
interface CachedSessionOutput {
output: string;
messages: ClaudeStreamMessage[];
lastUpdated: number;
status: string;
}
interface OutputCacheContextType {
getCachedOutput: (sessionId: number) => CachedSessionOutput | null;
setCachedOutput: (sessionId: number, data: CachedSessionOutput) => void;
updateSessionStatus: (sessionId: number, status: string) => void;
clearCache: (sessionId?: number) => void;
isPolling: boolean;
startBackgroundPolling: () => void;
stopBackgroundPolling: () => void;
}
const OutputCacheContext = createContext<OutputCacheContextType | null>(null);
export function useOutputCache() {
const context = useContext(OutputCacheContext);
if (!context) {
throw new Error('useOutputCache must be used within an OutputCacheProvider');
}
return context;
}
interface OutputCacheProviderProps {
children: React.ReactNode;
}
export function OutputCacheProvider({ children }: OutputCacheProviderProps) {
const [cache, setCache] = useState<Map<number, CachedSessionOutput>>(new Map());
const [isPolling, setIsPolling] = useState(false);
const [pollingInterval, setPollingInterval] = useState<NodeJS.Timeout | null>(null);
const getCachedOutput = useCallback((sessionId: number): CachedSessionOutput | null => {
return cache.get(sessionId) || null;
}, [cache]);
const setCachedOutput = useCallback((sessionId: number, data: CachedSessionOutput) => {
setCache(prev => new Map(prev.set(sessionId, data)));
}, []);
const updateSessionStatus = useCallback((sessionId: number, status: string) => {
setCache(prev => {
const existing = prev.get(sessionId);
if (existing) {
const updated = new Map(prev);
updated.set(sessionId, { ...existing, status });
return updated;
}
return prev;
});
}, []);
const clearCache = useCallback((sessionId?: number) => {
if (sessionId) {
setCache(prev => {
const updated = new Map(prev);
updated.delete(sessionId);
return updated;
});
} else {
setCache(new Map());
}
}, []);
const parseOutput = useCallback((rawOutput: string): ClaudeStreamMessage[] => {
if (!rawOutput) return [];
const lines = rawOutput.split('\n').filter(line => line.trim());
const parsedMessages: ClaudeStreamMessage[] = [];
for (const line of lines) {
try {
const message = JSON.parse(line) as ClaudeStreamMessage;
parsedMessages.push(message);
} catch (err) {
console.error("Failed to parse message:", err, line);
// Add a fallback message for unparseable content
parsedMessages.push({
type: 'result',
subtype: 'error',
error: 'Failed to parse message',
raw_content: line
});
}
}
return parsedMessages;
}, []);
const updateSessionCache = useCallback(async (sessionId: number, status: string) => {
try {
const rawOutput = await api.getSessionOutput(sessionId);
const messages = parseOutput(rawOutput);
setCachedOutput(sessionId, {
output: rawOutput,
messages,
lastUpdated: Date.now(),
status
});
} catch (error) {
console.warn(`Failed to update cache for session ${sessionId}:`, error);
}
}, [parseOutput, setCachedOutput]);
const pollRunningSessions = useCallback(async () => {
try {
const runningSessions = await api.listRunningAgentSessions();
// Update cache for all running sessions
for (const session of runningSessions) {
if (session.id && session.status === 'running') {
await updateSessionCache(session.id, session.status);
}
}
// Clean up cache for sessions that are no longer running
const runningIds = new Set(runningSessions.map(s => s.id).filter(Boolean));
setCache(prev => {
const updated = new Map();
for (const [sessionId, data] of prev) {
if (runningIds.has(sessionId) || data.status !== 'running') {
updated.set(sessionId, data);
}
}
return updated;
});
} catch (error) {
console.warn('Failed to poll running sessions:', error);
}
}, [updateSessionCache]);
const startBackgroundPolling = useCallback(() => {
if (pollingInterval) return;
setIsPolling(true);
const interval = setInterval(pollRunningSessions, 3000); // Poll every 3 seconds
setPollingInterval(interval);
}, [pollingInterval, pollRunningSessions]);
const stopBackgroundPolling = useCallback(() => {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
setIsPolling(false);
}, [pollingInterval]);
// Auto-start polling when provider mounts
useEffect(() => {
startBackgroundPolling();
return () => stopBackgroundPolling();
}, [startBackgroundPolling, stopBackgroundPolling]);
const value: OutputCacheContextType = {
getCachedOutput,
setCachedOutput,
updateSessionStatus,
clearCache,
isPolling,
startBackgroundPolling,
stopBackgroundPolling,
};
return (
<OutputCacheContext.Provider value={value}>
{children}
</OutputCacheContext.Provider>
);
}

17
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,17 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Combines multiple class values into a single string using clsx and tailwind-merge.
* This utility function helps manage dynamic class names and prevents Tailwind CSS conflicts.
*
* @param inputs - Array of class values that can be strings, objects, arrays, etc.
* @returns A merged string of class names with Tailwind conflicts resolved
*
* @example
* cn("px-2 py-1", condition && "bg-blue-500", { "text-white": isActive })
* // Returns: "px-2 py-1 bg-blue-500 text-white" (when condition and isActive are true)
*/
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import "./styles.css";
import "./assets/shimmer.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);

562
src/styles.css Normal file
View File

@@ -0,0 +1,562 @@
@import "tailwindcss";
/* Dark theme configuration */
@theme {
/* Colors */
--color-background: oklch(0.12 0.01 240);
--color-foreground: oklch(0.98 0.01 240);
--color-card: oklch(0.14 0.01 240);
--color-card-foreground: oklch(0.98 0.01 240);
--color-popover: oklch(0.12 0.01 240);
--color-popover-foreground: oklch(0.98 0.01 240);
--color-primary: oklch(0.98 0.01 240);
--color-primary-foreground: oklch(0.17 0.01 240);
--color-secondary: oklch(0.16 0.01 240);
--color-secondary-foreground: oklch(0.98 0.01 240);
--color-muted: oklch(0.16 0.01 240);
--color-muted-foreground: oklch(0.68 0.01 240);
--color-accent: oklch(0.16 0.01 240);
--color-accent-foreground: oklch(0.98 0.01 240);
--color-destructive: oklch(0.6 0.2 25);
--color-destructive-foreground: oklch(0.98 0.01 240);
--color-border: oklch(0.16 0.01 240);
--color-input: oklch(0.16 0.01 240);
--color-ring: oklch(0.52 0.015 240);
/* Additional colors for status messages */
--color-green-500: oklch(0.72 0.20 142);
--color-green-600: oklch(0.64 0.22 142);
/* Border radius */
--radius-sm: 0.25rem;
--radius-base: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
/* Fonts */
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace;
/* Transitions */
--ease-smooth: cubic-bezier(0.4, 0, 0.2, 1);
--ease-bounce: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* Reset and base styles */
* {
border-color: var(--color-border);
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family: var(--font-sans);
}
/* Placeholder text styling */
input::placeholder,
textarea::placeholder {
color: var(--color-muted-foreground);
opacity: 0.6;
}
/* Cursor pointer for all interactive elements */
button,
a,
[role="button"],
[role="link"],
[role="menuitem"],
[role="tab"],
[tabindex]:not([tabindex="-1"]),
.cursor-pointer {
cursor: pointer;
}
/* Ensure disabled elements don't have pointer cursor */
button:disabled,
[disabled],
.disabled {
cursor: not-allowed !important;
}
/* Custom utilities */
@utility animate-in {
animation-name: enter;
animation-duration: 150ms;
animation-fill-mode: both;
}
@utility animate-out {
animation-name: exit;
animation-duration: 150ms;
animation-fill-mode: both;
}
@utility line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@keyframes enter {
from {
opacity: var(--tw-enter-opacity, 1);
transform: translate3d(var(--tw-enter-translate-x, 0), var(--tw-enter-translate-y, 0), 0) scale3d(var(--tw-enter-scale, 1), var(--tw-enter-scale, 1), var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0));
}
}
@keyframes exit {
to {
opacity: var(--tw-exit-opacity, 1);
transform: translate3d(var(--tw-exit-translate-x, 0), var(--tw-exit-translate-y, 0), 0) scale3d(var(--tw-exit-scale, 1), var(--tw-exit-scale, 1), var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0));
}
}
/* Markdown Editor Dark Mode Styles */
[data-color-mode="dark"] {
--color-border-default: rgb(48, 54, 61);
--color-canvas-default: rgb(13, 17, 23);
--color-canvas-subtle: rgb(22, 27, 34);
--color-fg-default: rgb(201, 209, 217);
--color-fg-muted: rgb(139, 148, 158);
--color-fg-subtle: rgb(110, 118, 129);
--color-accent-fg: rgb(88, 166, 255);
--color-danger-fg: rgb(248, 81, 73);
}
.w-md-editor {
background-color: transparent !important;
color: var(--color-foreground) !important;
}
.w-md-editor.w-md-editor-focus {
box-shadow: none !important;
border-color: var(--color-border) !important;
}
.w-md-editor-toolbar {
background-color: var(--color-card) !important;
border-bottom: 1px solid var(--color-border) !important;
}
.w-md-editor-toolbar-divider {
background-color: var(--color-border) !important;
}
.w-md-editor-toolbar button {
color: var(--color-foreground) !important;
}
.w-md-editor-toolbar button:hover {
background-color: var(--color-accent) !important;
color: var(--color-accent-foreground) !important;
}
.w-md-editor-content {
background-color: var(--color-background) !important;
}
.w-md-editor-text-pre,
.w-md-editor-text-input,
.w-md-editor-text {
color: var(--color-foreground) !important;
background-color: transparent !important;
}
.w-md-editor-preview {
background-color: var(--color-background) !important;
}
.wmde-markdown {
background-color: transparent !important;
color: var(--color-foreground) !important;
}
/* Prose styles for markdown rendering */
.prose {
color: var(--color-foreground);
max-width: 65ch;
font-size: 1rem;
line-height: 1.75;
}
.prose-sm {
font-size: 0.875rem;
line-height: 1.714;
}
.prose p {
margin-top: 1.25em;
margin-bottom: 1.25em;
}
.prose-sm p {
margin-top: 1.143em;
margin-bottom: 1.143em;
}
.prose [class~="lead"] {
font-size: 1.25em;
line-height: 1.6;
margin-top: 1.2em;
margin-bottom: 1.2em;
}
.prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
margin-top: 0;
margin-bottom: 0.8888889em;
font-weight: 600;
line-height: 1.1111111;
}
.prose h1 {
font-size: 2.25em;
}
.prose h2 {
font-size: 1.5em;
}
.prose h3 {
font-size: 1.25em;
}
.prose h4 {
font-size: 1em;
}
.prose a {
color: var(--color-primary);
text-decoration: underline;
font-weight: 500;
}
.prose strong {
font-weight: 600;
}
.prose ol, .prose ul {
margin-top: 1.25em;
margin-bottom: 1.25em;
padding-left: 1.625em;
}
.prose li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.prose > ul > li p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.prose > ol > li > *:first-child {
margin-top: 1.25em;
}
.prose code {
font-weight: 600;
font-size: 0.875em;
background-color: var(--color-muted);
padding: 0.125em 0.375em;
border-radius: 0.25rem;
}
.prose pre {
overflow-x: auto;
font-size: 0.875em;
line-height: 1.714;
margin-top: 1.714em;
margin-bottom: 1.714em;
border-radius: 0.375rem;
padding: 0.857em 1.143em;
background-color: var(--color-card);
}
.prose pre code {
background-color: transparent;
border-width: 0;
border-radius: 0;
padding: 0;
font-weight: 400;
color: inherit;
font-size: inherit;
font-family: inherit;
line-height: inherit;
}
.prose blockquote {
font-weight: 500;
font-style: italic;
margin-top: 1.6em;
margin-bottom: 1.6em;
padding-left: 1em;
border-left: 0.25rem solid var(--color-border);
}
.prose hr {
margin-top: 3em;
margin-bottom: 3em;
border-color: var(--color-border);
}
.prose table {
width: 100%;
table-layout: auto;
text-align: left;
margin-top: 2em;
margin-bottom: 2em;
font-size: 0.875em;
line-height: 1.714;
}
.prose thead {
border-bottom-width: 1px;
border-bottom-color: var(--color-border);
}
.prose thead th {
vertical-align: bottom;
padding-right: 0.571em;
padding-bottom: 0.571em;
padding-left: 0.571em;
font-weight: 600;
}
.prose tbody tr {
border-bottom-width: 1px;
border-bottom-color: var(--color-border);
}
.prose tbody tr:last-child {
border-bottom-width: 0;
}
.prose tbody td {
vertical-align: baseline;
padding: 0.571em;
}
/* Dark mode adjustments */
.prose.dark\:prose-invert {
color: var(--color-foreground);
}
.prose.dark\:prose-invert a {
color: var(--color-primary);
}
.prose.dark\:prose-invert strong {
color: inherit;
}
.prose.dark\:prose-invert code {
color: var(--color-foreground);
background-color: var(--color-muted);
}
.prose.dark\:prose-invert pre {
background-color: rgb(13, 17, 23);
border: 1px solid var(--color-border);
}
.prose.dark\:prose-invert thead {
border-bottom-color: var(--color-border);
}
.prose.dark\:prose-invert tbody tr {
border-bottom-color: var(--color-border);
}
/* Remove maximum width constraint */
.prose.max-w-none {
max-width: none;
}
/* Rotating symbol animation */
@keyframes rotate-symbol {
0% { content: "◐"; }
25% { content: "◓"; }
50% { content: "◑"; }
75% { content: "◒"; }
100% { content: "◐"; }
}
.rotating-symbol {
display: inline-block;
vertical-align: text-bottom;
line-height: 1;
}
.rotating-symbol::before {
content: "◐";
animation: rotate-symbol 2s linear infinite;
display: inline-block;
font-size: inherit;
line-height: inherit;
vertical-align: baseline;
}
/* Shimmer hover effect */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.shimmer-hover {
position: relative;
overflow: hidden;
}
.shimmer-hover::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.05),
transparent
);
transition: left 0.5s;
}
.shimmer-hover:hover::before {
left: 100%;
animation: shimmer 0.5s;
}
/* --- THEME-MATCHING SCROLLBARS --- */
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: var(--color-muted-foreground) var(--color-background);
}
/* For Webkit Browsers (Chrome, Safari, Edge) */
*::-webkit-scrollbar {
width: 12px;
height: 12px;
}
*::-webkit-scrollbar-track {
background: var(--color-background);
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-muted);
border-radius: 6px;
border: 3px solid var(--color-background);
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--color-muted-foreground);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
/* Code blocks and editors specific scrollbar */
pre::-webkit-scrollbar,
.w-md-editor-content::-webkit-scrollbar,
code::-webkit-scrollbar,
.overflow-auto::-webkit-scrollbar {
width: 8px;
height: 8px;
}
pre::-webkit-scrollbar-thumb,
.w-md-editor-content::-webkit-scrollbar-thumb,
code::-webkit-scrollbar-thumb,
.overflow-auto::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.2);
}
pre::-webkit-scrollbar-thumb:hover,
.w-md-editor-content::-webkit-scrollbar-thumb:hover,
code::-webkit-scrollbar-thumb:hover,
.overflow-auto::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.4);
}
/* Syntax highlighter specific */
.bg-zinc-950 ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.bg-zinc-950 ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.3);
}
.bg-zinc-950 ::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.3);
border-radius: 4px;
}
.bg-zinc-950 ::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.5);
}
/* Code preview specific scrollbar */
.code-preview-scroll::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.code-preview-scroll::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
.code-preview-scroll::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.4);
border-radius: 6px;
border: 2px solid transparent;
background-clip: content-box;
}
.code-preview-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.6);
}
.code-preview-scroll::-webkit-scrollbar-thumb:active {
background-color: rgba(107, 114, 128, 0.8);
}
.code-preview-scroll::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0.2);
border-radius: 6px;
}
/* Firefox scrollbar for code preview */
.code-preview-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(107, 114, 128, 0.4) rgba(0, 0, 0, 0.2);
}
/* NFO Credits Scanlines Animation */
@keyframes scanlines {
0% {
transform: translateY(-100%);
}
100% {
transform: translateY(100%);
}
}
.animate-scanlines {
animation: scanlines 8s linear infinite;
}

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />