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:
Forgotten
2026-02-23 12:25:13 -06:00
parent 5b8708eae9
commit cf237d2e7f
9 changed files with 369 additions and 38 deletions

View File

@@ -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<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({
mutationFn: (taskKey: string | null) => agentsApi.resetSession(agentId!, taskKey),
onSuccess: () => {
@@ -363,12 +374,22 @@ export function AgentDetail() {
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{/* Header */}
<div className="flex items-center justify-between gap-2">
<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 className="flex items-center gap-3 min-w-0">
<AgentIconPicker
value={agent.icon}
onChange={(icon) => updateIcon.mutate(icon)}
>
<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 className="flex items-center gap-1 sm:gap-2 shrink-0">
<Button
@@ -1024,6 +1045,11 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const navigate = useNavigate();
const metrics = runMetrics(run);
const [sessionOpen, setSessionOpen] = useState(false);
const [claudeLoginResult, setClaudeLoginResult] = useState<ClaudeLoginResult | null>(null);
useEffect(() => {
setClaudeLoginResult(null);
}, [run.id]);
const cancelRun = useMutation({
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 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;
@@ -1111,6 +1144,53 @@ function RunDetail({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.errorCode && <span className="text-muted-foreground ml-1">({run.errorCode})</span>}
</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 && (
<div className="text-xs text-red-400">
Exit code {run.exitCode}