Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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