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