Skip to content

Commit

Chat's sidebar puts direct messages, with people and agents alike, right after the channels, folds its sections and remembers them, lists the agents you haven't talked to last, and a channel's whole row in Browse channels opens it

syntaqxcommitted Parentbee5a81Browse files
5 files+241−1130/5 viewed
+22−2
129129 ### Join, leave and browse
130130
131131 - **Browse channels** lists every public channel, and the private ones
132− you're in, at `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and
133− choose **Join** to post in it and see it in your sidebar. A private channel
132+ you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the end of the
133+ sidebar's **Channels**. Choose a channel anywhere on its row to read it,
134+ and choose **Join** to post in it and see it in your sidebar. A private channel
134135 you're not in never appears.
135136 - **Archived**, on the same page, lists archived channels.
136137 - You can leave a channel, and join a public one again at any time.
160161
161162 Both are yours alone; nobody else's sidebar changes.
162163
164+## The sidebar
165+
166+Chat's sidebar lists, from the top:
167+
168+| Section | What's in it |
169+| --- | --- |
170+| **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. |
171+| **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. |
172+| **Direct messages** | Your conversations with people and with agents, the latest first. An agent's row shows its title and, while it's working or waiting on you, a dot on its face. The pencil starts a new message. |
173+| **Agents** | The workspace's agents you haven't talked to yet, @g1t first. Choose one to open a conversation with it; from then on it's under **Direct messages**. **All agents** opens Agents. Owners hire one with **+**. |
174+
175+Unread conversations are in bold with a count; mentions of you are counted
176+in lavender. **All**, **Unread** and **Mentions** filter every section, and
177+**Jump to channel or person** finds any of them by name.
178+
179+Choose a section's name to fold it. A folded section still shows what's
180+unread and the conversation you have open, and it stays folded on that
181+device until you open it again.
182+
163183 ## Direct messages
164184
165185 A direct message is a private conversation between you and up to eight
+199−101
1−import { BellOff, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
1+import { BellOff, Bot, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
22 import { ContextMenu } from "radix-ui";
33 import { type ReactNode, useEffect, useState } from "react";
44 import { Link, NavLink, useFetcher, useLocation, useNavigate } from "react-router";
4040 }
4141
4242 /**
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.
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.
4850 */
4951 export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
5052 const data = useChatData();
8082 const loading = !data;
8183 const owner = data?.role === "owner";
8284
83− // Every agent, g1t first; filtered as the conversations are.
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.
8488 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);
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;
92107 });
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+ };
93112
94113 /** The direct message with an agent: opened, or made on first use. */
95114 const openAgent = async (agent: WorkspaceAgent, pin = false) => {
177196 <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>
178197 ) : (
179198 <>
180− {pinned.length > 0 && <Section title="Pinned">{pinned.map(conversation)}</Section>}
181− <Section title="Channels" action={<CreateChannelButton slug={slug} />}>
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+ >
182212 {channels.map(conversation)}
183213 {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+ )}
184219 </Section>
185220 <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− }
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)}
200227 >
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} />}>
218228 {dms.map(conversation)}
219− {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
229+ {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate or an agent.</Empty>}
220230 </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+ )}
221267 {shown.length === 0 && agents.length === 0 && (filter !== "all" || query) && (
222268 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
223269 )}
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>
234270 </>
235271 )}
236272 </nav>
238274 );
239275 }
240276
241−function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
242− const [open, setOpen] = useState(true);
277+const 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. */
280+function 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+
289+function 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+ */
302+function 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}`;
243321 return (
244322 <section className="mb-3">
245323 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
246324 <button
247325 type="button"
248− onClick={() => setOpen(!open)}
326+ onClick={() => onFold(id)}
249327 aria-expanded={open}
250− className="flex items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
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]"
251330 >
252− <ChevronDown size={12} className={`transition-transform ${open ? "" : "-rotate-90"}`} />
331+ <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
253332 {title}
254333 </button>
255− <span className="opacity-70 transition-opacity group-hover/head:opacity-100">{action}</span>
334+ <span className="opacity-70 transition-opacity group-hover/head:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">{action}</span>
256335 </div>
257− {open && <ul className="space-y-px">{children}</ul>}
336+ {(open || (keep && keep.length > 0)) && (
337+ <ul id={list} className="space-y-px">
338+ {open ? children : keep}
339+ </ul>
340+ )}
258341 </section>
259342 );
260343 }
261344
345+/** A quieter row at the end of a section: somewhere to go rather than a conversation. */
346+function 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+
262367 function Empty({ children }: { children: ReactNode }) {
263368 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
264369 }
342447 const agentId = agentDmOf(entry);
343448 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
344449 const other = entry.others[0];
450+ const g1t = agent ? isOrchestrator(agent) : false;
345451 const icon = agent ? (
346− <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={18} />
452+ <AgentFace agent={agent} g1t={g1t} />
347453 ) : entry.channel.kind === "dm" && other ? (
348454 // One person: their dot, cut out of the sidebar behind it.
349455 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
363469 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"
364470 }`}
365471 >
366− <span className={`flex w-[18px] shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
472+ <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
367473 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
368− {entry.title}
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>}
369476 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
370477 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
371478 )}
379486 );
380487 }
381488
382−/** One agent: its face, its name and status, and unread from your conversation with it. */
383−function 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−}) {
489+/** An agent's face in a row; any but g1t carries its status, cut out of the sidebar behind it. */
490+function 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. */
504+function AgentRow({ agent, busy, onOpen, onPin }: { agent: WorkspaceAgent; busy: boolean; onOpen: () => void; onPin: () => void }) {
398505 const g1t = isOrchestrator(agent);
399− const unread = dm != null && !dm.muted && (dm.unread > 0 || dm.mentions > 0);
400506 return (
401507 <li className="group/row relative">
402− <RowMenu pinned={dm?.starred ?? false} onPin={onPin}>
508+ <RowMenu pinned={false} onPin={onPin}>
403509 <button
404510 type="button"
405511 onClick={onOpen}
406− aria-current={current ? "page" : undefined}
407512 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" : ""}`}
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" : ""}`}
409515 >
410− <span className="relative flex w-[18px] shrink-0 justify-center">
411− <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
516+ <span className="flex w-4.5 shrink-0 justify-center">
517+ <AgentFace agent={agent} g1t={g1t} />
412518 </span>
413− <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
519+ <span className="min-w-0 grow truncate">
414520 {agent.display_name}
415− <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>
521+ <span className="ml-1.5 text-[0.6875rem] text-faint">{g1t ? "orchestrator" : agent.title}</span>
416522 </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} />
425523 </button>
426524 </RowMenu>
427− <PinButton pinned={dm?.starred ?? false} onPin={onPin} label={agent.display_name} />
525+ <PinButton pinned={false} onPin={onPin} label={agent.display_name} />
428526 </li>
429527 );
430528 }
+2−2
153153 const { pinned, channels, agentDms, dms } = sections(all);
154154 assert.deepEqual(pinned.map((e) => e.title), ["general"]);
155155 assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]);
156− // A direct message with one agent is the agent's, under Agents.
157− assert.deepEqual(dms.map((e) => e.title), ["ana"]);
156+ // Direct messages, the latest first: people and agents alike.
157+ assert.deepEqual(dms.map((e) => e.title), ["ana", "reviewer"]);
158158 assert.equal(agentDms.get("a1")?.title, "reviewer");
159159 assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "reviewer", "noisy"]);
160160 assert.deepEqual(filterEntries(all, "mentions", "").map((e) => e.title), ["reviewer"]);
+6−5
296296 });
297297 }
298298
299−/** Whether a conversation is a direct message with one agent: it is listed under Agents, not Direct messages. */
299+/** The agent a conversation is a direct message with, when it is with one agent alone; else null. */
300300 export function agentDmOf(entry: ChatSidebarEntry): string | null {
301301 const only = entry.others.length === 1 ? entry.others[0]! : null;
302302 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
304304
305305 /**
306306 * The sidebar's sections, in the order they show: what is pinned (the
307− * `starred` preference), channels, the direct message with each agent (by
308− * agent id, for the Agents section), and direct messages with people,
309− * groups that mix people and agents included.
307+ * `starred` preference), channels, and direct messages, the latest first,
308+ * with people and agents alike (an agent is a member you message like
309+ * anyone). `agentDms` finds the direct message with each agent, by agent
310+ * id, so the Agents section lists only the agents you have not talked to.
310311 */
311312 export function sections(entries: ChatSidebarEntry[]): {
312313 pinned: ChatSidebarEntry[];
327328 pinned: entries.filter((e) => e.starred).sort(byName),
328329 channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
329330 agentDms,
330− dms: entries.filter((e) => !e.starred && e.channel.kind === "dm" && !agentDmOf(e)).sort(byRecent),
331+ dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort(byRecent),
331332 };
332333 }
333334
+12−3
8585 {shown.map((channel) => {
8686 const isIn = joined.has(channel.id);
8787 return (
88− <li key={channel.id} className="flex items-center gap-3 px-4 py-3">
88+ // The whole row opens the channel: its name's link reaches over
89+ // it (`after:inset-0`), and Join sits above that, so nothing
90+ // interactive nests inside anything else.
91+ <li
92+ key={channel.id}
93+ className="group/card relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/50 has-[a:focus-visible]:bg-raised/50 has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-accent has-[a:focus-visible]:ring-inset"
94+ >
8995 {channel.private ? (
9096 <Lock size={15} className="shrink-0 text-faint" aria-label="Private" />
9197 ) : (
9298 <Hash size={16} className="shrink-0 text-faint" aria-label="Public" />
9399 )}
94100 <div className="min-w-0 grow">
95− <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent">
101+ <Link
102+ to={channelPath(loaderData.slug, channel)}
103+ className="font-medium outline-none group-hover/card:text-accent after:absolute after:inset-0 after:content-['']"
104+ >
96105 {channel.name}
97106 </Link>
98107 {channel.private && <span className="ml-2 text-xs text-faint">Private</span>}
112121 setJoining(null);
113122 if (done.ok) navigate(channelPath(loaderData.slug, channel));
114123 }}
115− className="h-8 rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised disabled:opacity-50"
124+ className="relative z-10 h-8 rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised disabled:opacity-50"
116125 >
117126 {joining === channel.id ? "Joining…" : "Join"}
118127 </button>