Skip to content
549 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'chat-sidebar' into fast-push1import { BellOff, Bot, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)2import { ContextMenu } from "radix-ui";
Chat and workspace agents: channels, DMs and named agents you talk to3import { type ReactNode, useEffect, useState } from "react";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)4import { Link, NavLink, useFetcher, useLocation, useNavigate } from "react-router";
Chat and workspace agents: channels, DMs and named agents you talk to5
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)6import type { ChatSidebarEntry, WorkspaceAgent } from "@g1t/contracts";
Chat and workspace agents: channels, DMs and named agents you talk to7
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)8import { conversationCache } from "./conversation-cache";
9import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions";
10import { MemberAvatar, StatusDot, statusLabel } from "./marks";
11import { AgentAvatar } from "../agent-avatar";
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers12import { PersonStatusEmoji } from "../presence";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)13import { isOrchestrator } from "../orchestrator";
14import { Hint } from "../ui/hint";
Chat and workspace agents: channels, DMs and named agents you talk to15import { Skeleton } from "../ui/skeleton";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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";
Chat and workspace agents: channels, DMs and named agents you talk to25
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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}
Chat and workspace agents: channels, DMs and named agents you talk to41
42/**
Merge branch 'chat-sidebar' into fast-push43 * 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.
Chat and workspace agents: channels, DMs and named agents you talk to50 */
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)51export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
Chat and workspace agents: channels, DMs and named agents you talk to52 const data = useChatData();
53 const { sidebar, refresh } = useChatSidebar();
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)54 const send = useChatSend(slug);
Chat and workspace agents: channels, DMs and named agents you talk to55 const [filter, setFilter] = useState<ChatFilter>("all");
56 const [query, setQuery] = useState("");
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)57 const [opening, setOpening] = useState<string | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to58 const navigate = useNavigate();
59 const { pathname } = useLocation();
60
61 useEffect(() => {
62 if (!data) return;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)63 // Notify: counts arrive live over the feed socket; this asks again only while it is down.
Chat and workspace agents: channels, DMs and named agents you talk to64 const tick = () => {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)65 if (document.visibilityState === "visible" && !feedConnected()) refresh();
Chat and workspace agents: channels, DMs and named agents you talk to66 };
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]);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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));
Chat and workspace agents: channels, DMs and named agents you talk to79 const shown = filterEntries(entries, filter, query);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)80 const { pinned, channels, dms } = sections(shown);
81 const { agentDms } = sections(entries);
Chat and workspace agents: channels, DMs and named agents you talk to82 const loading = !data;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)83 const owner = data?.role === "owner";
84
Merge branch 'chat-sidebar' into fast-push85 // 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.
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)88 const q = query.trim().toLowerCase().replace(/^@/, "");
Merge branch 'chat-sidebar' into fast-push89 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;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)107 });
Merge branch 'chat-sidebar' into fast-push108 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 };
Chat and workspace agents: channels, DMs and named agents you talk to112
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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
Chat and workspace agents: channels, DMs and named agents you talk to140 return (
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers141 // The colour behind it, for the dots cut out of people's avatars.
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.142 <div className="flex h-full flex-col [--chat-sidebar-bg:var(--color-shell)]">
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)143 {heading && (
g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.144 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
145 <h2 className="text-xs font-medium text-faint">Chat</h2>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)146 <NewMessageButton slug={slug} />
147 </div>
148 )}
Chat and workspace agents: channels, DMs and named agents you talk to149 <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") {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)157 const first = [...pinned, ...channels, ...dms][0];
Chat and workspace agents: channels, DMs and named agents you talk to158 if (first) {
159 navigate(channelPath(slug, first.channel));
160 setQuery("");
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)161 } else if (agents[0]) void openAgent(agents[0]);
Chat and workspace agents: channels, DMs and named agents you talk to162 } 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>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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 >
Chat and workspace agents: channels, DMs and named agents you talk to193 {loading ? (
194 <SidebarSkeleton />
195 ) : !sidebar ? (
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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>
Chat and workspace agents: channels, DMs and named agents you talk to197 ) : (
198 <>
Merge branch 'chat-sidebar' into fast-push199 {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 >
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)212 {channels.map(conversation)}
Chat and workspace agents: channels, DMs and named agents you talk to213 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
Merge branch 'chat-sidebar' into fast-push214 {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 )}
Chat and workspace agents: channels, DMs and named agents you talk to219 </Section>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)220 <Section
Merge branch 'chat-sidebar' into fast-push221 id="dms"
222 title="Direct messages"
223 folded={folded}
224 onFold={fold}
225 action={<NewMessageButton slug={slug} />}
226 keep={dms.filter(keepShown).map(conversation)}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)227 >
228 {dms.map(conversation)}
Merge branch 'chat-sidebar' into fast-push229 {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate or an agent.</Empty>}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)230 </Section>
Merge branch 'chat-sidebar' into fast-push231 {(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 )}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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>
Chat and workspace agents: channels, DMs and named agents you talk to269 )}
270 </>
271 )}
272 </nav>
273 </div>
274 );
275}
276
Merge branch 'chat-sidebar' into fast-push277const 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}`;
Chat and workspace agents: channels, DMs and named agents you talk to321 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"
Merge branch 'chat-sidebar' into fast-push326 onClick={() => onFold(id)}
Chat and workspace agents: channels, DMs and named agents you talk to327 aria-expanded={open}
Merge branch 'chat-sidebar' into fast-push328 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]"
Chat and workspace agents: channels, DMs and named agents you talk to330 >
Merge branch 'chat-sidebar' into fast-push331 <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
Chat and workspace agents: channels, DMs and named agents you talk to332 {title}
333 </button>
Merge branch 'chat-sidebar' into fast-push334 <span className="opacity-70 transition-opacity group-hover/head:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">{action}</span>
Chat and workspace agents: channels, DMs and named agents you talk to335 </div>
Merge branch 'chat-sidebar' into fast-push336 {(open || (keep && keep.length > 0)) && (
337 <ul id={list} className="space-y-px">
338 {open ? children : keep}
339 </ul>
340 )}
Chat and workspace agents: channels, DMs and named agents you talk to341 </section>
342 );
343}
344
Merge branch 'chat-sidebar' into fast-push345/** 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
Chat and workspace agents: channels, DMs and named agents you talk to367function Empty({ children }: { children: ReactNode }) {
368 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
369}
370
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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}) {
Chat and workspace agents: channels, DMs and named agents you talk to443 const to = channelPath(slug, entry.channel);
444 const current = pathname === to || pathname.startsWith(`${to}/`);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)445 const prefetch = usePrefetchConversation(to);
Chat and workspace agents: channels, DMs and named agents you talk to446 const unread = !entry.muted && (entry.unread > 0 || entry.mentions > 0);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)447 const agentId = agentDmOf(entry);
448 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
Chat and workspace agents: channels, DMs and named agents you talk to449 const other = entry.others[0];
Merge branch 'chat-sidebar' into fast-push450 const g1t = agent ? isOrchestrator(agent) : false;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)451 const icon = agent ? (
Merge branch 'chat-sidebar' into fast-push452 <AgentFace agent={agent} g1t={g1t} />
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)453 ) : entry.channel.kind === "dm" && other ? (
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers454 // 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)" />
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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 >
Merge branch 'chat-sidebar' into fast-push472 <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers473 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
Merge branch 'chat-sidebar' into fast-push474 {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>}
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers476 {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>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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
Merge branch 'chat-sidebar' into fast-push489/** 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 }) {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)505 const g1t = isOrchestrator(agent);
Chat and workspace agents: channels, DMs and named agents you talk to506 return (
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)507 <li className="group/row relative">
Merge branch 'chat-sidebar' into fast-push508 <RowMenu pinned={false} onPin={onPin}>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)509 <button
510 type="button"
511 onClick={onOpen}
512 aria-busy={busy || undefined}
Merge branch 'chat-sidebar' into fast-push513 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" : ""}`}
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)515 >
Merge branch 'chat-sidebar' into fast-push516 <span className="flex w-4.5 shrink-0 justify-center">
517 <AgentFace agent={agent} g1t={g1t} />
Chat and workspace agents: channels, DMs and named agents you talk to518 </span>
Merge branch 'chat-sidebar' into fast-push519 <span className="min-w-0 grow truncate">
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)520 {agent.display_name}
Merge branch 'chat-sidebar' into fast-push521 <span className="ml-1.5 text-[0.6875rem] text-faint">{g1t ? "orchestrator" : agent.title}</span>
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)522 </span>
523 </button>
524 </RowMenu>
Merge branch 'chat-sidebar' into fast-push525 <PinButton pinned={false} onPin={onPin} label={agent.display_name} />
Chat and workspace agents: channels, DMs and named agents you talk to526 </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}

This file's history is long; its oldest lines are credited to the oldest commit read.