* feat(agents): hide archived agents from default list Archived agents are now filtered out of the default agent list view. A toggle button (archive icon) appears when archived agents exist, allowing users to switch between viewing active and archived agents. The @mention suggestion list already filters out archived agents. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agents): show "No active agents" when all agents are archived When there are archived agents but no active ones, the empty state now shows "No active agents" instead of "No agents yet" to avoid confusion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Devv <devv@Devvs-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1757 lines
61 KiB
TypeScript
1757 lines
61 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef, useMemo } from "react";
|
|
import { useDefaultLayout } from "react-resizable-panels";
|
|
import {
|
|
Bot,
|
|
Cloud,
|
|
Monitor,
|
|
Plus,
|
|
ListTodo,
|
|
Wrench,
|
|
FileText,
|
|
BookOpenText,
|
|
MessageSquare,
|
|
Timer,
|
|
Trash2,
|
|
Save,
|
|
Key,
|
|
Link2,
|
|
Clock,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Loader2,
|
|
AlertCircle,
|
|
MoreHorizontal,
|
|
Play,
|
|
ChevronDown,
|
|
Globe,
|
|
Lock,
|
|
Settings,
|
|
Camera,
|
|
Archive,
|
|
} from "lucide-react";
|
|
import type {
|
|
Agent,
|
|
AgentStatus,
|
|
AgentVisibility,
|
|
AgentTool,
|
|
AgentTrigger,
|
|
AgentTriggerType,
|
|
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 { useRuntimeStore } from "@/features/runtimes";
|
|
import { useIssueStore } from "@/features/issues";
|
|
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,
|
|
triggers: [
|
|
{ id: generateId(), type: "on_assign", enabled: true, config: {} },
|
|
{ id: generateId(), type: "on_comment", enabled: true, config: {} },
|
|
],
|
|
});
|
|
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 workspaceSkills = useWorkspaceStore((s) => s.skills);
|
|
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
|
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 });
|
|
await refreshAgents();
|
|
} 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 });
|
|
await refreshAgents();
|
|
} 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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tools Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function AddToolDialog({
|
|
onClose,
|
|
onAdd,
|
|
}: {
|
|
onClose: () => void;
|
|
onAdd: (tool: AgentTool) => void;
|
|
}) {
|
|
const [name, setName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [authType, setAuthType] = useState<"oauth" | "api_key" | "none">("api_key");
|
|
|
|
const handleAdd = () => {
|
|
if (!name.trim()) return;
|
|
onAdd({
|
|
id: generateId(),
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
auth_type: authType,
|
|
connected: false,
|
|
config: {},
|
|
});
|
|
onClose();
|
|
};
|
|
|
|
return (
|
|
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">Add Tool</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
Connect an external tool for this agent to use.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Tool Name</Label>
|
|
<Input
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Google Search, Slack, GitHub"
|
|
className="mt-1"
|
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
|
/>
|
|
</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 tool do?"
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">Authentication</Label>
|
|
<div className="mt-1.5 flex gap-2">
|
|
{(["api_key", "oauth", "none"] as const).map((type) => (
|
|
<Button
|
|
key={type}
|
|
variant={authType === type ? "outline" : "ghost"}
|
|
size="xs"
|
|
onClick={() => setAuthType(type)}
|
|
className={`flex-1 ${
|
|
authType === type
|
|
? "border-primary bg-primary/5 font-medium"
|
|
: ""
|
|
}`}
|
|
>
|
|
{type === "api_key" ? "API Key" : type === "oauth" ? "OAuth" : "None"}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleAdd}
|
|
disabled={!name.trim()}
|
|
>
|
|
Add
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function ToolsTab({
|
|
agent,
|
|
onSave,
|
|
}: {
|
|
agent: Agent;
|
|
onSave: (tools: AgentTool[]) => Promise<void>;
|
|
}) {
|
|
const [tools, setTools] = useState<AgentTool[]>(agent.tools ?? []);
|
|
const [showAdd, setShowAdd] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setTools(agent.tools ?? []);
|
|
}, [agent.id, agent.tools]);
|
|
|
|
const isDirty = JSON.stringify(tools) !== JSON.stringify(agent.tools ?? []);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await onSave(tools);
|
|
} catch {
|
|
// toast handled by parent
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleConnect = (toolId: string) => {
|
|
setTools((prev) =>
|
|
prev.map((t) => (t.id === toolId ? { ...t, connected: !t.connected } : t)),
|
|
);
|
|
};
|
|
|
|
const removeTool = (toolId: string) => {
|
|
setTools((prev) => prev.filter((t) => t.id !== toolId));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Tools</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
External tools and APIs this agent can use during task execution.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isDirty && (
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
size="xs"
|
|
>
|
|
<Save className="h-3 w-3" />
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => setShowAdd(true)}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add Tool
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{tools.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
|
<Wrench className="h-8 w-8 text-muted-foreground/40" />
|
|
<p className="mt-3 text-sm text-muted-foreground">No tools configured</p>
|
|
<Button
|
|
onClick={() => setShowAdd(true)}
|
|
size="xs"
|
|
className="mt-3"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add Tool
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{tools.map((tool) => (
|
|
<div
|
|
key={tool.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">
|
|
{tool.auth_type === "oauth" ? (
|
|
<Link2 className="h-4 w-4 text-muted-foreground" />
|
|
) : tool.auth_type === "api_key" ? (
|
|
<Key className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Wrench className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">{tool.name}</div>
|
|
{tool.description && (
|
|
<div className="text-xs text-muted-foreground truncate">
|
|
{tool.description}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="xs"
|
|
onClick={() => toggleConnect(tool.id)}
|
|
className={
|
|
tool.connected
|
|
? "bg-success/10 text-success"
|
|
: "bg-muted text-muted-foreground hover:bg-accent"
|
|
}
|
|
>
|
|
{tool.connected ? "Connected" : "Connect"}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => removeTool(tool.id)}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showAdd && (
|
|
<AddToolDialog
|
|
onClose={() => setShowAdd(false)}
|
|
onAdd={(tool) => setTools((prev) => [...prev, tool])}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Triggers Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function TriggersTab({
|
|
agent,
|
|
onSave,
|
|
}: {
|
|
agent: Agent;
|
|
onSave: (triggers: AgentTrigger[]) => Promise<void>;
|
|
}) {
|
|
const [triggers, setTriggers] = useState<AgentTrigger[]>(agent.triggers ?? []);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setTriggers(agent.triggers ?? []);
|
|
}, [agent.id, agent.triggers]);
|
|
|
|
const isDirty = JSON.stringify(triggers) !== JSON.stringify(agent.triggers ?? []);
|
|
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await onSave(triggers);
|
|
} catch {
|
|
// toast handled by parent
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const toggleTrigger = (triggerId: string) => {
|
|
setTriggers((prev) =>
|
|
prev.map((t) => (t.id === triggerId ? { ...t, enabled: !t.enabled } : t)),
|
|
);
|
|
};
|
|
|
|
const removeTrigger = (triggerId: string) => {
|
|
setTriggers((prev) => prev.filter((t) => t.id !== triggerId));
|
|
};
|
|
|
|
const addTrigger = (type: AgentTriggerType) => {
|
|
const newTrigger: AgentTrigger = {
|
|
id: generateId(),
|
|
type,
|
|
enabled: true,
|
|
config: type === "scheduled" ? { cron: "0 9 * * 1-5", timezone: "UTC" } : {},
|
|
};
|
|
setTriggers((prev) => [...prev, newTrigger]);
|
|
};
|
|
|
|
const updateTriggerConfig = (triggerId: string, config: Record<string, unknown>) => {
|
|
setTriggers((prev) =>
|
|
prev.map((t) => (t.id === triggerId ? { ...t, config } : t)),
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">Triggers</h3>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
Configure when this agent should start working.
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isDirty && (
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
size="xs"
|
|
>
|
|
<Save className="h-3 w-3" />
|
|
{saving ? "Saving..." : "Save"}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{triggers.map((trigger) => (
|
|
<div
|
|
key={trigger.id}
|
|
className="rounded-lg border px-4 py-3"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
|
|
{trigger.type === "on_assign" ? (
|
|
<Bot className="h-4 w-4 text-muted-foreground" />
|
|
) : trigger.type === "on_comment" ? (
|
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Timer className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium">
|
|
{trigger.type === "on_assign"
|
|
? "On Issue Assign"
|
|
: trigger.type === "on_comment"
|
|
? "On Comment"
|
|
: "Scheduled"}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{trigger.type === "on_assign"
|
|
? "Runs when an issue is assigned to this agent"
|
|
: trigger.type === "on_comment"
|
|
? "Runs when a member comments on the agent's issue"
|
|
: `Cron: ${(trigger.config as { cron?: string }).cron ?? "Not set"}`}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => toggleTrigger(trigger.id)}
|
|
className={`relative h-5 w-9 rounded-full transition-colors ${
|
|
trigger.enabled ? "bg-primary" : "bg-muted"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
|
|
trigger.enabled ? "left-4.5" : "left-0.5"
|
|
}`}
|
|
/>
|
|
</button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-xs"
|
|
onClick={() => removeTrigger(trigger.id)}
|
|
className="text-muted-foreground hover:text-destructive"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{trigger.type === "scheduled" && (
|
|
<div className="mt-3 grid grid-cols-2 gap-3 pl-12">
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
Cron Expression
|
|
</Label>
|
|
<Input
|
|
type="text"
|
|
value={(trigger.config as { cron?: string }).cron ?? ""}
|
|
onChange={(e) =>
|
|
updateTriggerConfig(trigger.id, {
|
|
...trigger.config,
|
|
cron: e.target.value,
|
|
})
|
|
}
|
|
placeholder="0 9 * * 1-5"
|
|
className="mt-1 text-xs font-mono"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-muted-foreground">
|
|
Timezone
|
|
</Label>
|
|
<Input
|
|
type="text"
|
|
value={(trigger.config as { timezone?: string }).timezone ?? ""}
|
|
onChange={(e) =>
|
|
updateTriggerConfig(trigger.id, {
|
|
...trigger.config,
|
|
timezone: e.target.value,
|
|
})
|
|
}
|
|
placeholder="UTC"
|
|
className="mt-1 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => addTrigger("on_assign")}
|
|
className="border-dashed text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Bot className="h-3 w-3" />
|
|
Add On Assign
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => addTrigger("on_comment")}
|
|
className="border-dashed text-muted-foreground hover:text-foreground"
|
|
>
|
|
<MessageSquare className="h-3 w-3" />
|
|
Add On Comment
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => addTrigger("scheduled")}
|
|
className="border-dashed text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Timer className="h-3 w-3" />
|
|
Add Scheduled
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tasks Tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function TasksTab({ agent }: { agent: Agent }) {
|
|
const [tasks, setTasks] = useState<AgentTask[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const issues = useIssueStore((s) => s.issues);
|
|
|
|
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" | "tools" | "triggers" | "tasks" | "settings";
|
|
|
|
const detailTabs: { id: DetailTab; label: string; icon: typeof FileText }[] = [
|
|
{ id: "instructions", label: "Instructions", icon: FileText },
|
|
{ id: "skills", label: "Skills", icon: BookOpenText },
|
|
{ id: "tools", label: "Tools", icon: Wrench },
|
|
{ id: "triggers", label: "Triggers", icon: Timer },
|
|
{ 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">
|
|
<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 === "tools" && (
|
|
<ToolsTab
|
|
agent={agent}
|
|
onSave={(tools) => onUpdate(agent.id, { tools })}
|
|
/>
|
|
)}
|
|
{activeTab === "triggers" && (
|
|
<TriggersTab
|
|
agent={agent}
|
|
onSave={(triggers) => onUpdate(agent.id, { triggers })}
|
|
/>
|
|
)}
|
|
{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 agents = useWorkspaceStore((s) => s.agents);
|
|
const refreshAgents = useWorkspaceStore((s) => s.refreshAgents);
|
|
const [selectedId, setSelectedId] = useState<string>("");
|
|
const [showArchived, setShowArchived] = useState(false);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const runtimes = useRuntimeStore((s) => s.runtimes);
|
|
const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes);
|
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
|
id: "multica_agents_layout",
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (workspace) fetchRuntimes();
|
|
}, [workspace, fetchRuntimes]);
|
|
|
|
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);
|
|
await refreshAgents();
|
|
setSelectedId(agent.id);
|
|
};
|
|
|
|
const handleUpdate = async (id: string, data: Record<string, unknown>) => {
|
|
try {
|
|
await api.updateAgent(id, data as UpdateAgentRequest);
|
|
await refreshAgents();
|
|
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);
|
|
await refreshAgents();
|
|
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);
|
|
await refreshAgents();
|
|
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>
|
|
);
|
|
}
|