feat: CodePilot - desktop GUI for Claude Code

Full-featured desktop application built with Next.js + Electron:
- Streaming chat with Claude Code via Agent SDK
- Session management with SQLite persistence
- Project file tree and file preview
- MCP server and custom skills management
- Permission controls with Code/Plan/Ask modes
- Dark/light theme, token tracking
- macOS DMG packaging with electron-builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useState, useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowLeft01Icon, Copy01Icon, Tick01Icon, Loading02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Light as SyntaxHighlighter } from "react-syntax-highlighter";
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
import type { FilePreview as FilePreviewType } from "@/types";
interface FilePreviewProps {
filePath: string;
onBack: () => void;
}
export function FilePreview({ filePath, onBack }: FilePreviewProps) {
const [preview, setPreview] = useState<FilePreviewType | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
async function loadPreview() {
setLoading(true);
setError(null);
try {
const res = await fetch(
`/api/files/preview?path=${encodeURIComponent(filePath)}`
);
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to load file");
}
const data = await res.json();
setPreview(data.preview);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load file");
} finally {
setLoading(false);
}
}
loadPreview();
}, [filePath]);
const handleCopyPath = async () => {
await navigator.clipboard.writeText(filePath);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Extract filename from path
const fileName = filePath.split("/").pop() || filePath;
// Build breadcrumb segments
const segments = filePath.split("/").filter(Boolean);
const displaySegments = segments.slice(-3); // show last 3 segments
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center gap-2 pb-2">
<Button variant="ghost" size="icon-sm" onClick={onBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} className="h-3.5 w-3.5" />
<span className="sr-only">Back to file tree</span>
</Button>
<div className="min-w-0 flex-1">
<p className="truncate text-xs text-muted-foreground">
{displaySegments.length < segments.length && ".../"}{displaySegments.join("/")}
</p>
</div>
<Button variant="ghost" size="icon-sm" onClick={handleCopyPath}>
{copied ? (
<HugeiconsIcon icon={Tick01Icon} className="h-3.5 w-3.5 text-green-500" />
) : (
<HugeiconsIcon icon={Copy01Icon} className="h-3.5 w-3.5" />
)}
<span className="sr-only">Copy path</span>
</Button>
</div>
{/* File info */}
{preview && (
<div className="flex items-center gap-2 pb-2">
<Badge variant="secondary" className="text-[10px]">
{preview.language}
</Badge>
<span className="text-[10px] text-muted-foreground">
{preview.line_count} lines
</span>
</div>
)}
{/* Content */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="py-4 text-center">
<p className="text-xs text-destructive">{error}</p>
<Button
variant="ghost"
size="sm"
onClick={onBack}
className="mt-2 text-xs"
>
Back to file tree
</Button>
</div>
) : preview ? (
<div className="rounded-md border border-border text-xs">
<SyntaxHighlighter
language={preview.language}
style={atomOneDark}
showLineNumbers
customStyle={{
margin: 0,
padding: "8px",
borderRadius: "6px",
fontSize: "11px",
lineHeight: "1.5",
}}
lineNumberStyle={{
minWidth: "2.5em",
paddingRight: "8px",
color: "#636d83",
userSelect: "none",
}}
>
{preview.content}
</SyntaxHighlighter>
</div>
) : null}
</ScrollArea>
</div>
);
}
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { RefreshIcon, Search01Icon, SourceCodeIcon, CodeIcon, File01Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import type { FileTreeNode } from "@/types";
import {
FileTree as AIFileTree,
FileTreeFolder,
FileTreeFile,
} from "@/components/ai-elements/file-tree";
import type { ReactNode } from "react";
interface FileTreeProps {
workingDirectory: string;
onFileSelect: (path: string) => void;
}
function getFileIcon(extension?: string): ReactNode {
switch (extension) {
case "ts":
case "tsx":
case "js":
case "jsx":
case "py":
case "rb":
case "rs":
case "go":
case "java":
case "c":
case "cpp":
case "h":
case "hpp":
case "cs":
case "swift":
case "kt":
case "dart":
case "lua":
case "php":
case "zig":
return <HugeiconsIcon icon={SourceCodeIcon} className="size-4 text-muted-foreground" />;
case "json":
case "yaml":
case "yml":
case "toml":
return <HugeiconsIcon icon={CodeIcon} className="size-4 text-muted-foreground" />;
case "md":
case "mdx":
case "txt":
case "csv":
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
default:
return <HugeiconsIcon icon={File01Icon} className="size-4 text-muted-foreground" />;
}
}
function containsMatch(node: FileTreeNode, query: string): boolean {
const q = query.toLowerCase();
if (node.name.toLowerCase().includes(q)) return true;
if (node.children) {
return node.children.some((child) => containsMatch(child, q));
}
return false;
}
function filterTree(nodes: FileTreeNode[], query: string): FileTreeNode[] {
if (!query) return nodes;
return nodes
.filter((node) => containsMatch(node, query))
.map((node) => ({
...node,
children: node.children ? filterTree(node.children, query) : undefined,
}));
}
function RenderTreeNodes({ nodes, searchQuery }: { nodes: FileTreeNode[]; searchQuery: string }) {
const filtered = searchQuery ? filterTree(nodes, searchQuery) : nodes;
return (
<>
{filtered.map((node) => {
if (node.type === "directory") {
return (
<FileTreeFolder key={node.path} path={node.path} name={node.name}>
{node.children && (
<RenderTreeNodes nodes={node.children} searchQuery={searchQuery} />
)}
</FileTreeFolder>
);
}
return (
<FileTreeFile
key={node.path}
path={node.path}
name={node.name}
icon={getFileIcon(node.extension)}
/>
);
})}
</>
);
}
export function FileTree({ workingDirectory, onFileSelect }: FileTreeProps) {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const fetchTree = useCallback(async () => {
if (!workingDirectory) {
setTree([]);
return;
}
setLoading(true);
try {
const res = await fetch(
`/api/files?dir=${encodeURIComponent(workingDirectory)}&depth=4`
);
if (res.ok) {
const data = await res.json();
setTree(data.tree || []);
} else {
setTree([]);
}
} catch {
setTree([]);
} finally {
setLoading(false);
}
}, [workingDirectory]);
useEffect(() => {
fetchTree();
}, [fetchTree]);
// Build default expanded set from first-level directories
const defaultExpanded = new Set(
tree.filter((n) => n.type === "directory").map((n) => n.path)
);
return (
<div className="flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 p-2 border-b border-border/30">
<p className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground" title={workingDirectory}>
{workingDirectory || 'No directory selected'}
</p>
<Button
variant="ghost"
size="icon-sm"
onClick={fetchTree}
disabled={loading}
className="h-6 w-6 shrink-0"
>
<HugeiconsIcon icon={RefreshIcon} className={cn("h-3 w-3", loading && "animate-spin")} />
<span className="sr-only">Refresh</span>
</Button>
</div>
{/* Search */}
<div className="relative p-2 border-b border-border/30">
<HugeiconsIcon icon={Search01Icon} className="absolute left-4 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Filter files..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 pl-7 text-xs"
/>
</div>
{/* Tree */}
<div className="max-h-[400px] overflow-auto">
{loading && tree.length === 0 ? (
<div className="flex items-center justify-center py-8">
<HugeiconsIcon icon={RefreshIcon} className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : tree.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{workingDirectory ? 'No files found' : 'Select a project folder to view files'}
</p>
) : (
<AIFileTree
defaultExpanded={defaultExpanded}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AI Elements FileTree onSelect type conflicts with HTMLAttributes.onSelect
onSelect={onFileSelect as any}
className="border-0 rounded-none"
>
<RenderTreeNodes nodes={tree} searchQuery={searchQuery} />
</AIFileTree>
)}
</div>
</div>
);
}
+142
View File
@@ -0,0 +1,142 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskCardProps {
task: TaskItem;
onUpdate: (id: string, updates: { title?: string; status?: TaskStatus }) => void;
onDelete: (id: string) => void;
}
const statusColors: Record<TaskStatus, string> = {
pending: "bg-yellow-500",
in_progress: "bg-blue-500",
completed: "bg-green-500",
failed: "bg-red-500",
};
const nextStatus: Record<TaskStatus, TaskStatus> = {
pending: "in_progress",
in_progress: "completed",
completed: "pending",
failed: "pending",
};
function formatTime(dateStr: string): string {
const date = new Date(dateStr.includes("T") ? dateStr : dateStr + "Z");
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
const diffHr = Math.floor(diffMin / 60);
if (diffMin < 1) return "just now";
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHr < 24) return `${diffHr}h ago`;
return date.toLocaleDateString();
}
export function TaskCard({ task, onUpdate, onDelete }: TaskCardProps) {
const [editing, setEditing] = useState(false);
const [editTitle, setEditTitle] = useState(task.title);
const [hovered, setHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editing]);
const handleStatusClick = () => {
onUpdate(task.id, { status: nextStatus[task.status] });
};
const handleTitleSubmit = () => {
const trimmed = editTitle.trim();
if (trimmed && trimmed !== task.title) {
onUpdate(task.id, { title: trimmed });
} else {
setEditTitle(task.title);
}
setEditing(false);
};
return (
<div
className="group flex items-start gap-2 rounded-md border border-border p-2 transition-colors hover:bg-accent/50"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Status dot */}
<button
onClick={handleStatusClick}
className="mt-1 shrink-0"
title={`Status: ${task.status} (click to change)`}
>
<span
className={cn(
"block h-2.5 w-2.5 rounded-full transition-transform hover:scale-125",
statusColors[task.status]
)}
/>
</button>
{/* Content */}
<div className="min-w-0 flex-1">
{editing ? (
<input
ref={inputRef}
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onBlur={handleTitleSubmit}
onKeyDown={(e) => {
if (e.key === "Enter") handleTitleSubmit();
if (e.key === "Escape") {
setEditTitle(task.title);
setEditing(false);
}
}}
className="w-full rounded border-none bg-transparent p-0 text-xs font-medium outline-none ring-1 ring-ring/30 px-1"
/>
) : (
<p
className={cn(
"cursor-pointer truncate text-xs font-medium",
task.status === "completed" && "text-muted-foreground line-through"
)}
onDoubleClick={() => setEditing(true)}
>
{task.title}
</p>
)}
{task.description && (
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{task.description}
</p>
)}
<p className="mt-0.5 text-[10px] text-muted-foreground">
{formatTime(task.updated_at)}
</p>
</div>
{/* Delete button */}
{hovered && (
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDelete(task.id)}
className="shrink-0 text-muted-foreground hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="h-3 w-3" />
<span className="sr-only">Delete task</span>
</Button>
)}
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { PlusSignIcon } from "@hugeicons/core-free-icons";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { TaskCard } from "./TaskCard";
import type { TaskItem, TaskStatus } from "@/types";
interface TaskListProps {
sessionId: string;
}
type FilterTab = "all" | "in_progress" | "completed";
export function TaskList({ sessionId }: TaskListProps) {
const [tasks, setTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [filter, setFilter] = useState<FilterTab>("all");
const fetchTasks = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
try {
const res = await fetch(`/api/tasks?session_id=${encodeURIComponent(sessionId)}`);
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch {
// silently fail
} finally {
setLoading(false);
}
}, [sessionId]);
useEffect(() => {
fetchTasks();
}, [fetchTasks]);
const handleCreate = async () => {
const title = newTitle.trim();
if (!title || !sessionId) return;
try {
const res = await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, title }),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => [...prev, data.task]);
setNewTitle("");
}
} catch {
// silently fail
}
};
const handleUpdate = async (
id: string,
updates: { title?: string; status?: TaskStatus }
) => {
try {
const res = await fetch(`/api/tasks/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (res.ok) {
const data = await res.json();
setTasks((prev) => prev.map((t) => (t.id === id ? data.task : t)));
}
} catch {
// silently fail
}
};
const handleDelete = async (id: string) => {
try {
const res = await fetch(`/api/tasks/${id}`, { method: "DELETE" });
if (res.ok) {
setTasks((prev) => prev.filter((t) => t.id !== id));
}
} catch {
// silently fail
}
};
const filtered = tasks.filter((task) => {
if (filter === "all") return true;
if (filter === "in_progress")
return task.status === "pending" || task.status === "in_progress";
if (filter === "completed") return task.status === "completed";
return true;
});
const filterTabs: { key: FilterTab; label: string }[] = [
{ key: "all", label: "All" },
{ key: "in_progress", label: "Active" },
{ key: "completed", label: "Done" },
];
return (
<div className="flex h-full flex-col">
{/* Filter tabs */}
<div className="flex items-center gap-1 pb-2">
{filterTabs.map((tab) => (
<Button
key={tab.key}
variant="ghost"
size="sm"
className={cn(
"h-6 px-2 text-[10px]",
filter === tab.key && "bg-accent"
)}
onClick={() => setFilter(tab.key)}
>
{tab.label}
</Button>
))}
</div>
{/* Add task input */}
<div className="flex items-center gap-1 pb-2">
<Input
placeholder="Add a task..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
className="h-7 text-xs"
/>
<Button
variant="ghost"
size="icon-sm"
onClick={handleCreate}
disabled={!newTitle.trim()}
>
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
<span className="sr-only">Add task</span>
</Button>
</div>
{/* Task list */}
<ScrollArea className="flex-1">
{loading && tasks.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
Loading tasks...
</p>
) : filtered.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
{tasks.length === 0 ? "No tasks yet" : "No matching tasks"}
</p>
) : (
<div className="flex flex-col gap-1.5 pb-4">
{filtered.map((task) => (
<TaskCard
key={task.id}
task={task}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
</ScrollArea>
</div>
);
}