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