Skip to content
274 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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

This file's history is long; its oldest lines are credited to the oldest commit read.