| 1 | import { Bot, ExternalLink, User } from "lucide-react"; |
| 2 | import type { ReactElement } from "react"; |
| 3 | import { siClaude, siCursor, siDependabot, siGithubactions, siGithubcopilot, siGooglegemini, siRenovate } from "simple-icons"; |
| 4 | |
| 5 | import { type CodingAgent, type CodingAgentId, codingAgentById } from "@g1t/contracts/coding-agents"; |
| 6 | |
| 7 | import { cn } from "../lib/cn"; |
| 8 | |
| 9 | import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; |
| 10 | |
| 11 | /** Each agent's mark (from Simple Icons) where its maker publishes one there; the rest show a bot. */ |
| 12 | const MARKS: Partial<Record<CodingAgentId, string>> = { |
| 13 | claude: siClaude.path, |
| 14 | copilot: siGithubcopilot.path, |
| 15 | gemini: siGooglegemini.path, |
| 16 | jules: siGooglegemini.path, |
| 17 | cursor: siCursor.path, |
| 18 | dependabot: siDependabot.path, |
| 19 | renovate: siRenovate.path, |
| 20 | "github-actions": siGithubactions.path, |
| 21 | }; |
| 22 | |
| 23 | /** |
| 24 | * A coding agent's avatar: its maker's mark on its colour, or on the |
| 25 | * page's own (light on dark, dark on light) when its mark is black or |
| 26 | * it has none. |
| 27 | */ |
| 28 | export function AgentMark({ agent, size = 20, className }: { agent: CodingAgent; size?: number; className?: string }) { |
| 29 | const path = MARKS[agent.id]; |
| 30 | const glyph = Math.round(size * 0.58); |
| 31 | return ( |
| 32 | <span |
| 33 | aria-hidden="true" |
| 34 | className={cn("inline-flex shrink-0 items-center justify-center rounded-full", agent.color ? "text-white" : "bg-fg text-bg", className)} |
| 35 | style={{ width: size, height: size, ...(agent.color ? { backgroundColor: `#${agent.color}` } : {}) }} |
| 36 | > |
| 37 | {path ? ( |
| 38 | <svg viewBox="0 0 24 24" fill="currentColor" width={glyph} height={glyph}> |
| 39 | <path d={path} /> |
| 40 | </svg> |
| 41 | ) : ( |
| 42 | <Bot size={glyph} strokeWidth={2.25} /> |
| 43 | )} |
| 44 | </span> |
| 45 | ); |
| 46 | } |
| 47 | |
| 48 | /** "agent" or "bot", beside an agent's name. */ |
| 49 | export function AgentBadge({ agent }: { agent: CodingAgent }) { |
| 50 | return <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">{agent.kind}</span>; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * A coding agent's card, over its name or avatar (`children`, one element |
| 55 | * that takes a ref): what it is, who makes it, and that g1t knows it by |
| 56 | * the address on its commits rather than as an account. |
| 57 | */ |
| 58 | export function AgentCard({ agentId, children }: { agentId: string | null | undefined; children: ReactElement }) { |
| 59 | const agent = codingAgentById(agentId); |
| 60 | if (!agent) return children; |
| 61 | return ( |
| 62 | <HoverCard> |
| 63 | <HoverCardTrigger asChild>{children}</HoverCardTrigger> |
| 64 | <HoverCardContent aria-label={`About ${agent.name}`}> |
| 65 | <div className="space-y-3"> |
| 66 | <div className="flex items-start gap-3"> |
| 67 | <AgentMark agent={agent} size={48} /> |
| 68 | <div className="min-w-0 pt-0.5"> |
| 69 | <p className="flex items-baseline gap-1.5 leading-tight"> |
| 70 | <span className="font-semibold">{agent.name}</span> |
| 71 | <AgentBadge agent={agent} /> |
| 72 | </p> |
| 73 | <p className="mt-0.5 text-xs text-faint">by {agent.maker}</p> |
| 74 | </div> |
| 75 | </div> |
| 76 | <p className="leading-relaxed text-fg/90">{agent.about}</p> |
| 77 | <p className="text-[0.8125rem] text-muted">Not an account on g1t: known by the address on its commits.</p> |
| 78 | <a href={agent.url} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[0.8125rem] font-medium text-accent hover:underline"> |
| 79 | About {agent.name} |
| 80 | <ExternalLink size={12} /> |
| 81 | </a> |
| 82 | </div> |
| 83 | </HoverCardContent> |
| 84 | </HoverCard> |
| 85 | ); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * The card of an author nobody on g1t owns: the name on their commits, and |
| 90 | * how to have them shown as you when they are yours. |
| 91 | */ |
| 92 | export function AuthorCard({ name, children }: { name: string; children: ReactElement }) { |
| 93 | return ( |
| 94 | <HoverCard> |
| 95 | <HoverCardTrigger asChild>{children}</HoverCardTrigger> |
| 96 | <HoverCardContent aria-label={`About ${name}`}> |
| 97 | <div className="space-y-3"> |
| 98 | <div className="flex items-start gap-3"> |
| 99 | <span aria-hidden="true" className="inline-flex size-12 shrink-0 items-center justify-center rounded-full bg-line text-faint"> |
| 100 | <User size={28} strokeWidth={2.25} /> |
| 101 | </span> |
| 102 | <div className="min-w-0 pt-0.5"> |
| 103 | <p className="leading-tight font-semibold wrap-anywhere">{name}</p> |
| 104 | <p className="mt-0.5 text-xs text-faint">Not on g1t</p> |
| 105 | </div> |
| 106 | </div> |
| 107 | <p className="text-[0.8125rem] leading-relaxed text-muted"> |
| 108 | The name on these commits. Their address isn't one any account has confirmed. If they're yours, add that address under{" "} |
| 109 | <a href="/settings/emails" className="font-medium text-fg hover:underline"> |
| 110 | Settings → Emails |
| 111 | </a>{" "} |
| 112 | and they'll show as you. |
| 113 | </p> |
| 114 | </div> |
| 115 | </HoverCardContent> |
| 116 | </HoverCard> |
| 117 | ); |
| 118 | } |