diff --git a/packages/db/src/migrations/0016_agent_icon.sql b/packages/db/src/migrations/0016_agent_icon.sql new file mode 100644 index 00000000..47c0168f --- /dev/null +++ b/packages/db/src/migrations/0016_agent_icon.sql @@ -0,0 +1 @@ +ALTER TABLE "agents" ADD COLUMN "icon" text; diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index fe4f6ac3..f57f0ab0 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1771865100000, "tag": "0015_project_color_archived", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1771955900000, + "tag": "0016_agent_icon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/agents.ts b/packages/db/src/schema/agents.ts index 2b711e70..cf0635cf 100644 --- a/packages/db/src/schema/agents.ts +++ b/packages/db/src/schema/agents.ts @@ -18,6 +18,7 @@ export const agents = pgTable( name: text("name").notNull(), role: text("role").notNull().default("general"), title: text("title"), + icon: text("icon"), status: text("status").notNull().default("idle"), reportsTo: uuid("reports_to").references((): AnyPgColumn => agents.id), capabilities: text("capabilities"), diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index b7c4edf4..004c1dbb 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -14,6 +14,7 @@ export interface Agent { name: string; role: AgentRole; title: string | null; + icon: string | null; status: AgentStatus; reportsTo: string | null; capabilities: string | null; diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 1af0ee7c..74918685 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -27,6 +27,7 @@ export const createAgentSchema = z.object({ name: z.string().min(1), role: z.enum(AGENT_ROLES).optional().default("general"), title: z.string().optional().nullable(), + icon: z.string().optional().nullable(), reportsTo: z.string().uuid().optional().nullable(), capabilities: z.string().optional().nullable(), adapterType: z.enum(AGENT_ADAPTER_TYPES).optional().default("process"), diff --git a/ui/src/components/AgentIconPicker.tsx b/ui/src/components/AgentIconPicker.tsx new file mode 100644 index 00000000..f40abbff --- /dev/null +++ b/ui/src/components/AgentIconPicker.tsx @@ -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 = { + 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 ; +} + +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 ( + + {children} + + setSearch(e.target.value)} + className="mb-2 h-8 text-sm" + autoFocus + /> +
+ {filtered.map(([name, Icon]) => ( + + ))} + {filtered.length === 0 && ( +

No icons match

+ )} +
+
+
+ ); +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 09b9b168..68ccff34 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -3,34 +3,39 @@ import { CircleDot, Target, LayoutDashboard, - Bot, DollarSign, History, Search, SquarePen, - BookOpen, - Paperclip, } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { CompanySwitcher } from "./CompanySwitcher"; import { SidebarSection } from "./SidebarSection"; import { SidebarNavItem } from "./SidebarNavItem"; import { SidebarProjects } from "./SidebarProjects"; +import { SidebarAgents } from "./SidebarAgents"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { sidebarBadgesApi } from "../api/sidebarBadges"; +import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; export function Sidebar() { const { openNewIssue } = useDialog(); - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const { data: sidebarBadges } = useQuery({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!), queryFn: () => sidebarBadgesApi.get(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() { document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); @@ -38,17 +43,11 @@ export function Sidebar() { return ( ); } diff --git a/ui/src/components/SidebarAgents.tsx b/ui/src/components/SidebarAgents.tsx new file mode 100644 index 00000000..dc183d3b --- /dev/null +++ b/ui/src/components/SidebarAgents.tsx @@ -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 ( + +
+
+ + + + Agents + + +
+
+ + +
+ {visibleAgents.map((agent: Agent) => ( + { + 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" + )} + > + + {agent.name} + + ))} +
+
+
+ ); +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index fd803e9e..3b1b5d0d 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { useParams, useNavigate, Link, useBeforeUnload } from "react-router-dom"; 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 { activityApi } from "../api/activity"; import { issuesApi } from "../api/issues"; @@ -51,6 +51,7 @@ import { ArrowLeft, } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { AgentIcon, AgentIconPicker } from "../components/AgentIconPicker"; import type { Agent, HeartbeatRun, HeartbeatRunEvent, AgentRuntimeState } from "@paperclip/shared"; const runStatusIcons: Record = { @@ -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({ mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey), onSuccess: () => { @@ -363,12 +374,22 @@ export function AgentDetail() {
{/* Header */}
-
-

{agent.name}

-

- {roleLabels[agent.role] ?? agent.role} - {agent.title ? ` - ${agent.title}` : ""} -

+
+ updateIcon.mutate(icon)} + > + + +
+

{agent.name}

+

+ {roleLabels[agent.role] ?? agent.role} + {agent.title ? ` - ${agent.title}` : ""} +

+
)} + {run.errorCode === "claude_auth_required" && adapterType === "claude_local" && ( +
+ + {runClaudeLogin.isError && ( +

+ {runClaudeLogin.error instanceof Error + ? runClaudeLogin.error.message + : "Failed to run Claude login"} +

+ )} + {claudeLoginResult?.loginUrl && ( +

+ Login URL: + + {claudeLoginResult.loginUrl} + +

+ )} + {claudeLoginResult && ( + <> + {!!claudeLoginResult.stdout && ( +
+                        {claudeLoginResult.stdout}
+                      
+ )} + {!!claudeLoginResult.stderr && ( +
+                        {claudeLoginResult.stderr}
+                      
+ )} + + )} +
+ )} {hasNonZeroExit && (
Exit code {run.exitCode}