Skip to content
278 linesCodeBlameRaw
1import { Clock, MapPin, MessageSquare, UserRound, Users } from "lucide-react";
2import { type ReactNode, createContext, useContext, useEffect, useState } from "react";
3import { Link, useNavigate } from "react-router";
4
5import { type WorkspaceAgent, shownUsername } from "@g1t/contracts";
6
7import { useChatSend } from "./actions";
8import { AgentPill, StatusDot, statusLabel } from "./marks";
9import { AgentAvatar } from "../agent-avatar";
10import { BottomSheet } from "../mobile";
11import { PresenceSummary, WithPresence } from "../presence";
12import { isOrchestrator } from "../orchestrator";
13
14import { ButtonLink } from "../ui";
15import { Avatar } from "../ui/avatar";
16import { Button } from "../ui/button";
17import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
18import { Skeleton } from "../ui/skeleton";
19import { wholeDollars } from "../../lib/money";
20import type { UserCard } from "../../lib/hovercard";
21import { localTime } from "../../lib/time-zone";
22
23/** A person's card as Chat reads it: the hovercard, and their teams here. */
24export type PersonCard = UserCard & { teams: { slug: string; name: string }[] };
25
26/** What a card needs from the conversation it opens in. */
27export type CardContextValue = {
28 slug: string;
29 agents: WorkspaceAgent[];
30 /** Opens the profile panel beside the conversation, in place of its details. */
31 onViewProfile: (username: string) => void;
32};
33
34/** The conversation's card context; outside one, names are plain links. */
35export const CardContext = createContext<CardContextValue | null>(null);
36
37/** People's cards, read once per page and shared: hovering prefetches, opening uses it. */
38const cards = new Map<string, Promise<PersonCard | null>>();
39
40export function personCard(slug: string, username: string): Promise<PersonCard | null> {
41 const key = `${slug}:${username.toLowerCase()}`;
42 let found = cards.get(key);
43 if (!found) {
44 found = fetch(`/${slug}/-/chat/person/${encodeURIComponent(username)}`)
45 .then((response) => (response.ok ? (response.json() as Promise<PersonCard>) : null))
46 .catch(() => null);
47 cards.set(key, found);
48 }
49 return found;
50}
51
52function usePersonCard(slug: string, username: string, open: boolean): PersonCard | null | undefined {
53 const [card, setCard] = useState<PersonCard | null | undefined>(undefined);
54 useEffect(() => {
55 if (!open) return;
56 let live = true;
57 void personCard(slug, username).then((value) => {
58 if (live) setCard(value);
59 });
60 return () => {
61 live = false;
62 };
63 }, [slug, username, open]);
64 return card;
65}
66
67/** Opens (or makes) the direct message with someone, then goes there. */
68function useMessage(slug: string) {
69 const send = useChatSend(slug);
70 const navigate = useNavigate();
71 const [busy, setBusy] = useState(false);
72 return {
73 busy,
74 open: async (member: string) => {
75 setBusy(true);
76 const opened = await send<{ to: string }>({ intent: "dm", members: [member] });
77 setBusy(false);
78 if (opened.ok) navigate(opened.value.to);
79 },
80 };
81}
82
83
84function PersonBody({ username, display, avatar, ctx, open, close }: { username: string; display: string; avatar: string | null; ctx: CardContextValue; open: boolean; close: () => void }) {
85 const card = usePersonCard(ctx.slug, username, open);
86 const message = useMessage(ctx.slug);
87 const [now, setNow] = useState(() => Date.now());
88 useEffect(() => setNow(Date.now()), [open]);
89 const time = card ? localTime(card.timezone, now) : null;
90 return (
91 <div className="w-full">
92 <div className="flex items-start gap-3.5">
93 <WithPresence person={{ username }} size={56} ring="var(--color-raised)">
94 <Avatar name={username} image={card?.avatar ?? avatar} size={56} />
95 </WithPresence>
96 <div className="min-w-0 pt-1">
97 <p className="truncate text-base font-semibold">{card?.name?.trim() || display}</p>
98 <p className="truncate font-mono text-xs text-muted">
99 @{card ? shownUsername(card) : username}
100 {card?.pronouns ? <span className="font-sans"> · {card.pronouns}</span> : null}
101 </p>
102 </div>
103 </div>
104 {/* Live: their status and whether they are here (components/presence.tsx). */}
105 <PresenceSummary person={{ username }} className="mt-3 text-[0.8125rem] text-muted" />
106 <div className="mt-3 space-y-1.5 text-[0.8125rem] text-muted">
107 {card === undefined ? (
108 <>
109 <Skeleton className="h-3 w-40" />
110 <Skeleton className="h-3 w-28" />
111 </>
112 ) : (
113 <>
114 {card?.bio && <p className="line-clamp-2 text-fg-soft">{card.bio}</p>}
115 {card && card.teams.length > 0 && (
116 <p className="flex items-center gap-2">
117 <Users size={13} className="shrink-0 text-faint" />
118 <span className="truncate">{card.teams.map((team) => team.name).join(", ")}</span>
119 </p>
120 )}
121 {time && (
122 <p className="flex items-center gap-2" suppressHydrationWarning>
123 <Clock size={13} className="shrink-0 text-faint" />
124 {time} local time
125 </p>
126 )}
127 {card?.location && (
128 <p className="flex items-center gap-2">
129 <MapPin size={13} className="shrink-0 text-faint" />
130 <span className="truncate">{card.location}</span>
131 </p>
132 )}
133 </>
134 )}
135 </div>
136 <div className="mt-4 flex gap-2">
137 <Button type="button" disabled={message.busy} onClick={() => void message.open(`user:${username}`)} variant="accent" className="grow rounded-lg max-md:h-11">
138 <MessageSquare size={15} />
139 {message.busy ? "Opening…" : "Message"}
140 </Button>
141 <Button
142 type="button"
143 onClick={() => {
144 close();
145 ctx.onViewProfile(username);
146 }}
147 variant="outline" className="grow rounded-lg max-md:h-11">
148 <UserRound size={15} />
149 View profile
150 </Button>
151 </div>
152 </div>
153 );
154}
155
156function AgentBody({ agent, ctx }: { agent: WorkspaceAgent; ctx: CardContextValue }) {
157 const message = useMessage(ctx.slug);
158 const g1t = isOrchestrator(agent);
159 const cap = agent.budget.monthly_micros;
160 const share = cap ? Math.min(1, agent.spent_month_micros / cap) : null;
161 return (
162 <div className="w-full">
163 <div className="flex items-start gap-3.5">
164 <AgentAvatar agent={agent} size={56} />
165 <div className="min-w-0 pt-1">
166 <p className="flex items-center gap-2 text-base font-semibold">
167 <span className="truncate">{agent.display_name}</span>
168 <AgentPill />
169 </p>
170 <p className="truncate text-xs text-muted">{g1t ? "Orchestrator" : agent.title || agent.role}</p>
171 </div>
172 </div>
173 <div className="mt-3 space-y-2 text-[0.8125rem] text-muted">
174 <p className="flex items-center gap-2">
175 <StatusDot status={agent.status} />
176 {statusLabel(agent.status)}
177 {agent.status === "working" ? <span className="text-faint">· on a task now</span> : null}
178 </p>
179 {!g1t && agent.responsibilities.length > 0 && <p className="line-clamp-2 text-fg-soft">{agent.responsibilities.slice(0, 2).join(" · ")}</p>}
180 {cap != null && share != null && (
181 <div>
182 <p className="flex justify-between text-xs">
183 <span>This month</span>
184 <span className="tabular-nums">
185 {wholeDollars(agent.spent_month_micros)} of {wholeDollars(cap)}
186 </span>
187 </p>
188 <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-line">
189 <div className={`h-full rounded-full ${share >= 1 ? "bg-danger" : share >= 0.8 ? "bg-warn" : "bg-accent"}`} style={{ width: `${Math.max(2, share * 100)}%` }} />
190 </div>
191 </div>
192 )}
193 </div>
194 <div className="mt-4 flex gap-2">
195 <Button type="button" disabled={message.busy} onClick={() => void message.open(`agent:${agent.id}`)} variant="accent" className="grow rounded-lg max-md:h-11">
196 <MessageSquare size={15} />
197 {message.busy ? "Opening…" : "Message"}
198 </Button>
199 <ButtonLink to={`/${ctx.slug}/-/agents/${agent.handle}`} variant="outline" className="grow rounded-lg max-md:h-11">
200 Profile
201 </ButtonLink>
202 </div>
203 </div>
204 );
205}
206
207/**
208 * A name or avatar that opens the member's card: a popover beside it, or a
209 * sheet from the bottom on a phone. People get Message and View profile
210 * (a panel beside the conversation); agents get Message and Profile.
211 * Hovering or focusing it reads the card ahead. Outside a conversation it
212 * is a plain link.
213 */
214export function MemberCard({
215 member,
216 children,
217 className = "",
218 label,
219}: {
220 member: { kind: "user" | "agent"; name: string; id?: string; display_name?: string; avatar?: string | null };
221 children: ReactNode;
222 className?: string;
223 label?: string;
224}) {
225 const ctx = useContext(CardContext);
226 const [open, setOpen] = useState(false);
227 const [sheet, setSheet] = useState(false);
228 const agent =
229 member.kind === "agent" ? (ctx?.agents.find((a) => a.id === member.id || a.handle === member.name.toLowerCase()) ?? null) : null;
230 const href = member.kind === "agent" ? `/${ctx?.slug ?? ""}/-/agents/${member.name}` : `/u/${member.name}`;
231 if (!ctx || (member.kind === "agent" && !agent)) {
232 return (
233 <Link to={href} className={className} aria-label={label}>
234 {children}
235 </Link>
236 );
237 }
238 const prefetch = () => {
239 if (member.kind === "user") void personCard(ctx.slug, member.name);
240 };
241 const body = (close: () => void, isOpen: boolean) =>
242 agent ? (
243 <AgentBody agent={agent} ctx={ctx} />
244 ) : (
245 <PersonBody username={member.name} display={member.display_name ?? member.name} avatar={member.avatar ?? null} ctx={ctx} open={isOpen} close={close} />
246 );
247 return (
248 <>
249 <Popover open={open} onOpenChange={setOpen}>
250 <PopoverTrigger asChild>
251 <button
252 type="button"
253 aria-label={label ?? `${member.display_name ?? member.name}'s card`}
254 onPointerEnter={prefetch}
255 onFocus={prefetch}
256 onTouchStart={prefetch}
257 onClick={(event) => {
258 // A phone: the card is a sheet from the bottom instead.
259 if (window.matchMedia("(max-width: 767px)").matches) {
260 event.preventDefault();
261 setSheet(true);
262 }
263 }}
264 className={`cursor-pointer text-left ${className}`}
265 >
266 {children}
267 </button>
268 </PopoverTrigger>
269 <PopoverContent align="start" className="w-80 p-4">
270 {body(() => setOpen(false), open)}
271 </PopoverContent>
272 </Popover>
273 <BottomSheet open={sheet} onOpenChange={setSheet} title={`${member.display_name ?? member.name}`}>
274 <div className="px-2 pt-1 pb-2">{body(() => setSheet(false), sheet)}</div>
275 </BottomSheet>
276 </>
277 );
278}