1279 lines
46 KiB
TypeScript
1279 lines
46 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useMemo } from "react";
|
|
import { useDefaultLayout } from "react-resizable-panels";
|
|
import {
|
|
Bot,
|
|
Cloud,
|
|
Monitor,
|
|
Plus,
|
|
ListTodo,
|
|
FileText,
|
|
BookOpenText,
|
|
Trash2,
|
|
Save,
|
|
Clock,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
AlertCircle,
|
|
MoreHorizontal,
|
|
Play,
|
|
ChevronDown,
|
|
Globe,
|
|
Lock,
|
|
Settings,
|
|
Camera,
|
|
Archive,
|
|
} from "lucide-react";
|
|
import type {
|
|
Agent,
|
|
AgentStatus,
|
|
AgentVisibility,
|
|
AgentTask,
|
|
RuntimeDevice,
|
|
CreateAgentRequest,
|
|
UpdateAgentRequest,
|
|
} from "@/shared/types";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
ResizablePanelGroup,
|
|
ResizablePanel,
|
|
ResizableHandle,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "@/components/ui/popover";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { toast } from "sonner";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { api } from "@/shared/api";
|
|
import { useAuthStore } from "@/features/auth";
|
|
import { useWorkspaceStore } from "@/features/workspace";
|
|
import { runtimeListOptions } from "@core/runtimes/queries";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { useWorkspaceId } from "@core/hooks";
|
|
import { issueListOptions } from "@core/issues/queries";
|
|
import { skillListOptions, agentListOptions, workspaceKeys } from "@core/workspace/queries";
|
|
import { ActorAvatar } from "@/components/common/actor-avatar";
|
|
import { useFileUpload } from "@/shared/hooks/use-file-upload";
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const statusConfig: Record<AgentStatus, { label: string; color: string; dot: string }> = {
|
|
idle: { label: "Idle", color: "text-muted-foreground", dot: "bg-muted-foreground" },
|
|
working: { label: "Working", color: "text-success", dot: "bg-success" },
|
|
blocked: { label: "Blocked", color: "text-warning", dot: "bg-warning" },
|
|
error: { label: "Error", color: "text-destructive", dot: "bg-destructive" },
|
|
offline: { label: "Offline", color: "text-muted-foreground/50", dot: "bg-muted-foreground/40" },
|
|
};
|
|
|
|
const taskStatusConfig: Record<string, { label: string; icon: typeof CheckCircle2; color: string }> = {
|
|
queued: { label: "Queued", icon: Clock, color: "text-muted-foreground" },
|
|
dispatched: { label: "Dispatched", icon: Play, color: "text-info" },
|
|
running: { label: "Running", icon: Loader2, color: "text-success" },
|
|
completed: { label: "Completed", icon: CheckCircle2, color: "text-success" },
|
|
failed: { label: "Failed", icon: XCircle, color: "text-destructive" },
|
|
cancelled: { label: "Cancelled", icon: XCircle, color: "text-muted-foreground" },
|
|
};
|
|
|
|
|
|
function generateId(): string {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
}
|
|
|
|
function getRuntimeDevice(agent: Agent, runtimes: RuntimeDevice[]): RuntimeDevice | undefined {
|
|
return runtimes.find((runtime) => runtime.id === agent.runtime_id);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Create Agent Dialog
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function CreateAgentDialog({
|
|
runtimes,
|
|
onClose,
|
|
onCreate,
|
|
}: {
|
|
runtimes: RuntimeDevice[];
|
|
onClose: () => void;
|
|
onCreate: (data: CreateAgentRequest) => Promise<void>;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
|
|
const [visibility, setVisibility] = useState<AgentVisibility>("private");
|
|
const [creating, setCreating] = useState(false);
|
|
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!selectedRuntimeId && runtimes[0]) {
|
|
setSelectedRuntimeId(runtimes[0].id);
|
|
}
|
|
}, [runtimes, selectedRuntimeId]);
|
|
|
|
const selectedRuntime = runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
|
|
|
|
const handleSubmit = async () => {
|
|
if (!name.trim() || !selectedRuntime) return;
|
|
setCreating(true);
|
|
try {
|
|
await onCreate({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
runtime_id: selectedRuntime.id,
|
|
visibility,
|
|
});
|
|
onClose();
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to create agent");
|
|
setCreating(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Create Agent</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new AI agent for your workspace.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
|
<Input
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Deep Research Agent"
|
|
className="mt-1"
|
|
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Description</Label>
|
|
<Input
|
|
type="text"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="What does this agent do?"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
|
<div className="mt-1.5 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibility("workspace")}
|
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
|
visibility === "workspace"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="text-left">
|
|
<div className="font-medium">Workspace</div>
|
|
<div className="text-xs text-muted-foreground">All members can assign</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibility("private")}
|
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
|
visibility === "private"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="text-left">
|
|
<div className="font-medium">Private</div>
|
|
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
|
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
|
<PopoverTrigger
|
|
disabled={runtimes.length === 0}
|
|
className="flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
{selectedRuntime?.runtime_mode === "cloud" ? (
|
|
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium">
|
|
{selectedRuntime?.name ?? "No runtime available"}
|
|
</span>
|
|
{selectedRuntime?.runtime_mode === "cloud" && (
|
|
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
|
Cloud
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{selectedRuntime?.device_info ?? "Register a runtime before creating an agent"}
|
|
</div>
|
|
</div>
|
|
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
|
</PopoverTrigger>
|
|
<PopoverContent align="start" className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto">
|
|
{runtimes.map((device) => (
|
|
<button
|
|
key={device.id}
|
|
onClick={() => {
|
|
setSelectedRuntimeId(device.id);
|
|
setRuntimeOpen(false);
|
|
}}
|
|
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
|
device.id === selectedRuntimeId ? "bg-accent" : "hover:bg-accent/50"
|
|
}`}
|
|
>
|
|
{device.runtime_mode === "cloud" ? (
|
|
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<Monitor className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate font-medium">{device.name}</span>
|
|
{device.runtime_mode === "cloud" && (
|
|
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
|
Cloud
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">{device.device_info}</div>
|
|
</div>
|
|
<span
|
|
className={`h-2 w-2 shrink-0 rounded-full ${
|
|
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
|
}`}
|
|
/>
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={creating || !name.trim() || !selectedRuntime}
|
|
>
|
|
{creating ? "Creating..." : "Create"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent List Item
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AgentListItem({
|
|
agent,
|
|
isSelected,
|
|
onClick,
|
|
}: {
|
|
agent: Agent;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const st = statusConfig[agent.status];
|
|
const isArchived = !!agent.archived_at;
|
|
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
|
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
|
}`}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={agent.id} size={32} className={`rounded-lg ${isArchived ? "opacity-50 grayscale" : ""}`} />
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`truncate text-sm font-medium ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</span>
|
|
{agent.runtime_mode === "cloud" ? (
|
|
<Cloud className="h-3 w-3 text-muted-foreground" />
|
|
) : (
|
|
<Monitor className="h-3 w-3 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 mt-0.5">
|
|
{isArchived ? (
|
|
<span className="text-xs text-muted-foreground">Archived</span>
|
|
) : (
|
|
<>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
|
<span className={`text-xs ${st.color}`}>{st.label}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Instructions Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function InstructionsTab({
|
|
agent,
|
|
onSave,
|
|
}: {
|
|
agent: Agent;
|
|
onSave: (instructions: string) => Promise<void>;
|
|
}) {
|
|
const [value, setValue] = useState(agent.instructions ?? "");
|
|
const [saving, setSaving] = useState(false);
|
|
const isDirty = value !== (agent.instructions ?? "");
|
|
|
|
// Sync when switching between agents.
|
|
useEffect(() => {
|
|
setValue(agent.instructions ?? "");
|
|
}, [agent.id, agent.instructions]);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await onSave(value);
|
|
} catch {
|
|
// toast handled by parent
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Agent Instructions</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Define this agent's identity and working style. These instructions are
|
|
injected into the agent's context for every task.
|
|
</p>
|
|
</div>
|
|
|
|
<textarea
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
placeholder={`Define this agent's role, expertise, and working style.\n\nExample:\nYou are a frontend engineer specializing in React and TypeScript.\n\n## Working Style\n- Write small, focused PRs — one commit per logical change\n- Prefer composition over inheritance\n- Always add unit tests for new components\n\n## Constraints\n- Do not modify shared/ types without explicit approval\n- Follow the existing component patterns in features/`}
|
|
className="w-full min-h-[300px] rounded-md border bg-transparent px-3 py-2 text-sm font-mono placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y"
|
|
/>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-muted-foreground">
|
|
{value.length > 0 ? `${value.length} characters` : "No instructions set"}
|
|
</span>
|
|
<Button
|
|
size="xs"
|
|
onClick={handleSave}
|
|
disabled={!isDirty || saving}
|
|
>
|
|
{saving ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
<Save className="h-3 w-3" />
|
|
)}
|
|
Save
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skills Tab (picker — skills are managed on /skills page)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SkillsTab({
|
|
agent,
|
|
}: {
|
|
agent: Agent;
|
|
}) {
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
|
const [saving, setSaving] = useState(false);
|
|
const [showPicker, setShowPicker] = useState(false);
|
|
|
|
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
|
const availableSkills = workspaceSkills.filter((s) => !agentSkillIds.has(s.id));
|
|
|
|
const handleAdd = async (skillId: string) => {
|
|
setSaving(true);
|
|
try {
|
|
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
|
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to add skill");
|
|
} finally {
|
|
setSaving(false);
|
|
setShowPicker(false);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (skillId: string) => {
|
|
setSaving(true);
|
|
try {
|
|
const newIds = agent.skills.filter((s) => s.id !== skillId).map((s) => s.id);
|
|
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to remove skill");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Skills</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Reusable skills assigned to this agent. Manage skills on the Skills page.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => setShowPicker(true)}
|
|
disabled={saving || availableSkills.length === 0}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add Skill
|
|
</Button>
|
|
</div>
|
|
|
|
{agent.skills.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
|
<FileText className="h-8 w-8 text-muted-foreground/40" />
|
|
<p className="mt-3 text-sm text-muted-foreground">No skills assigned</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Add skills from the workspace to this agent.
|
|
</p>
|
|
{availableSkills.length > 0 && (
|
|
<Button
|
|
onClick={() => setShowPicker(true)}
|
|
size="xs"
|
|
className="mt-3"
|
|
disabled={saving}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add Skill
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{agent.skills.map((skill) => (
|
|
<div
|
|
key={skill.id}
|
|
className="flex items-center gap-3 rounded-lg border px-4 py-3"
|
|
>
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">{skill.name}</div>
|
|
{skill.description && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{skill.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => handleRemove(skill.id)}
|
|
disabled={saving}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Skill Picker Dialog */}
|
|
{showPicker && (
|
|
<Dialog open onOpenChange={(v) => { if (!v) setShowPicker(false); }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">Add Skill</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
Select a skill to assign to this agent.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="max-h-64 overflow-y-auto space-y-1">
|
|
{availableSkills.map((skill) => (
|
|
<button
|
|
key={skill.id}
|
|
onClick={() => handleAdd(skill.id)}
|
|
disabled={saving}
|
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent/50"
|
|
>
|
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="font-medium">{skill.name}</div>
|
|
{skill.description && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{skill.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
{availableSkills.length === 0 && (
|
|
<p className="py-6 text-center text-xs text-muted-foreground">
|
|
All workspace skills are already assigned.
|
|
</p>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setShowPicker(false)}>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tasks Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function TasksTab({ agent }: { agent: Agent }) {
|
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const wsId = useWorkspaceId();
|
|
const { data: issues = [] } = useQuery(issueListOptions(wsId));
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
api
|
|
.listAgentTasks(agent.id)
|
|
.then(setTasks)
|
|
.catch(() => setTasks([]))
|
|
.finally(() => setLoading(false));
|
|
}, [agent.id]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-2">
|
|
{Array.from({ length: 3 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 rounded-lg border px-4 py-3">
|
|
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<Skeleton className="h-4 w-1/2" />
|
|
<Skeleton className="h-3 w-1/3" />
|
|
</div>
|
|
<Skeleton className="h-4 w-16" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sort: active tasks (running > dispatched > queued) first, then completed/failed by date
|
|
const activeStatuses = ["running", "dispatched", "queued"];
|
|
const sortedTasks = [...tasks].sort((a, b) => {
|
|
const aActive = activeStatuses.indexOf(a.status);
|
|
const bActive = activeStatuses.indexOf(b.status);
|
|
const aIsActive = aActive !== -1;
|
|
const bIsActive = bActive !== -1;
|
|
if (aIsActive && !bIsActive) return -1;
|
|
if (!aIsActive && bIsActive) return 1;
|
|
if (aIsActive && bIsActive) return aActive - bActive;
|
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
});
|
|
|
|
const issueMap = new Map(issues.map((i) => [i.id, i]));
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Task Queue</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Issues assigned to this agent and their execution status.
|
|
</p>
|
|
</div>
|
|
|
|
{tasks.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
|
<ListTodo className="h-8 w-8 text-muted-foreground/40" />
|
|
<p className="mt-3 text-sm text-muted-foreground">No tasks in queue</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Assign an issue to this agent to get started.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{sortedTasks.map((task) => {
|
|
const config = taskStatusConfig[task.status] ?? taskStatusConfig.queued!;
|
|
const Icon = config.icon;
|
|
const issue = issueMap.get(task.issue_id);
|
|
const isActive = task.status === "running" || task.status === "dispatched";
|
|
const isRunning = task.status === "running";
|
|
|
|
return (
|
|
<div
|
|
key={task.id}
|
|
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
|
|
isRunning
|
|
? "border-success/40 bg-success/5"
|
|
: task.status === "dispatched"
|
|
? "border-info/40 bg-info/5"
|
|
: ""
|
|
}`}
|
|
>
|
|
<Icon
|
|
className={`h-4 w-4 shrink-0 ${config.color} ${
|
|
isRunning ? "animate-spin" : ""
|
|
}`}
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
{issue && (
|
|
<span className="shrink-0 text-xs font-mono text-muted-foreground">
|
|
{issue.identifier}
|
|
</span>
|
|
)}
|
|
<span className={`text-sm truncate ${isActive ? "font-medium" : ""}`}>
|
|
{issue?.title ?? `Issue ${task.issue_id.slice(0, 8)}...`}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-0.5">
|
|
{isRunning && task.started_at
|
|
? `Started ${new Date(task.started_at).toLocaleString()}`
|
|
: task.status === "dispatched" && task.dispatched_at
|
|
? `Dispatched ${new Date(task.dispatched_at).toLocaleString()}`
|
|
: task.status === "completed" && task.completed_at
|
|
? `Completed ${new Date(task.completed_at).toLocaleString()}`
|
|
: task.status === "failed" && task.completed_at
|
|
? `Failed ${new Date(task.completed_at).toLocaleString()}`
|
|
: `Queued ${new Date(task.created_at).toLocaleString()}`}
|
|
</div>
|
|
</div>
|
|
<span className={`shrink-0 text-xs font-medium ${config.color}`}>
|
|
{config.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SettingsTab({
|
|
agent,
|
|
runtimes,
|
|
onSave,
|
|
}: {
|
|
agent: Agent;
|
|
runtimes: RuntimeDevice[];
|
|
onSave: (updates: Partial<Agent>) => Promise<void>;
|
|
}) {
|
|
const [name, setName] = useState(agent.name);
|
|
const [description, setDescription] = useState(agent.description ?? "");
|
|
const [visibility, setVisibility] = useState<AgentVisibility>(agent.visibility);
|
|
const [maxTasks, setMaxTasks] = useState(agent.max_concurrent_tasks);
|
|
const [saving, setSaving] = useState(false);
|
|
const { upload, uploading } = useFileUpload();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = "";
|
|
try {
|
|
const result = await upload(file);
|
|
if (!result) return;
|
|
await onSave({ avatar_url: result.link });
|
|
toast.success("Avatar updated");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Failed to upload avatar");
|
|
}
|
|
};
|
|
|
|
const dirty =
|
|
name !== agent.name ||
|
|
description !== (agent.description ?? "") ||
|
|
visibility !== agent.visibility ||
|
|
maxTasks !== agent.max_concurrent_tasks;
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim()) {
|
|
toast.error("Name is required");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await onSave({ name: name.trim(), description, visibility, max_concurrent_tasks: maxTasks });
|
|
toast.success("Settings saved");
|
|
} catch {
|
|
toast.error("Failed to save settings");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const runtimeDevice = runtimes.find((r) => r.id === agent.runtime_id);
|
|
|
|
return (
|
|
<div className="max-w-lg space-y-6">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Avatar</Label>
|
|
<div className="mt-1.5 flex items-center gap-4">
|
|
<button
|
|
type="button"
|
|
className="group relative h-16 w-16 shrink-0 rounded-full bg-muted overflow-hidden focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={uploading}
|
|
>
|
|
<ActorAvatar actorType="agent" actorId={agent.id} size={64} className="rounded-none" />
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
|
{uploading ? (
|
|
<Loader2 className="h-5 w-5 animate-spin text-white" />
|
|
) : (
|
|
<Camera className="h-5 w-5 text-white" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
onChange={handleAvatarUpload}
|
|
/>
|
|
<div className="text-xs text-muted-foreground">
|
|
Click to upload avatar
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Name</Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Description</Label>
|
|
<Input
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="What does this agent do?"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
|
<div className="mt-1.5 flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibility("workspace")}
|
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
|
visibility === "workspace"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="text-left">
|
|
<div className="font-medium">Workspace</div>
|
|
<div className="text-xs text-muted-foreground">All members can assign</div>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setVisibility("private")}
|
|
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
|
visibility === "private"
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:bg-muted"
|
|
}`}
|
|
>
|
|
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<div className="text-left">
|
|
<div className="font-medium">Private</div>
|
|
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Max Concurrent Tasks</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={50}
|
|
value={maxTasks}
|
|
onChange={(e) => setMaxTasks(Number(e.target.value))}
|
|
className="mt-1 w-24"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
|
<div className="mt-1 flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm text-muted-foreground">
|
|
{agent.runtime_mode === "cloud" ? (
|
|
<Cloud className="h-4 w-4" />
|
|
) : (
|
|
<Monitor className="h-4 w-4" />
|
|
)}
|
|
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
|
|
</div>
|
|
</div>
|
|
|
|
<Button onClick={handleSave} disabled={!dirty || saving} size="sm">
|
|
{saving ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Save className="h-3.5 w-3.5 mr-1.5" />}
|
|
Save Changes
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Agent Detail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type DetailTab = "instructions" | "skills" | "tasks" | "settings";
|
|
|
|
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
|
{ id: "instructions", label: "Instructions", icon: FileText },
|
|
{ id: "skills", label: "Skills", icon: BookOpenText },
|
|
{ id: "tasks", label: "Tasks", icon: ListTodo },
|
|
{ id: "settings", label: "Settings", icon: Settings },
|
|
];
|
|
|
|
function AgentDetail({
|
|
agent,
|
|
runtimes,
|
|
onUpdate,
|
|
onArchive,
|
|
onRestore,
|
|
}: {
|
|
agent: Agent;
|
|
runtimes: RuntimeDevice[];
|
|
onUpdate: (id: string, data: Partial<Agent>) => Promise<void>;
|
|
onArchive: (id: string) => Promise<void>;
|
|
onRestore: (id: string) => Promise<void>;
|
|
}) {
|
|
const st = statusConfig[agent.status];
|
|
const runtimeDevice = getRuntimeDevice(agent, runtimes);
|
|
const [activeTab, setActiveTab] = useState<DetailTab>("instructions");
|
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
|
const isArchived = !!agent.archived_at;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
{/* Archive Banner */}
|
|
{isArchived && (
|
|
<div className="flex items-center gap-2 bg-muted/50 px-4 py-2 text-xs text-muted-foreground border-b">
|
|
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
|
|
<span className="flex-1">This agent is archived. It cannot be assigned or mentioned.</span>
|
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={() => onRestore(agent.id)}>
|
|
Restore
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
|
<ActorAvatar actorType="agent" actorId={agent.id} size={28} className={`rounded-md ${isArchived ? "opacity-50" : ""}`} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h2 className={`text-sm font-semibold truncate ${isArchived ? "text-muted-foreground" : ""}`}>{agent.name}</h2>
|
|
{isArchived ? (
|
|
<span className="rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
|
Archived
|
|
</span>
|
|
) : (
|
|
<span className={`flex items-center gap-1.5 text-xs ${st.color}`}>
|
|
<span className={`h-1.5 w-1.5 rounded-full ${st.dot}`} />
|
|
{st.label}
|
|
</span>
|
|
)}
|
|
<span className="flex items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
|
{agent.runtime_mode === "cloud" ? (
|
|
<Cloud className="h-3 w-3" />
|
|
) : (
|
|
<Monitor className="h-3 w-3" />
|
|
)}
|
|
{runtimeDevice?.name ?? (agent.runtime_mode === "cloud" ? "Cloud" : "Local")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{!isArchived && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button variant="ghost" size="icon-sm" />
|
|
}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-auto">
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={() => setConfirmArchive(true)}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
Archive Agent
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b px-6">
|
|
{detailTabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-xs font-medium transition-colors ${
|
|
activeTab === tab.id
|
|
? "border-primary text-foreground"
|
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<tab.icon className="h-3.5 w-3.5" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
<div className="flex-1 overflow-y-auto p-6">
|
|
{activeTab === "instructions" && (
|
|
<InstructionsTab
|
|
agent={agent}
|
|
onSave={(instructions) => onUpdate(agent.id, { instructions })}
|
|
/>
|
|
)}
|
|
{activeTab === "skills" && (
|
|
<SkillsTab agent={agent} />
|
|
)}
|
|
{activeTab === "tasks" && <TasksTab agent={agent} />}
|
|
{activeTab === "settings" && (
|
|
<SettingsTab
|
|
agent={agent}
|
|
runtimes={runtimes}
|
|
onSave={(updates) => onUpdate(agent.id, updates)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Archive Confirmation */}
|
|
{confirmArchive && (
|
|
<Dialog open onOpenChange={(v) => { if (!v) setConfirmArchive(false); }}>
|
|
<DialogContent className="max-w-sm" showCloseButton={false}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
|
<AlertCircle className="h-5 w-5 text-destructive" />
|
|
</div>
|
|
<DialogHeader className="flex-1 gap-1">
|
|
<DialogTitle className="text-sm font-semibold">Archive agent?</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
"{agent.name}" will be archived. It won't be assignable or mentionable, but all history is preserved. You can restore it later.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setConfirmArchive(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setConfirmArchive(false);
|
|
onArchive(agent.id);
|
|
}}
|
|
>
|
|
Archive
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function AgentsPage() {
|
|
const isLoading = useAuthStore((s) => s.isLoading);
|
|
const workspace = useWorkspaceStore((s) => s.workspace);
|
|
const qc = useQueryClient();
|
|
const wsId = useWorkspaceId();
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const [selectedId, setSelectedId] = useState<string>("");
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
|
id: "multica_agents_layout",
|
|
});
|
|
|
|
const filteredAgents = useMemo(
|
|
() => showArchived ? agents.filter((a) => !!a.archived_at) : agents.filter((a) => !a.archived_at),
|
|
[agents, showArchived],
|
|
);
|
|
|
|
const archivedCount = useMemo(() => agents.filter((a) => !!a.archived_at).length, [agents]);
|
|
|
|
// Select first agent on initial load or when filter changes
|
|
useEffect(() => {
|
|
if (filteredAgents.length > 0 && !filteredAgents.some((a) => a.id === selectedId)) {
|
|
setSelectedId(filteredAgents[0]!.id);
|
|
}
|
|
}, [filteredAgents, selectedId]);
|
|
|
|
const handleCreate = async (data: CreateAgentRequest) => {
|
|
const agent = await api.createAgent(data);
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
setSelectedId(agent.id);
|
|
};
|
|
|
|
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
|
try {
|
|
await api.updateAgent(id, data as UpdateAgentRequest);
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
toast.success("Agent updated");
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to update agent");
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const handleArchive = async (id: string) => {
|
|
try {
|
|
await api.archiveAgent(id);
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
toast.success("Agent archived");
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to archive agent");
|
|
}
|
|
};
|
|
|
|
const handleRestore = async (id: string) => {
|
|
try {
|
|
await api.restoreAgent(id);
|
|
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
|
toast.success("Agent restored");
|
|
} catch (e) {
|
|
toast.error(e instanceof Error ? e.message : "Failed to restore agent");
|
|
}
|
|
};
|
|
|
|
const selected = agents.find((a) => a.id === selectedId) ?? null;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex flex-1 min-h-0">
|
|
{/* List skeleton */}
|
|
<div className="w-72 border-r">
|
|
<div className="flex h-12 items-center justify-between border-b px-4">
|
|
<Skeleton className="h-4 w-16" />
|
|
<Skeleton className="h-6 w-6 rounded" />
|
|
</div>
|
|
<div className="divide-y">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-3 px-4 py-3">
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
<div className="flex-1 space-y-1.5">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-3 w-16" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Detail skeleton */}
|
|
<div className="flex-1 p-6 space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="h-10 w-10 rounded-full" />
|
|
<div className="space-y-1.5">
|
|
<Skeleton className="h-5 w-32" />
|
|
<Skeleton className="h-3 w-20" />
|
|
</div>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-8 w-full rounded-lg" />
|
|
<Skeleton className="h-8 w-full rounded-lg" />
|
|
<Skeleton className="h-8 w-3/4 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ResizablePanelGroup
|
|
orientation="horizontal"
|
|
className="flex-1 min-h-0"
|
|
defaultLayout={defaultLayout}
|
|
onLayoutChanged={onLayoutChanged}
|
|
>
|
|
<ResizablePanel id="list" defaultSize={280} minSize={240} maxSize={400} groupResizeBehavior="preserve-pixel-size">
|
|
{/* Left column — agent list */}
|
|
<div className="overflow-y-auto h-full border-r">
|
|
<div className="flex h-12 items-center justify-between border-b px-4">
|
|
<h1 className="text-sm font-semibold">Agents</h1>
|
|
<div className="flex items-center gap-1">
|
|
{archivedCount > 0 && (
|
|
<Button
|
|
variant={showArchived ? "secondary" : "ghost"}
|
|
size="icon-xs"
|
|
onClick={() => setShowArchived(!showArchived)}
|
|
title={showArchived ? "Show active agents" : "Show archived agents"}
|
|
>
|
|
<Archive className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => setShowCreate(true)}
|
|
>
|
|
<Plus className="h-4 w-4 text-muted-foreground" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{filteredAgents.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center px-4 py-12">
|
|
<Bot className="h-8 w-8 text-muted-foreground/40" />
|
|
<p className="mt-3 text-sm text-muted-foreground">
|
|
{showArchived ? "No archived agents" : archivedCount > 0 ? "No active agents" : "No agents yet"}
|
|
</p>
|
|
{!showArchived && (
|
|
<Button
|
|
onClick={() => setShowCreate(true)}
|
|
size="xs"
|
|
className="mt-3"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Create Agent
|
|
</Button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{filteredAgents.map((agent) => (
|
|
<AgentListItem
|
|
key={agent.id}
|
|
agent={agent}
|
|
isSelected={agent.id === selectedId}
|
|
onClick={() => setSelectedId(agent.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle />
|
|
|
|
<ResizablePanel id="detail" minSize="50%">
|
|
{/* Right column — agent detail */}
|
|
{selected ? (
|
|
<AgentDetail
|
|
key={selected.id}
|
|
agent={selected}
|
|
runtimes={runtimes}
|
|
onUpdate={handleUpdate}
|
|
onArchive={handleArchive}
|
|
onRestore={handleRestore}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
|
<Bot className="h-10 w-10 text-muted-foreground/30" />
|
|
<p className="mt-3 text-sm">Select an agent to view details</p>
|
|
<Button
|
|
onClick={() => setShowCreate(true)}
|
|
size="xs"
|
|
className="mt-3"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Create Agent
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</ResizablePanel>
|
|
|
|
{showCreate && (
|
|
<CreateAgentDialog
|
|
runtimes={runtimes}
|
|
onClose={() => setShowCreate(false)}
|
|
onCreate={handleCreate}
|
|
/>
|
|
)}
|
|
</ResizablePanelGroup>
|
|
);
|
|
}
|