Skip to content
585 linesCodeBlameRaw
1import { BellOff, Bot, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
2import { ContextMenu } from "radix-ui";
3import { type ReactNode, useEffect, useState } from "react";
4import { Link, NavLink, useFetcher, useLocation, useNavigate } from "react-router";
5
6import type { ChatSidebarEntry, WorkspaceAgent } from "@g1t/contracts";
7
8import { conversationCache } from "./conversation-cache";
9import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
10import { DndMenu } from "./dnd-menu";
11import { MemberAvatar, PairAvatar, StatusDot, statusLabel } from "./marks";
12import { AgentAvatar } from "../agent-avatar";
13import { PersonStatusEmoji } from "../presence";
14import { isOrchestrator } from "../orchestrator";
15import { Badge } from "../ui/badge";
16import { Button } from "../ui/button";
17import { Hint } from "../ui/hint";
18import { Skeleton } from "../ui/skeleton";
19import { type ChatFilter, agentDmOf, agentsAmong, agentsToTalkTo, archivedAgentsDm, channelPath, filterEntries, mixedDm, sections } from "../../lib/chat";
20import { feedConnected } from "../../lib/notify-client";
21import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
22import { setMutedConversations } from "../../lib/sound-events";
23
24/** How often the sidebar asks again while the tab is shown and the feed socket is down. */
25const REFRESH_MS = FALLBACK_REFRESH_MS;
26
27const ROW =
28 "group/row flex h-9 items-center gap-2 rounded-md pr-1.5 pl-2 text-[0.8125rem] transition-colors max-md:h-11 max-md:text-[0.9375rem] max-md:active:bg-raised";
29
30/** Unread on a row: lavender for a mention, quiet for the rest. */
31function Count({ entry }: { entry: ChatSidebarEntry | undefined }) {
32 if (!entry || entry.muted) return null;
33 const count = entry.mentions > 0 ? entry.mentions : entry.unread;
34 if (count <= 0) return null;
35 return (
36 <span
37 className={`min-w-5 shrink-0 rounded-full group-hover/row:invisible px-1.5 text-center text-[0.6875rem] leading-[1.125rem] font-semibold tabular-nums ${
38 entry.mentions > 0 ? "bg-accent text-bg" : "bg-line-strong text-fg"
39 }`}
40 >
41 {count}
42 </span>
43 );
44}
45
46/**
47 * Chat mode's sidebar: what you pinned, the channels (and a way to browse
48 * the rest), then direct messages in two buckets so it is always clear who
49 * is on the other side: People, your conversations with people, the latest
50 * first (a conversation with a person and an agent in it is here, marked
51 * as such), and Agents, your conversations with agents alone, the latest
52 * first, then the agents you have not talked to yet, g1t first: one click
53 * opens a conversation with any of them. Sections fold, and stay folded on
54 * this device. All, Unread and Mentions filter every section. It refreshes
55 * itself while the tab is shown, so unread counts move without a reload.
56 */
57export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
58 const data = useChatData();
59 const { sidebar, refresh } = useChatSidebar();
60 const send = useChatSend(slug);
61 const [filter, setFilter] = useState<ChatFilter>("all");
62 const [query, setQuery] = useState("");
63 const [opening, setOpening] = useState<string | null>(null);
64 const navigate = useNavigate();
65 const { pathname } = useLocation();
66
67 useEffect(() => {
68 if (!data) return;
69 // Notify: counts arrive live over the feed socket; this asks again only while it is down.
70 const tick = () => {
71 if (document.visibilityState === "visible" && !feedConnected()) refresh();
72 };
73 const timer = setInterval(tick, REFRESH_MS);
74 document.addEventListener("visibilitychange", tick);
75 return () => {
76 clearInterval(timer);
77 document.removeEventListener("visibilitychange", tick);
78 };
79 }, [data, refresh]);
80
81 // Pins show at once and go back if the service refuses.
82 const [pins, setPins] = useState<Map<string, boolean>>(new Map());
83 useEffect(() => setPins(new Map()), [sidebar]);
84 // Sounds leave muted conversations alone (lib/sound-events.ts).
85 useEffect(() => {
86 if (sidebar) setMutedConversations(sidebar.entries.filter((e) => e.muted).map((e) => e.channel.id));
87 }, [sidebar]);
88 const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e));
89 // The open conversation stays listed even when it is one with an archived agent, which Jump to otherwise finds.
90 const currentId = entries.find((e) => pathname === channelPath(slug, e.channel) || pathname.startsWith(`${channelPath(slug, e.channel)}/`))?.channel.id ?? null;
91 const shown = filterEntries(entries, filter, query, currentId);
92 const { pinned, channels, people, agents: agentConversations } = sections(shown);
93 const { agentDms } = sections(entries);
94 const loading = !data;
95 const owner = data?.role === "owner";
96
97 // The agents you have not talked to yet, g1t first: one click opens a
98 // conversation, which then lists under Direct messages like anyone's.
99 // Nothing of theirs is unread, so Unread and Mentions leave them out.
100 // An archived agent is never offered (lib/chat.ts `agentsToTalkTo`).
101 const agents = filter !== "all" ? [] : agentsToTalkTo(data?.agents ?? [], agentDms, query);
102
103 // Folded sections, remembered per workspace on this device. A folded
104 // section still shows what is unread and the conversation open now.
105 const [folded, setFolded] = useState<ReadonlySet<string>>(new Set());
106 useEffect(() => setFolded(readFolded(slug)), [slug]);
107 const fold = (id: string) =>
108 setFolded((now) => {
109 const next = new Set(now);
110 if (!next.delete(id)) next.add(id);
111 writeFolded(slug, next);
112 return next;
113 });
114 const keepShown = (entry: ChatSidebarEntry) => {
115 const to = channelPath(slug, entry.channel);
116 return pathname === to || pathname.startsWith(`${to}/`) || (!entry.muted && (entry.unread > 0 || entry.mentions > 0));
117 };
118
119 /** The direct message with an agent: opened, or made on first use. */
120 const openAgent = async (agent: WorkspaceAgent, pin = false) => {
121 const had = agentDms.get(agent.id);
122 if (had && !pin) return navigate(channelPath(slug, had.channel));
123 setOpening(agent.id);
124 const opened = had ? { ok: true as const, value: { channel: had.channel, to: channelPath(slug, had.channel) } } : await send<{ channel: ChatSidebarEntry["channel"]; to: string }>({ intent: "dm", members: [`agent:${agent.id}`] });
125 if (opened.ok && pin) await send({ intent: "preferences", channel_id: opened.value.channel.id, starred: !had?.starred });
126 setOpening(null);
127 if (opened.ok && !pin) navigate(opened.value.to);
128 if (pin) refresh();
129 };
130 const togglePin = async (entry: ChatSidebarEntry) => {
131 const next = !entry.starred;
132 setPins((now) => new Map(now).set(entry.channel.id, next));
133 const done = await send({ intent: "preferences", channel_id: entry.channel.id, starred: next });
134 if (!done.ok)
135 setPins((now) => {
136 const back = new Map(now);
137 back.delete(entry.channel.id);
138 return back;
139 });
140 };
141
142 const conversation = (entry: ChatSidebarEntry) => (
143 <ConversationRow key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} agents={data?.agents ?? []} onPin={() => void togglePin(entry)} />
144 );
145
146 return (
147 // The colour behind it, for the dots cut out of people's avatars.
148 <div className="flex h-full flex-col [--chat-sidebar-bg:var(--color-sidebar)]">
149 {heading && (
150 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
151 <h2 className="text-[0.6875rem] font-medium tracking-wide text-faint uppercase">Chat</h2>
152 <div className="flex items-center gap-0.5">
153 <DndMenu />
154 <NewMessageButton slug={slug} />
155 </div>
156 </div>
157 )}
158 <div className="space-y-2.5 px-2.5 pt-3">
159 <div className="flex items-center gap-2">
160 <label className="flex h-8 min-w-0 grow items-center gap-2 rounded-md bg-surface px-2.5 text-[0.8125rem] ring-1 ring-line transition-shadow focus-within:ring-accent-dim/70">
161 <Search size={14} className="shrink-0 text-faint" />
162 <input
163 value={query}
164 onChange={(event) => setQuery(event.target.value)}
165 onKeyDown={(event) => {
166 if (event.key === "Enter") {
167 const first = [...pinned, ...channels, ...people, ...agentConversations][0];
168 if (first) {
169 navigate(channelPath(slug, first.channel));
170 setQuery("");
171 } else if (agents[0]) void openAgent(agents[0]);
172 } else if (event.key === "Escape") setQuery("");
173 }}
174 placeholder="Jump to channel or person"
175 aria-label="Jump to channel or person"
176 autoComplete="off"
177 data-1p-ignore
178 className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
179 />
180 </label>
181 {/* A phone has no heading row: Do not disturb sits by the search box instead. */}
182 {!heading && <DndMenu className="size-8 max-md:size-11" />}
183 </div>
184 <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
185 {(["all", "unread", "mentions"] as const).map((key) => (
186 <button
187 key={key}
188 type="button"
189 role="radio"
190 aria-checked={filter === key}
191 onClick={() => setFilter(key)}
192 className={`h-6 rounded-[5px] text-xs font-medium capitalize transition-colors ${
193 filter === key ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
194 }`}
195 >
196 {key}
197 </button>
198 ))}
199 </div>
200 </div>
201 {/* A phone's list (no heading): room at the end so the compose button never covers the last row. */}
202 <nav
203 aria-label="Conversations"
204 className={`min-h-0 grow overflow-y-auto px-2.5 pt-3 [scrollbar-width:thin] ${heading ? "pb-4" : "pb-[calc(5.5rem+env(safe-area-inset-bottom))]"}`}
205 >
206 {loading ? (
207 <SidebarSkeleton />
208 ) : !sidebar ? (
209 <p className="px-2 py-3 text-xs leading-relaxed text-faint">Chat didn&apos;t answer. Your conversations will show here once it does.</p>
210 ) : (
211 <>
212 {pinned.length > 0 && (
213 <Section id="pinned" title="Pinned" folded={folded} onFold={fold} keep={pinned.filter(keepShown).map(conversation)}>
214 {pinned.map(conversation)}
215 </Section>
216 )}
217 <Section
218 id="channels"
219 title="Channels"
220 folded={folded}
221 onFold={fold}
222 action={<CreateChannelButton slug={slug} />}
223 keep={channels.filter(keepShown).map(conversation)}
224 >
225 {channels.map(conversation)}
226 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
227 {filter === "all" && !query && (
228 <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join">
229 Browse channels
230 </ExtraRow>
231 )}
232 </Section>
233 <Section
234 id="people"
235 title="People"
236 folded={folded}
237 onFold={fold}
238 action={<NewMessageButton slug={slug} />}
239 keep={people.filter(keepShown).map(conversation)}
240 >
241 {people.map(conversation)}
242 {people.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
243 </Section>
244 {(agentConversations.length > 0 || agents.length > 0 || (filter === "all" && !query)) && (
245 <Section
246 id="agents"
247 title="Agents"
248 folded={folded}
249 onFold={fold}
250 action={
251 owner ? (
252 <Hint label="New agent">
253 <Link
254 to={`/${slug}/-/agents/new`}
255 aria-label="New agent"
256 className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
257 >
258 <Plus size={14} />
259 </Link>
260 </Hint>
261 ) : null
262 }
263 keep={agentConversations.filter(keepShown).map(conversation)}
264 >
265 {agentConversations.map(conversation)}
266 {agentConversations.length === 0 && agents.length === 0 && filter === "all" && !query && <Empty>Message an agent.</Empty>}
267 {agents.map((agent) => (
268 <AgentRow
269 key={agent.id}
270 agent={agent}
271 busy={opening === agent.id}
272 onOpen={() => void openAgent(agent)}
273 onPin={() => void openAgent(agent, true)}
274 />
275 ))}
276 {filter === "all" && !query && (
277 <ExtraRow to={`/${slug}/-/agents`} icon={<Bot size={15} />}>
278 {(data?.agents ?? []).length === 0 ? (owner ? "Hire an agent" : "No agents yet") : "All agents"}
279 </ExtraRow>
280 )}
281 </Section>
282 )}
283 {shown.length === 0 && agents.length === 0 && (filter !== "all" || query) && (
284 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
285 )}
286 </>
287 )}
288 </nav>
289 </div>
290 );
291}
292
293const FOLDED_KEY = (slug: string) => `g1t:chat-folded:${slug}`;
294
295/** The sections folded in this workspace, as last left; none if storage is not there. */
296function readFolded(slug: string): ReadonlySet<string> {
297 try {
298 const saved = JSON.parse(localStorage.getItem(FOLDED_KEY(slug)) ?? "[]");
299 return new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === "string") : []);
300 } catch {
301 return new Set();
302 }
303}
304
305function writeFolded(slug: string, folded: ReadonlySet<string>) {
306 try {
307 if (folded.size === 0) localStorage.removeItem(FOLDED_KEY(slug));
308 else localStorage.setItem(FOLDED_KEY(slug), JSON.stringify([...folded]));
309 } catch {
310 // Private windows and blocked storage: folding still works until a reload.
311 }
312}
313
314/**
315 * A titled part of the sidebar that folds away. Folded, it keeps `keep`
316 * showing: the unread conversations and the open one.
317 */
318function Section({
319 id,
320 title,
321 folded,
322 onFold,
323 action,
324 keep,
325 children,
326}: {
327 id: string;
328 title: string;
329 folded: ReadonlySet<string>;
330 onFold: (id: string) => void;
331 action?: ReactNode;
332 keep?: ReactNode[];
333 children: ReactNode;
334}) {
335 const open = !folded.has(id);
336 const list = `chat-section-${id}`;
337 return (
338 <section className="mb-3">
339 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
340 <button
341 type="button"
342 onClick={() => onFold(id)}
343 aria-expanded={open}
344 aria-controls={list}
345 className="flex min-w-0 grow items-center gap-1 rounded px-1 py-1 text-left text-[0.6875rem] font-medium tracking-wide text-faint uppercase transition-colors hover:text-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none max-md:text-[0.8125rem]"
346 >
347 <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
348 {title}
349 </button>
350 <span className="opacity-70 transition-opacity group-hover/head:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">{action}</span>
351 </div>
352 {(open || (keep && keep.length > 0)) && (
353 <ul id={list} className="space-y-px">
354 {open ? children : keep}
355 </ul>
356 )}
357 </section>
358 );
359}
360
361/** A quieter row at the end of a section: somewhere to go rather than a conversation. */
362function ExtraRow({ to, icon, count, countLabel, children }: { to: string; icon: ReactNode; count?: number; countLabel?: string; children: ReactNode }) {
363 return (
364 <li>
365 <NavLink
366 to={to}
367 end
368 className={({ isActive }) => `${ROW} ${isActive ? "bg-sidebar-accent text-fg" : "text-faint hover:bg-sidebar-accent/60 hover:text-muted"}`}
369 >
370 <span className="flex w-4.5 shrink-0 justify-center">{icon}</span>
371 <span className="min-w-0 grow truncate">{children}</span>
372 {count != null && (
373 <span className="shrink-0 text-xs tabular-nums">
374 {count}
375 {countLabel && <span className="sr-only"> {countLabel}</span>}
376 </span>
377 )}
378 </NavLink>
379 </li>
380 );
381}
382
383function Empty({ children }: { children: ReactNode }) {
384 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
385}
386
387/** Pin or unpin, on hover or focus; the same as the context menu's first item. */
388function PinButton({ pinned, onPin, label }: { pinned: boolean; onPin: () => void; label: string }) {
389 return (
390 <Hint label={pinned ? "Unpin" : "Pin to the top"}>
391 <Button
392 type="button"
393 aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`}
394 onClick={(event) => {
395 event.preventDefault();
396 event.stopPropagation();
397 onPin();
398 }}
399 variant="secondary" size="icon"
400 className="absolute top-1/2 right-1 hidden size-6 -translate-y-1/2 rounded text-faint group-hover/row:flex group-focus-within/row:flex hover:text-fg [@media(hover:none)]:hidden"
401 >
402 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
403 </Button>
404 </Hint>
405 );
406}
407
408/** Right-click on a row: pin it, or open it. */
409function RowMenu({ pinned, onPin, to, children }: { pinned: boolean; onPin: () => void; to?: string; children: ReactNode }) {
410 const navigate = useNavigate();
411 const item =
412 "flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-[0.8125rem] text-fg/90 outline-none select-none data-highlighted:bg-line data-highlighted:text-fg [&_svg]:size-4 [&_svg]:text-muted";
413 return (
414 <ContextMenu.Root>
415 <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
416 <ContextMenu.Portal>
417 <ContextMenu.Content className="z-50 min-w-44 rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40 data-[state=open]:animate-pop-in">
418 <ContextMenu.Item className={item} onSelect={onPin}>
419 {pinned ? <PinOff /> : <Pin />}
420 {pinned ? "Unpin" : "Pin to the top"}
421 </ContextMenu.Item>
422 {to && (
423 <ContextMenu.Item className={item} onSelect={() => navigate(to)}>
424 <Hash />
425 Open
426 </ContextMenu.Item>
427 )}
428 </ContextMenu.Content>
429 </ContextMenu.Portal>
430 </ContextMenu.Root>
431 );
432}
433
434/**
435 * Reads a conversation ahead, on hover, focus or the start of a touch, so
436 * opening it draws at once (its route's `clientLoader` keeps it).
437 */
438function usePrefetchConversation(to: string) {
439 const fetcher = useFetcher();
440 const load = () => {
441 if (fetcher.state === "idle" && fetcher.data === undefined && !conversationCache.has(to)) fetcher.load(to);
442 };
443 return { onPointerEnter: load, onFocus: load, onTouchStart: load };
444}
445
446/** One channel or direct message: its mark, its name, its unread count, and a pin. */
447function ConversationRow({
448 entry,
449 slug,
450 pathname,
451 agents,
452 onPin,
453}: {
454 entry: ChatSidebarEntry;
455 slug: string;
456 pathname: string;
457 agents: WorkspaceAgent[];
458 onPin: () => void;
459}) {
460 const to = channelPath(slug, entry.channel);
461 const current = pathname === to || pathname.startsWith(`${to}/`);
462 const prefetch = usePrefetchConversation(to);
463 const unread = !entry.muted && (entry.unread > 0 || entry.mentions > 0);
464 const agentId = agentDmOf(entry);
465 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
466 const other = entry.others[0];
467 const g1t = agent ? isOrchestrator(agent) : false;
468 // A person and an agent together: both faces, the agent's marked, and the word.
469 const mixed = mixedDm(entry);
470 // With an archived agent (lib/chat.ts): still here to read, dimmed, and said to be archived in place of its title.
471 const archived = archivedAgentsDm(entry);
472 const icon = agent ? (
473 <AgentFace agent={agent} g1t={g1t} />
474 ) : mixed ? (
475 <PairAvatar person={entry.others.find((m) => m.kind === "user")!} agent={agentsAmong(entry.others)[0]!} size={20} ring="var(--chat-sidebar-bg)" />
476 ) : entry.channel.kind === "dm" && other ? (
477 // One person: their dot, cut out of the sidebar behind it.
478 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
479 ) : entry.channel.private ? (
480 <Lock size={14} />
481 ) : (
482 <Hash size={15} />
483 );
484 return (
485 <li className="group/row relative">
486 <RowMenu pinned={entry.starred} onPin={onPin} to={to}>
487 <NavLink
488 to={to}
489 {...prefetch}
490 aria-current={current ? "page" : undefined}
491 className={`${ROW} ${
492 current ? "bg-sidebar-accent text-fg" : unread ? "text-fg hover:bg-sidebar-accent/60" : entry.muted || archived ? "text-faint hover:bg-sidebar-accent/60 hover:text-muted" : "text-muted hover:bg-sidebar-accent/60 hover:text-fg"
493 }`}
494 >
495 <span className={`flex shrink-0 justify-center ${mixed ? "w-7" : "w-4.5"} ${current || unread ? "text-muted" : "text-faint"} ${archived ? "opacity-60" : ""}`}>{icon}</span>
496 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
497 {agent ? agent.display_name : entry.title}
498 {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>}
499 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
500 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
501 )}
502 </span>
503 {archived && (
504 <Badge tone="neutral" className="shrink-0 px-1.5 font-normal">
505 {archived.deleted ? "Deleted" : "Archived"}
506 </Badge>
507 )}
508 {mixed && (
509 <Badge tone="accent" className="shrink-0 px-1.5 font-normal">
510 with an agent
511 </Badge>
512 )}
513 {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
514 <Count entry={entry} />
515 </NavLink>
516 </RowMenu>
517 <PinButton pinned={entry.starred} onPin={onPin} label={entry.title} />
518 </li>
519 );
520}
521
522/**
523 * An agent's face in a row, the agent marker ringed in the sidebar's own
524 * colour; any but g1t carries its status in the other corner.
525 */
526function AgentFace({ agent, g1t }: { agent: WorkspaceAgent; g1t: boolean }) {
527 return (
528 <span className="relative inline-flex shrink-0">
529 <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} ring="var(--chat-sidebar-bg)" />
530 {!g1t && agent.status !== "idle" && (
531 <span className="absolute -top-1 -right-1 flex rounded-full p-[2px]" style={{ background: "var(--chat-sidebar-bg)" }}>
532 <StatusDot status={agent.status} className="size-[7px] shadow-none" />
533 </span>
534 )}
535 </span>
536 );
537}
538
539/** An agent you have not talked to yet: its face, name and title. Opening it starts your conversation. */
540function AgentRow({ agent, busy, onOpen, onPin }: { agent: WorkspaceAgent; busy: boolean; onOpen: () => void; onPin: () => void }) {
541 const g1t = isOrchestrator(agent);
542 return (
543 <li className="group/row relative">
544 <RowMenu pinned={false} onPin={onPin}>
545 <button
546 type="button"
547 onClick={onOpen}
548 aria-busy={busy || undefined}
549 aria-label={`Message ${agent.display_name}, ${g1t ? "orchestrator" : agent.title}${g1t ? "" : `, ${statusLabel(agent.status).toLowerCase()}`}`}
550 className={`${ROW} w-full text-left text-muted hover:bg-sidebar-accent/60 hover:text-fg ${busy ? "opacity-60" : ""}`}
551 >
552 <span className="flex w-4.5 shrink-0 justify-center">
553 <AgentFace agent={agent} g1t={g1t} />
554 </span>
555 <span className="min-w-0 grow truncate">
556 {agent.display_name}
557 <span className="ml-1.5 text-[0.6875rem] text-faint">{g1t ? "orchestrator" : agent.title}</span>
558 </span>
559 </button>
560 </RowMenu>
561 <PinButton pinned={false} onPin={onPin} label={agent.display_name} />
562 </li>
563 );
564}
565
566function SidebarSkeleton() {
567 return (
568 <div className="space-y-2 px-2 pt-1" aria-busy="true">
569 <Skeleton className="h-3 w-16" />
570 {[70, 52, 84, 60].map((w) => (
571 <div key={w} className="flex h-6 items-center gap-2">
572 <Skeleton className="size-4 rounded" />
573 <Skeleton className="h-3" style={{ width: `${w}%` }} />
574 </div>
575 ))}
576 <Skeleton className="mt-4 h-3 w-24" />
577 {[64, 48, 72].map((w) => (
578 <div key={w} className="flex h-6 items-center gap-2">
579 <Skeleton className="size-4 rounded-full" />
580 <Skeleton className="h-3" style={{ width: `${w}%` }} />
581 </div>
582 ))}
583 </div>
584 );
585}