Skip to content
451 linesCodeBlameRaw
1import { BellOff, 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, the workspace's
44 * agents (g1t first, each opening your conversation with it), then direct
45 * messages with people. All, Unread and Mentions filter every section. It
46 * refreshes itself while the tab is shown, so unread counts move without
47 * a reload.
48 */
49export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
50 const data = useChatData();
51 const { sidebar, refresh } = useChatSidebar();
52 const send = useChatSend(slug);
53 const [filter, setFilter] = useState<ChatFilter>("all");
54 const [query, setQuery] = useState("");
55 const [opening, setOpening] = useState<string | null>(null);
56 const navigate = useNavigate();
57 const { pathname } = useLocation();
58
59 useEffect(() => {
60 if (!data) return;
61 // Notify: counts arrive live over the feed socket; this asks again only while it is down.
62 const tick = () => {
63 if (document.visibilityState === "visible" && !feedConnected()) refresh();
64 };
65 const timer = setInterval(tick, REFRESH_MS);
66 document.addEventListener("visibilitychange", tick);
67 return () => {
68 clearInterval(timer);
69 document.removeEventListener("visibilitychange", tick);
70 };
71 }, [data, refresh]);
72
73 // Pins show at once and go back if the service refuses.
74 const [pins, setPins] = useState<Map<string, boolean>>(new Map());
75 useEffect(() => setPins(new Map()), [sidebar]);
76 const entries = (sidebar?.entries ?? []).map((e) => (pins.has(e.channel.id) ? { ...e, starred: pins.get(e.channel.id)! } : e));
77 const shown = filterEntries(entries, filter, query);
78 const { pinned, channels, dms } = sections(shown);
79 const { agentDms } = sections(entries);
80 const loading = !data;
81 const owner = data?.role === "owner";
82
83 // Every agent, g1t first; filtered as the conversations are.
84 const q = query.trim().toLowerCase().replace(/^@/, "");
85 const agents = [...(data?.agents ?? [])]
86 .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name))
87 .filter((agent) => {
88 const dm = agentDms.get(agent.id);
89 if (filter === "unread" && !(dm && !dm.muted && (dm.unread > 0 || dm.mentions > 0))) return false;
90 if (filter === "mentions" && !(dm && dm.mentions > 0)) return false;
91 return !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q);
92 });
93
94 /** The direct message with an agent: opened, or made on first use. */
95 const openAgent = async (agent: WorkspaceAgent, pin = false) => {
96 const had = agentDms.get(agent.id);
97 if (had && !pin) return navigate(channelPath(slug, had.channel));
98 setOpening(agent.id);
99 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}`] });
100 if (opened.ok && pin) await send({ intent: "preferences", channel_id: opened.value.channel.id, starred: !had?.starred });
101 setOpening(null);
102 if (opened.ok && !pin) navigate(opened.value.to);
103 if (pin) refresh();
104 };
105 const togglePin = async (entry: ChatSidebarEntry) => {
106 const next = !entry.starred;
107 setPins((now) => new Map(now).set(entry.channel.id, next));
108 const done = await send({ intent: "preferences", channel_id: entry.channel.id, starred: next });
109 if (!done.ok)
110 setPins((now) => {
111 const back = new Map(now);
112 back.delete(entry.channel.id);
113 return back;
114 });
115 };
116
117 const conversation = (entry: ChatSidebarEntry) => (
118 <ConversationRow key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} agents={data?.agents ?? []} onPin={() => void togglePin(entry)} />
119 );
120
121 return (
122 // The colour behind it, for the dots cut out of people's avatars.
123 <div className="flex h-full flex-col [--chat-sidebar-bg:color-mix(in_srgb,var(--color-surface)_70%,var(--color-bg))]">
124 {heading && (
125 <div className="flex h-14 shrink-0 items-center justify-between border-b border-line pr-2.5 pl-4">
126 <h2 className="text-[0.9375rem] font-semibold">Chat</h2>
127 <NewMessageButton slug={slug} />
128 </div>
129 )}
130 <div className="space-y-2.5 px-2.5 pt-3">
131 <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">
132 <Search size={14} className="shrink-0 text-faint" />
133 <input
134 value={query}
135 onChange={(event) => setQuery(event.target.value)}
136 onKeyDown={(event) => {
137 if (event.key === "Enter") {
138 const first = [...pinned, ...channels, ...dms][0];
139 if (first) {
140 navigate(channelPath(slug, first.channel));
141 setQuery("");
142 } else if (agents[0]) void openAgent(agents[0]);
143 } else if (event.key === "Escape") setQuery("");
144 }}
145 placeholder="Jump to channel or person"
146 aria-label="Jump to channel or person"
147 autoComplete="off"
148 data-1p-ignore
149 className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
150 />
151 </label>
152 <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
153 {(["all", "unread", "mentions"] as const).map((key) => (
154 <button
155 key={key}
156 type="button"
157 role="radio"
158 aria-checked={filter === key}
159 onClick={() => setFilter(key)}
160 className={`h-6 rounded-[5px] text-xs font-medium capitalize transition-colors ${
161 filter === key ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
162 }`}
163 >
164 {key}
165 </button>
166 ))}
167 </div>
168 </div>
169 {/* A phone's list (no heading): room at the end so the compose button never covers the last row. */}
170 <nav
171 aria-label="Conversations"
172 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))]"}`}
173 >
174 {loading ? (
175 <SidebarSkeleton />
176 ) : !sidebar ? (
177 <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>
178 ) : (
179 <>
180 {pinned.length > 0 && <Section title="Pinned">{pinned.map(conversation)}</Section>}
181 <Section title="Channels" action={<CreateChannelButton slug={slug} />}>
182 {channels.map(conversation)}
183 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
184 </Section>
185 <Section
186 title="Agents"
187 action={
188 owner ? (
189 <Hint label="New agent">
190 <Link
191 to={`/${slug}/-/agents/new`}
192 aria-label="New agent"
193 className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
194 >
195 <Plus size={14} />
196 </Link>
197 </Hint>
198 ) : null
199 }
200 >
201 {agents.map((agent) => (
202 <AgentRow
203 key={agent.id}
204 agent={agent}
205 dm={agentDms.get(agent.id)}
206 current={(() => {
207 const dm = agentDms.get(agent.id);
208 return dm != null && pathname === channelPath(slug, dm.channel);
209 })()}
210 busy={opening === agent.id}
211 onOpen={() => void openAgent(agent)}
212 onPin={() => void openAgent(agent, true)}
213 />
214 ))}
215 {agents.length === 0 && filter === "all" && !query && <Empty>No agents yet.</Empty>}
216 </Section>
217 <Section title="Direct messages" action={<NewMessageButton slug={slug} />}>
218 {dms.map(conversation)}
219 {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
220 </Section>
221 {shown.length === 0 && agents.length === 0 && (filter !== "all" || query) && (
222 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
223 )}
224 <NavLink
225 to={`/${slug}/-/chat/browse`}
226 className={({ isActive }) =>
227 `mt-3 flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${isActive ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`
228 }
229 >
230 <Compass size={15} className="text-faint" />
231 Browse all channels
232 <span className="ml-auto text-xs text-faint tabular-nums">{(sidebar.browsable ?? 0) + entries.filter((e) => e.channel.kind === "channel").length}</span>
233 </NavLink>
234 </>
235 )}
236 </nav>
237 </div>
238 );
239}
240
241function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
242 const [open, setOpen] = useState(true);
243 return (
244 <section className="mb-3">
245 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
246 <button
247 type="button"
248 onClick={() => setOpen(!open)}
249 aria-expanded={open}
250 className="flex items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
251 >
252 <ChevronDown size={12} className={`transition-transform ${open ? "" : "-rotate-90"}`} />
253 {title}
254 </button>
255 <span className="opacity-70 transition-opacity group-hover/head:opacity-100">{action}</span>
256 </div>
257 {open && <ul className="space-y-px">{children}</ul>}
258 </section>
259 );
260}
261
262function Empty({ children }: { children: ReactNode }) {
263 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
264}
265
266/** Pin or unpin, on hover or focus; the same as the context menu's first item. */
267function PinButton({ pinned, onPin, label }: { pinned: boolean; onPin: () => void; label: string }) {
268 return (
269 <Hint label={pinned ? "Unpin" : "Pin to the top"}>
270 <button
271 type="button"
272 aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`}
273 onClick={(event) => {
274 event.preventDefault();
275 event.stopPropagation();
276 onPin();
277 }}
278 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"
279 >
280 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
281 </button>
282 </Hint>
283 );
284}
285
286/** Right-click on a row: pin it, or open it. */
287function RowMenu({ pinned, onPin, to, children }: { pinned: boolean; onPin: () => void; to?: string; children: ReactNode }) {
288 const navigate = useNavigate();
289 const item =
290 "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";
291 return (
292 <ContextMenu.Root>
293 <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
294 <ContextMenu.Portal>
295 <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">
296 <ContextMenu.Item className={item} onSelect={onPin}>
297 {pinned ? <PinOff /> : <Pin />}
298 {pinned ? "Unpin" : "Pin to the top"}
299 </ContextMenu.Item>
300 {to && (
301 <ContextMenu.Item className={item} onSelect={() => navigate(to)}>
302 <Hash />
303 Open
304 </ContextMenu.Item>
305 )}
306 </ContextMenu.Content>
307 </ContextMenu.Portal>
308 </ContextMenu.Root>
309 );
310}
311
312/**
313 * Reads a conversation ahead, on hover, focus or the start of a touch, so
314 * opening it draws at once (its route's `clientLoader` keeps it).
315 */
316function usePrefetchConversation(to: string) {
317 const fetcher = useFetcher();
318 const load = () => {
319 if (fetcher.state === "idle" && fetcher.data === undefined && !conversationCache.has(to)) fetcher.load(to);
320 };
321 return { onPointerEnter: load, onFocus: load, onTouchStart: load };
322}
323
324/** One channel or direct message: its mark, its name, its unread count, and a pin. */
325function ConversationRow({
326 entry,
327 slug,
328 pathname,
329 agents,
330 onPin,
331}: {
332 entry: ChatSidebarEntry;
333 slug: string;
334 pathname: string;
335 agents: WorkspaceAgent[];
336 onPin: () => void;
337}) {
338 const to = channelPath(slug, entry.channel);
339 const current = pathname === to || pathname.startsWith(`${to}/`);
340 const prefetch = usePrefetchConversation(to);
341 const unread = !entry.muted && (entry.unread > 0 || entry.mentions > 0);
342 const agentId = agentDmOf(entry);
343 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
344 const other = entry.others[0];
345 const icon = agent ? (
346 <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={18} />
347 ) : entry.channel.kind === "dm" && other ? (
348 // One person: their dot, cut out of the sidebar behind it.
349 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
350 ) : entry.channel.private ? (
351 <Lock size={14} />
352 ) : (
353 <Hash size={15} />
354 );
355 return (
356 <li className="group/row relative">
357 <RowMenu pinned={entry.starred} onPin={onPin} to={to}>
358 <NavLink
359 to={to}
360 {...prefetch}
361 aria-current={current ? "page" : undefined}
362 className={`${ROW} ${
363 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"
364 }`}
365 >
366 <span className={`flex w-[18px] shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
367 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
368 {entry.title}
369 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
370 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
371 )}
372 </span>
373 {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
374 <Count entry={entry} />
375 </NavLink>
376 </RowMenu>
377 <PinButton pinned={entry.starred} onPin={onPin} label={entry.title} />
378 </li>
379 );
380}
381
382/** One agent: its face, its name and status, and unread from your conversation with it. */
383function AgentRow({
384 agent,
385 dm,
386 current,
387 busy,
388 onOpen,
389 onPin,
390}: {
391 agent: WorkspaceAgent;
392 dm: ChatSidebarEntry | undefined;
393 current: boolean;
394 busy: boolean;
395 onOpen: () => void;
396 onPin: () => void;
397}) {
398 const g1t = isOrchestrator(agent);
399 const unread = dm != null && !dm.muted && (dm.unread > 0 || dm.mentions > 0);
400 return (
401 <li className="group/row relative">
402 <RowMenu pinned={dm?.starred ?? false} onPin={onPin}>
403 <button
404 type="button"
405 onClick={onOpen}
406 aria-current={current ? "page" : undefined}
407 aria-busy={busy || undefined}
408 className={`${ROW} w-full text-left ${current ? "bg-raised text-fg" : unread ? "text-fg hover:bg-raised/60" : "text-muted hover:bg-raised/60 hover:text-fg"} ${busy ? "opacity-60" : ""}`}
409 >
410 <span className="relative flex w-[18px] shrink-0 justify-center">
411 <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
412 </span>
413 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
414 {agent.display_name}
415 <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>
416 </span>
417 {!g1t && (
418 <Hint label={statusLabel(agent.status)}>
419 <span className="flex size-4 shrink-0 items-center justify-center">
420 <StatusDot status={agent.status} />
421 </span>
422 </Hint>
423 )}
424 <Count entry={dm} />
425 </button>
426 </RowMenu>
427 <PinButton pinned={dm?.starred ?? false} onPin={onPin} label={agent.display_name} />
428 </li>
429 );
430}
431
432function SidebarSkeleton() {
433 return (
434 <div className="space-y-2 px-2 pt-1" aria-busy="true">
435 <Skeleton className="h-3 w-16" />
436 {[70, 52, 84, 60].map((w) => (
437 <div key={w} className="flex h-6 items-center gap-2">
438 <Skeleton className="size-4 rounded" />
439 <Skeleton className="h-3" style={{ width: `${w}%` }} />
440 </div>
441 ))}
442 <Skeleton className="mt-4 h-3 w-24" />
443 {[64, 48, 72].map((w) => (
444 <div key={w} className="flex h-6 items-center gap-2">
445 <Skeleton className="size-4 rounded-full" />
446 <Skeleton className="h-3" style={{ width: `${w}%` }} />
447 </div>
448 ))}
449 </div>
450 );
451}