feat: add agent icons with picker and collapsible sidebar section
- Add `icon` text column to agents DB schema with migration - Add icon field to shared Agent type and validators - Create AgentIconPicker component with 40+ curated lucide icons and search - Show clickable icon next to agent name on detail page header - Replace static Agents nav item with collapsible AGENTS section in sidebar - Each agent shows its icon (defaulting to Bot) with truncated name Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
packages/db/src/migrations/0016_agent_icon.sql
Normal file
1
packages/db/src/migrations/0016_agent_icon.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "agents" ADD COLUMN "icon" text;
|
||||||
@@ -113,6 +113,13 @@
|
|||||||
"when": 1771865100000,
|
"when": 1771865100000,
|
||||||
"tag": "0015_project_color_archived",
|
"tag": "0015_project_color_archived",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1771955900000,
|
||||||
|
"tag": "0016_agent_icon",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,7 @@ export const agents = pgTable(
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
role: text("role").notNull().default("general"),
|
role: text("role").notNull().default("general"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
|
icon: text("icon"),
|
||||||
status: text("status").notNull().default("idle"),
|
status: text("status").notNull().default("idle"),
|
||||||
reportsTo: uuid("reports_to").references((): AnyPgColumn => agents.id),
|
reportsTo: uuid("reports_to").references((): AnyPgColumn => agents.id),
|
||||||
capabilities: text("capabilities"),
|
capabilities: text("capabilities"),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface Agent {
|
|||||||
name: string;
|
name: string;
|
||||||
role: AgentRole;
|
role: AgentRole;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
icon: string | null;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
reportsTo: string | null;
|
reportsTo: string | null;
|
||||||
capabilities: string | null;
|
capabilities: string | null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const createAgentSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
role: z.enum(AGENT_ROLES).optional().default("general"),
|
role: z.enum(AGENT_ROLES).optional().default("general"),
|
||||||
title: z.string().optional().nullable(),
|
title: z.string().optional().nullable(),
|
||||||
|
icon: z.string().optional().nullable(),
|
||||||
reportsTo: z.string().uuid().optional().nullable(),
|
reportsTo: z.string().uuid().optional().nullable(),
|
||||||
capabilities: z.string().optional().nullable(),
|
capabilities: z.string().optional().nullable(),
|
||||||
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"),
|
adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"),
|
||||||
|
|||||||
166
ui/src/components/AgentIconPicker.tsx
Normal file
166
ui/src/components/AgentIconPicker.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
Cpu,
|
||||||
|
Brain,
|
||||||
|
Zap,
|
||||||
|
Rocket,
|
||||||
|
Code,
|
||||||
|
Terminal,
|
||||||
|
Shield,
|
||||||
|
Eye,
|
||||||
|
Search,
|
||||||
|
Wrench,
|
||||||
|
Hammer,
|
||||||
|
Lightbulb,
|
||||||
|
Sparkles,
|
||||||
|
Star,
|
||||||
|
Heart,
|
||||||
|
Flame,
|
||||||
|
Bug,
|
||||||
|
Cog,
|
||||||
|
Database,
|
||||||
|
Globe,
|
||||||
|
Lock,
|
||||||
|
Mail,
|
||||||
|
MessageSquare,
|
||||||
|
FileCode,
|
||||||
|
GitBranch,
|
||||||
|
Package,
|
||||||
|
Puzzle,
|
||||||
|
Target,
|
||||||
|
Wand2,
|
||||||
|
Atom,
|
||||||
|
CircuitBoard,
|
||||||
|
Radar,
|
||||||
|
Swords,
|
||||||
|
Telescope,
|
||||||
|
Microscope,
|
||||||
|
Crown,
|
||||||
|
Gem,
|
||||||
|
Hexagon,
|
||||||
|
Pentagon,
|
||||||
|
Fingerprint,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const AGENT_ICONS: Record<string, LucideIcon> = {
|
||||||
|
bot: Bot,
|
||||||
|
cpu: Cpu,
|
||||||
|
brain: Brain,
|
||||||
|
zap: Zap,
|
||||||
|
rocket: Rocket,
|
||||||
|
code: Code,
|
||||||
|
terminal: Terminal,
|
||||||
|
shield: Shield,
|
||||||
|
eye: Eye,
|
||||||
|
search: Search,
|
||||||
|
wrench: Wrench,
|
||||||
|
hammer: Hammer,
|
||||||
|
lightbulb: Lightbulb,
|
||||||
|
sparkles: Sparkles,
|
||||||
|
star: Star,
|
||||||
|
heart: Heart,
|
||||||
|
flame: Flame,
|
||||||
|
bug: Bug,
|
||||||
|
cog: Cog,
|
||||||
|
database: Database,
|
||||||
|
globe: Globe,
|
||||||
|
lock: Lock,
|
||||||
|
mail: Mail,
|
||||||
|
"message-square": MessageSquare,
|
||||||
|
"file-code": FileCode,
|
||||||
|
"git-branch": GitBranch,
|
||||||
|
package: Package,
|
||||||
|
puzzle: Puzzle,
|
||||||
|
target: Target,
|
||||||
|
wand: Wand2,
|
||||||
|
atom: Atom,
|
||||||
|
"circuit-board": CircuitBoard,
|
||||||
|
radar: Radar,
|
||||||
|
swords: Swords,
|
||||||
|
telescope: Telescope,
|
||||||
|
microscope: Microscope,
|
||||||
|
crown: Crown,
|
||||||
|
gem: Gem,
|
||||||
|
hexagon: Hexagon,
|
||||||
|
pentagon: Pentagon,
|
||||||
|
fingerprint: Fingerprint,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ICON = "bot";
|
||||||
|
|
||||||
|
export function getAgentIcon(iconName: string | null | undefined): LucideIcon {
|
||||||
|
return AGENT_ICONS[iconName ?? DEFAULT_ICON] ?? AGENT_ICONS[DEFAULT_ICON];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentIconProps {
|
||||||
|
icon: string | null | undefined;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentIcon({ icon, className }: AgentIconProps) {
|
||||||
|
const Icon = getAgentIcon(icon);
|
||||||
|
return <Icon className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentIconPickerProps {
|
||||||
|
value: string | null | undefined;
|
||||||
|
onChange: (icon: string) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentIconPicker({ value, onChange, children }: AgentIconPickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search) return Object.entries(AGENT_ICONS);
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return Object.entries(AGENT_ICONS).filter(([name]) => name.includes(q));
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-72 p-3" align="start">
|
||||||
|
<Input
|
||||||
|
placeholder="Search icons..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="mb-2 h-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-7 gap-1 max-h-48 overflow-y-auto">
|
||||||
|
{filtered.map(([name, Icon]) => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(name);
|
||||||
|
setOpen(false);
|
||||||
|
setSearch("");
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center h-8 w-8 rounded hover:bg-accent transition-colors",
|
||||||
|
(value ?? DEFAULT_ICON) === name && "bg-accent ring-1 ring-primary"
|
||||||
|
)}
|
||||||
|
title={name}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<p className="col-span-7 text-xs text-muted-foreground text-center py-2">No icons match</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,34 +3,39 @@ import {
|
|||||||
CircleDot,
|
CircleDot,
|
||||||
Target,
|
Target,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Bot,
|
|
||||||
DollarSign,
|
DollarSign,
|
||||||
History,
|
History,
|
||||||
Search,
|
Search,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
BookOpen,
|
|
||||||
Paperclip,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { CompanySwitcher } from "./CompanySwitcher";
|
|
||||||
import { SidebarSection } from "./SidebarSection";
|
import { SidebarSection } from "./SidebarSection";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { SidebarProjects } from "./SidebarProjects";
|
import { SidebarProjects } from "./SidebarProjects";
|
||||||
|
import { SidebarAgents } from "./SidebarAgents";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { openNewIssue } = useDialog();
|
const { openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { data: sidebarBadges } = useQuery({
|
const { data: sidebarBadges } = useQuery({
|
||||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
||||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: liveRuns } = useQuery({
|
||||||
|
queryKey: queryKeys.liveRuns(selectedCompanyId!),
|
||||||
|
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
});
|
||||||
|
const liveRunCount = liveRuns?.length ?? 0;
|
||||||
|
|
||||||
function openSearch() {
|
function openSearch() {
|
||||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||||
@@ -38,17 +43,11 @@ export function Sidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-60 h-full border-r border-border bg-background flex flex-col">
|
<aside className="w-60 h-full border-r border-border bg-background flex flex-col">
|
||||||
{/* Logo */}
|
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3">
|
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||||
<Paperclip className="h-5 w-5 text-foreground" />
|
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
|
||||||
<span className="text-sm font-semibold tracking-tight text-foreground">Paperclip</span>
|
{selectedCompany?.name ?? "Select company"}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
{/* Company switcher + actions */}
|
|
||||||
<div className="flex items-center gap-1 px-3 pb-3">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CompanySwitcher />
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
@@ -57,20 +56,20 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
className="text-muted-foreground shrink-0"
|
|
||||||
onClick={() => openNewIssue()}
|
|
||||||
>
|
|
||||||
<SquarePen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<nav className="flex flex-col gap-4 px-3 py-2">
|
<nav className="flex flex-col gap-4 px-3 py-2">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
{/* New Issue button aligned with nav items */}
|
||||||
|
<button
|
||||||
|
onClick={() => openNewIssue()}
|
||||||
|
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<SquarePen className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">New Issue</span>
|
||||||
|
</button>
|
||||||
|
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||||
<SidebarNavItem
|
<SidebarNavItem
|
||||||
to="/inbox"
|
to="/inbox"
|
||||||
label="Inbox"
|
label="Inbox"
|
||||||
@@ -88,18 +87,14 @@ export function Sidebar() {
|
|||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarAgents />
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
|
||||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
</nav>
|
</nav>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* Bottom links */}
|
|
||||||
<div className="border-t border-border px-3 py-2">
|
|
||||||
<SidebarNavItem to="/docs" label="Documentation" icon={BookOpen} />
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
79
ui/src/components/SidebarAgents.tsx
Normal file
79
ui/src/components/SidebarAgents.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
import { AgentIcon } from "./AgentIconPicker";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import type { Agent } from "@paperclip/shared";
|
||||||
|
|
||||||
|
export function SidebarAgents() {
|
||||||
|
const [open, setOpen] = useState(true);
|
||||||
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const visibleAgents = (agents ?? []).filter(
|
||||||
|
(a: Agent) => a.status !== "terminated"
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentMatch = location.pathname.match(/^\/agents\/([^/]+)/);
|
||||||
|
const activeAgentId = agentMatch?.[1] ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
|
<div className="group">
|
||||||
|
<div className="flex items-center px-3 py-1.5">
|
||||||
|
<CollapsibleTrigger className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 text-muted-foreground/60 transition-transform opacity-0 group-hover:opacity-100",
|
||||||
|
open && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-widest font-mono text-muted-foreground/60">
|
||||||
|
Agents
|
||||||
|
</span>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
|
{visibleAgents.map((agent: Agent) => (
|
||||||
|
<NavLink
|
||||||
|
key={agent.id}
|
||||||
|
to={`/agents/${agent.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||||
|
activeAgentId === agent.id
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AgentIcon icon={agent.icon} className="shrink-0 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="flex-1 truncate">{agent.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom";
|
import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { agentsApi, type AgentKey } from "../api/agents";
|
import { agentsApi, type AgentKey, type ClaudeLoginResult } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
import { activityApi } from "../api/activity";
|
import { activityApi } from "../api/activity";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker";
|
||||||
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared";
|
||||||
|
|
||||||
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||||
@@ -300,6 +301,16 @@ export function AgentDetail() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateIcon = useMutation({
|
||||||
|
mutationFn: (icon: string) => agentsApi.update(agentId!, { icon }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentId!) });
|
||||||
|
if (selectedCompanyId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const resetTaskSession = useMutation({
|
const resetTaskSession = useMutation({
|
||||||
mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey),
|
mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -363,12 +374,22 @@ export function AgentDetail() {
|
|||||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<h2 className="text-xl font-bold truncate">{agent.name}</h2>
|
<AgentIconPicker
|
||||||
<p className="text-sm text-muted-foreground truncate">
|
value={agent.icon}
|
||||||
{roleLabels[agent.role] ?? agent.role}
|
onChange={(icon) => updateIcon.mutate(icon)}
|
||||||
{agent.title ? ` - ${agent.title}` : ""}
|
>
|
||||||
</p>
|
<button className="shrink-0 flex items-center justify-center h-10 w-10 rounded-lg bg-accent hover:bg-accent/80 transition-colors">
|
||||||
|
<AgentIcon icon={agent.icon} className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</AgentIconPicker>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h2 className="text-xl font-bold truncate">{agent.name}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{roleLabels[agent.role] ?? agent.role}
|
||||||
|
{agent.title ? ` - ${agent.title}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
<div className="flex items-center gap-1 sm:gap-2 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
@@ -1024,6 +1045,11 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const metrics = runMetrics(run);
|
const metrics = runMetrics(run);
|
||||||
const [sessionOpen, setSessionOpen] = useState(false);
|
const [sessionOpen, setSessionOpen] = useState(false);
|
||||||
|
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setClaudeLoginResult(null);
|
||||||
|
}, [run.id]);
|
||||||
|
|
||||||
const cancelRun = useMutation({
|
const cancelRun = useMutation({
|
||||||
mutationFn: () => heartbeatsApi.cancel(run.id),
|
mutationFn: () => heartbeatsApi.cancel(run.id),
|
||||||
@@ -1054,6 +1080,13 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const runClaudeLogin = useMutation({
|
||||||
|
mutationFn: () => agentsApi.loginWithClaude(run.agentId),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setClaudeLoginResult(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
|
const timeFormat: Intl.DateTimeFormatOptions = { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false };
|
||||||
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
|
const startTime = run.startedAt ? new Date(run.startedAt).toLocaleTimeString("en-US", timeFormat) : null;
|
||||||
const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null;
|
const endTime = run.finishedAt ? new Date(run.finishedAt).toLocaleTimeString("en-US", timeFormat) : null;
|
||||||
@@ -1111,6 +1144,53 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
|
|||||||
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
|
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{run.errorCode === "claude_auth_required" && adapterType === "claude_local" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => runClaudeLogin.mutate()}
|
||||||
|
disabled={runClaudeLogin.isPending}
|
||||||
|
>
|
||||||
|
{runClaudeLogin.isPending ? "Running claude login..." : "Login to Claude Code"}
|
||||||
|
</Button>
|
||||||
|
{runClaudeLogin.isError && (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{runClaudeLogin.error instanceof Error
|
||||||
|
? runClaudeLogin.error.message
|
||||||
|
: "Failed to run Claude login"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{claudeLoginResult?.loginUrl && (
|
||||||
|
<p className="text-xs">
|
||||||
|
Login URL:
|
||||||
|
<a
|
||||||
|
href={claudeLoginResult.loginUrl}
|
||||||
|
className="text-blue-400 underline underline-offset-2 ml-1 break-all"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{claudeLoginResult.loginUrl}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{claudeLoginResult && (
|
||||||
|
<>
|
||||||
|
{!!claudeLoginResult.stdout && (
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-foreground overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{claudeLoginResult.stdout}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{!!claudeLoginResult.stderr && (
|
||||||
|
<pre className="bg-neutral-950 rounded-md p-3 text-xs font-mono text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{claudeLoginResult.stderr}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{hasNonZeroExit && (
|
{hasNonZeroExit && (
|
||||||
<div className="text-xs text-red-400">
|
<div className="text-xs text-red-400">
|
||||||
Exit code {run.exitCode}
|
Exit code {run.exitCode}
|
||||||
|
|||||||
Reference in New Issue
Block a user