| 1 | import { ArrowUpRight, Bot, ChevronRight, KeyRound, MessageSquare, Sparkles, UsersRound } from "lucide-react"; |
| 2 | import type { ReactNode } from "react"; |
| 3 | import { Link, data } from "react-router"; |
| 4 | |
| 5 | import type { Route } from "./+types/agent"; |
| 6 | import { AgentLink, AgentPersonFace, AgentTag, Coming, PersonFace, PersonLink, TeamKindMark, ToldText } from "../../../components/people"; |
| 7 | import { statusLabel } from "../../../components/chat/marks"; |
| 8 | import { ButtonLink } from "../../../components/ui"; |
| 9 | import { Badge } from "../../../components/ui/badge"; |
| 10 | import { page } from "../../../lib/meta"; |
| 11 | import { agentsOn, leads, peopleAgent, teamsOfAgent } from "../../../lib/people"; |
| 12 | import { teamPath } from "../../../lib/teams"; |
| 13 | import { money } from "../../../lib/usage"; |
| 14 | import { identity, workspaceAgents } from "../../../lib/services.server"; |
| 15 | import { getViewer, roleIn, unwrap } from "../../../lib/session.server"; |
| 16 | |
| 17 | export function meta({ loaderData, params, ...args }: Route.MetaArgs) { |
| 18 | return page(args, { title: `${loaderData?.agent.display_name ?? params.handle} · People · ${params.owner} · g1t` }); |
| 19 | } |
| 20 | |
| 21 | export async function loader({ params, context }: Route.LoaderArgs) { |
| 22 | const viewer = getViewer(context); |
| 23 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); |
| 24 | const [found, directory, listed, told] = await Promise.all([ |
| 25 | workspaceAgents.get(params.owner, params.handle, viewer!), |
| 26 | identity.peopleDirectory(viewer, params.owner).then(unwrap), |
| 27 | workspaceAgents.list(params.owner, viewer!).catch(() => null), |
| 28 | workspaceAgents.teamContext(params.owner, params.handle, viewer!).catch(() => null), |
| 29 | ]); |
| 30 | if (!found.ok) throw data(null, { status: 404 }); |
| 31 | const agent = found.value; |
| 32 | const agents = listed?.ok ? listed.value.map(peopleAgent) : [peopleAgent(agent)]; |
| 33 | const teams = teamsOfAgent(directory.teams, agent); |
| 34 | // Everyone on its teams, each once, leads first. |
| 35 | const names = new Set(teams.flatMap((team) => team.people.map((p) => p.username))); |
| 36 | const leadNames = new Set(teams.flatMap((team) => (team.lead?.kind === "user" ? [team.lead.username] : []))); |
| 37 | const teammates = directory.people |
| 38 | .filter((person) => names.has(person.username)) |
| 39 | .sort((a, b) => Number(leadNames.has(b.username)) - Number(leadNames.has(a.username))) |
| 40 | .map(({ user_id, username, display_username, name, avatar, title, owns }) => ({ user_id, username, display_username, name, avatar, title, owns, lead: leadNames.has(username) })); |
| 41 | return { |
| 42 | slug: params.owner.toLowerCase(), |
| 43 | agent: { ...peopleAgent(agent), monthly_micros: agent.budget.monthly_micros }, |
| 44 | teams, |
| 45 | teammates, |
| 46 | agents, |
| 47 | told: told?.ok ? told.value.text : null, |
| 48 | }; |
| 49 | } |
| 50 | |
| 51 | export default function AgentProfile({ loaderData }: Route.ComponentProps) { |
| 52 | const { slug, agent, teams, teammates, agents, told } = loaderData; |
| 53 | const otherAgents = teams |
| 54 | .flatMap((team) => agentsOn(team, agents)) |
| 55 | .filter((other, index, all) => other.id !== agent.id && all.findIndex((a) => a.id === other.id) === index); |
| 56 | |
| 57 | return ( |
| 58 | <div className="space-y-8"> |
| 59 | <nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm text-muted"> |
| 60 | <Link to={`/${slug}/-/people?kind=agents`} className="hover:text-fg"> |
| 61 | People |
| 62 | </Link> |
| 63 | <ChevronRight size={13} className="text-faint" /> |
| 64 | <span className="truncate text-fg">{agent.display_name}</span> |
| 65 | </nav> |
| 66 | |
| 67 | <header className="flex flex-col gap-5 border-b border-line pb-6 sm:flex-row sm:items-start"> |
| 68 | <div className="self-start"> |
| 69 | <AgentPersonFace agent={agent} size={72} /> |
| 70 | </div> |
| 71 | <div className="min-w-0 grow space-y-2"> |
| 72 | <div className="flex flex-wrap items-center gap-2"> |
| 73 | <h1 className="text-2xl font-semibold tracking-tight">{agent.display_name}</h1> |
| 74 | <AgentTag /> |
| 75 | </div> |
| 76 | <p className="text-sm text-muted">{[agent.title || agent.role, `@${agent.handle}`].join(" · ")}</p> |
| 77 | <p className="text-sm text-muted">{statusLabel(agent.status)}</p> |
| 78 | </div> |
| 79 | <div className="flex shrink-0 flex-wrap gap-2"> |
| 80 | <ButtonLink to={`/${slug}/-/chat?agent=${agent.handle}`}> |
| 81 | <MessageSquare size={15} /> |
| 82 | Message |
| 83 | </ButtonLink> |
| 84 | <ButtonLink to={`/${slug}/-/agents/${agent.handle}`} variant="quiet"> |
| 85 | Sessions and settings |
| 86 | <ArrowUpRight size={14} /> |
| 87 | </ButtonLink> |
| 88 | </div> |
| 89 | </header> |
| 90 | |
| 91 | {agent.responsibilities.length > 0 && ( |
| 92 | <section className="flex flex-wrap items-center gap-1.5"> |
| 93 | <span className="mr-1 text-sm text-muted">Responsible for</span> |
| 94 | {agent.responsibilities.map((duty) => ( |
| 95 | <Badge key={duty} tone="info"> |
| 96 | {duty} |
| 97 | </Badge> |
| 98 | ))} |
| 99 | </section> |
| 100 | )} |
| 101 | |
| 102 | <div className="grid gap-4 md:grid-cols-2"> |
| 103 | <Card icon={<UsersRound size={14} />} title="Teams"> |
| 104 | {teams.length ? ( |
| 105 | <ul className="space-y-2"> |
| 106 | {teams.map((team) => ( |
| 107 | <li key={team.slug} className="flex flex-wrap items-center gap-2"> |
| 108 | <TeamKindMark people={team.people.length} agents={agentsOn(team, agents).length} /> |
| 109 | <Link to={teamPath(slug, team.slug)} className="font-medium hover:text-accent"> |
| 110 | {team.name} |
| 111 | </Link> |
| 112 | {leads(team.lead, { id: agent.id }) && <Badge tone="accent">Lead</Badge>} |
| 113 | {agent.team === team.slug && <Badge>Home team</Badge>} |
| 114 | </li> |
| 115 | ))} |
| 116 | </ul> |
| 117 | ) : ( |
| 118 | <Quiet>Not on a team yet. Add it to one from the team's page.</Quiet> |
| 119 | )} |
| 120 | </Card> |
| 121 | |
| 122 | <Card icon={<Sparkles size={14} />} title="Who it works with"> |
| 123 | {teammates.length ? ( |
| 124 | <ul className="space-y-2"> |
| 125 | {teammates.map((person) => ( |
| 126 | <li key={person.username} className="flex min-w-0 items-center gap-2.5 text-sm"> |
| 127 | <PersonFace person={person} size={24} ring="var(--color-surface)" /> |
| 128 | <span className="min-w-0 grow truncate"> |
| 129 | <PersonLink workspace={slug} person={person} className="font-medium" /> |
| 130 | {person.title && <span className="text-muted"> · {person.title}</span>} |
| 131 | </span> |
| 132 | {person.lead && <Badge tone="accent">Lead</Badge>} |
| 133 | </li> |
| 134 | ))} |
| 135 | </ul> |
| 136 | ) : ( |
| 137 | <Quiet>{teams.length ? "Its teams are agents only." : "No people yet."}</Quiet> |
| 138 | )} |
| 139 | </Card> |
| 140 | |
| 141 | <Card icon={<Bot size={14} />} title="Agents on its teams"> |
| 142 | {otherAgents.length ? ( |
| 143 | <ul className="space-y-2"> |
| 144 | {otherAgents.map((other) => ( |
| 145 | <li key={other.id} className="flex min-w-0 items-center gap-2.5 text-sm"> |
| 146 | <AgentPersonFace agent={other} size={24} ring="var(--color-surface)" /> |
| 147 | <span className="min-w-0 truncate"> |
| 148 | <AgentLink workspace={slug} agent={other} className="font-medium" /> |
| 149 | <span className="text-muted"> · {other.title || other.role}</span> |
| 150 | </span> |
| 151 | </li> |
| 152 | ))} |
| 153 | </ul> |
| 154 | ) : ( |
| 155 | <Quiet>No other agents on its teams.</Quiet> |
| 156 | )} |
| 157 | </Card> |
| 158 | |
| 159 | <Card icon={<KeyRound size={14} />} title="Access"> |
| 160 | <dl className="space-y-3 text-sm"> |
| 161 | <div> |
| 162 | <dt className="text-xs text-faint">Code</dt> |
| 163 | <dd className="mt-0.5">What the person who asks it can read, and only what everyone in the conversation can see</dd> |
| 164 | </div> |
| 165 | <div> |
| 166 | <dt className="flex items-center gap-2 text-xs text-faint"> |
| 167 | Storage <Coming /> |
| 168 | </dt> |
| 169 | <dd className="mt-0.5 text-muted">Which of the workspace's files it may read and write comes with Storage.</dd> |
| 170 | </div> |
| 171 | <div> |
| 172 | <dt className="text-xs text-faint">Spent this month</dt> |
| 173 | <dd className="mt-0.5 tabular-nums"> |
| 174 | {money(agent.spent_month_micros)} |
| 175 | {agent.monthly_micros != null && <span className="text-muted"> of {money(agent.monthly_micros)}</span>} |
| 176 | </dd> |
| 177 | </div> |
| 178 | </dl> |
| 179 | </Card> |
| 180 | </div> |
| 181 | |
| 182 | <section className="rounded-xl border border-accent/40 bg-surface p-4"> |
| 183 | <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> |
| 184 | <h2 className="flex items-center gap-2 text-sm font-medium"> |
| 185 | <Sparkles size={14} className="text-accent" /> |
| 186 | What {agent.display_name} knows about its teams |
| 187 | </h2> |
| 188 | <span className="text-xs text-faint">Told every turn, from the team pages and who's around now</span> |
| 189 | </div> |
| 190 | {told ? <ToldText text={told} /> : <Quiet>Nothing yet: it's on no visible team. Once it is, it's told who leads, who owns what and who to page.</Quiet>} |
| 191 | </section> |
| 192 | </div> |
| 193 | ); |
| 194 | } |
| 195 | |
| 196 | function Card({ icon, title, children }: { icon: ReactNode; title: string; children: ReactNode }) { |
| 197 | return ( |
| 198 | <section className="rounded-xl border border-line bg-surface p-4"> |
| 199 | <h2 className="mb-3 flex items-center gap-2 text-sm font-medium text-muted"> |
| 200 | <span className="text-faint">{icon}</span> |
| 201 | {title} |
| 202 | </h2> |
| 203 | {children} |
| 204 | </section> |
| 205 | ); |
| 206 | } |
| 207 | |
| 208 | function Quiet({ children }: { children: ReactNode }) { |
| 209 | return <p className="text-sm text-faint">{children}</p>; |
| 210 | } |