Skip to content
551 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";
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.14import { Button } from "../ui/button";
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)15import { Hint } from "../ui/hint";
Chat and workspace agents: channels, DMs and named agents you talk to16import { 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)17import { type ChatFilter, agentDmOf, channelPath, filterEntries, sections } from "../../lib/chat";
18import { feedConnected } from "../../lib/notify-client";
19import { FALLBACK_REFRESH_MS } from "../../lib/notify-store";
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-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 to26
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)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}
Chat and workspace agents: channels, DMs and named agents you talk to42
43/**
Merge branch 'chat-sidebar' into fast-push44 * Chat mode's sidebar: what you pinned, the channels (and a way to browse
45 * the rest), then direct messages, the latest first, with people and
46 * agents alike. Last, the agents you have not talked to yet, g1t first:
47 * one click opens a conversation with any of them. Sections fold, and
48 * stay folded on this device. All, Unread and Mentions filter every
49 * section. It refreshes itself while the tab is shown, so unread counts
50 * move without a reload.
Chat and workspace agents: channels, DMs and named agents you talk to51 */
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)52export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
Chat and workspace agents: channels, DMs and named agents you talk to53 const data = useChatData();
54 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)55 const send = useChatSend(slug);
Chat and workspace agents: channels, DMs and named agents you talk to56 const [filter, setFilter] = useState<ChatFilter>("all");
57 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)58 const [opening, setOpening] = useState<string | null>(null);
Chat and workspace agents: channels, DMs and named agents you talk to59 const navigate = useNavigate();
60 const { pathname } = useLocation();
61
62 useEffect(() => {
63 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)64 // 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 to65 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)66 if (document.visibilityState === "visible" && !feedConnected()) refresh();
Chat and workspace agents: channels, DMs and named agents you talk to67 };
68 const timer = setInterval(tick, REFRESH_MS);
69 document.addEventListener("visibilitychange", tick);
70 return () => {
71 clearInterval(timer);
72 document.removeEventListener("visibilitychange", tick);
73 };
74 }, [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)75
76 // Pins show at once and go back if the service refuses.
77 const [pins, setPins] = useState<Map<string, boolean>>(new Map());
78 useEffect(() => setPins(new Map()), [sidebar]);
79 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 to80 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)81 const { pinned, channels, dms } = sections(shown);
82 const { agentDms } = sections(entries);
Chat and workspace agents: channels, DMs and named agents you talk to83 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)84 const owner = data?.role === "owner";
85
Merge branch 'chat-sidebar' into fast-push86 // The agents you have not talked to yet, g1t first: one click opens a
87 // conversation, which then lists under Direct messages like anyone's.
88 // 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)89 const q = query.trim().toLowerCase().replace(/^@/, "");
Merge branch 'chat-sidebar' into fast-push90 const agents =
91 filter !== "all"
92 ? []
93 : [...(data?.agents ?? [])]
94 .filter((agent) => !agentDms.has(agent.id))
95 .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name))
96 .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
97
98 // Folded sections, remembered per workspace on this device. A folded
99 // section still shows what is unread and the conversation open now.
100 const [folded, setFolded] = useState<ReadonlySet<string>>(new Set());
101 useEffect(() => setFolded(readFolded(slug)), [slug]);
102 const fold = (id: string) =>
103 setFolded((now) => {
104 const next = new Set(now);
105 if (!next.delete(id)) next.add(id);
106 writeFolded(slug, next);
107 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)108 });
Merge branch 'chat-sidebar' into fast-push109 const keepShown = (entry: ChatSidebarEntry) => {
110 const to = channelPath(slug, entry.channel);
111 return pathname === to || pathname.startsWith(`${to}/`) || (!entry.muted && (entry.unread > 0 || entry.mentions > 0));
112 };
Chat and workspace agents: channels, DMs and named agents you talk to113
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)114 /** The direct message with an agent: opened, or made on first use. */
115 const openAgent = async (agent: WorkspaceAgent, pin = false) => {
116 const had = agentDms.get(agent.id);
117 if (had && !pin) return navigate(channelPath(slug, had.channel));
118 setOpening(agent.id);
119 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}`] });
120 if (opened.ok && pin) await send({ intent: "preferences", channel_id: opened.value.channel.id, starred: !had?.starred });
121 setOpening(null);
122 if (opened.ok && !pin) navigate(opened.value.to);
123 if (pin) refresh();
124 };
125 const togglePin = async (entry: ChatSidebarEntry) => {
126 const next = !entry.starred;
127 setPins((now) => new Map(now).set(entry.channel.id, next));
128 const done = await send({ intent: "preferences", channel_id: entry.channel.id, starred: next });
129 if (!done.ok)
130 setPins((now) => {
131 const back = new Map(now);
132 back.delete(entry.channel.id);
133 return back;
134 });
135 };
136
137 const conversation = (entry: ChatSidebarEntry) => (
138 <ConversationRow key={entry.channel.id} entry={entry} slug={slug} pathname={pathname} agents={data?.agents ?? []} onPin={() => void togglePin(entry)} />
139 );
140
Chat and workspace agents: channels, DMs and named agents you talk to141 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 managers142 // 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.143 <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)144 {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.145 <div className="flex h-9 shrink-0 items-center justify-between pr-1 pl-3">
146 <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)147 <NewMessageButton slug={slug} />
148 </div>
149 )}
Chat and workspace agents: channels, DMs and named agents you talk to150 <div className="space-y-2.5 px-2.5 pt-3">
151 <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">
152 <Search size={14} className="shrink-0 text-faint" />
153 <input
154 value={query}
155 onChange={(event) => setQuery(event.target.value)}
156 onKeyDown={(event) => {
157 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)158 const first = [...pinned, ...channels, ...dms][0];
Chat and workspace agents: channels, DMs and named agents you talk to159 if (first) {
160 navigate(channelPath(slug, first.channel));
161 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)162 } else if (agents[0]) void openAgent(agents[0]);
Chat and workspace agents: channels, DMs and named agents you talk to163 } else if (event.key === "Escape") setQuery("");
164 }}
165 placeholder="Jump to channel or person"
166 aria-label="Jump to channel or person"
167 autoComplete="off"
168 data-1p-ignore
169 className="min-w-0 grow bg-transparent text-fg outline-none placeholder:text-faint"
170 />
171 </label>
172 <div role="radiogroup" aria-label="Show" className="grid grid-cols-3 rounded-md bg-surface p-0.5 ring-1 ring-line">
173 {(["all", "unread", "mentions"] as const).map((key) => (
174 <button
175 key={key}
176 type="button"
177 role="radio"
178 aria-checked={filter === key}
179 onClick={() => setFilter(key)}
180 className={`h-6 rounded-[5px] text-xs font-medium capitalize transition-colors ${
181 filter === key ? "bg-raised text-fg shadow-sm ring-1 ring-line-strong" : "text-muted hover:text-fg"
182 }`}
183 >
184 {key}
185 </button>
186 ))}
187 </div>
188 </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)189 {/* A phone's list (no heading): room at the end so the compose button never covers the last row. */}
190 <nav
191 aria-label="Conversations"
192 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))]"}`}
193 >
Chat and workspace agents: channels, DMs and named agents you talk to194 {loading ? (
195 <SidebarSkeleton />
196 ) : !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)197 <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 to198 ) : (
199 <>
Merge branch 'chat-sidebar' into fast-push200 {pinned.length > 0 && (
201 <Section id="pinned" title="Pinned" folded={folded} onFold={fold} keep={pinned.filter(keepShown).map(conversation)}>
202 {pinned.map(conversation)}
203 </Section>
204 )}
205 <Section
206 id="channels"
207 title="Channels"
208 folded={folded}
209 onFold={fold}
210 action={<CreateChannelButton slug={slug} />}
211 keep={channels.filter(keepShown).map(conversation)}
212 >
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)213 {channels.map(conversation)}
Chat and workspace agents: channels, DMs and named agents you talk to214 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
Merge branch 'chat-sidebar' into fast-push215 {filter === "all" && !query && (
216 <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join">
217 Browse channels
218 </ExtraRow>
219 )}
Chat and workspace agents: channels, DMs and named agents you talk to220 </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)221 <Section
Merge branch 'chat-sidebar' into fast-push222 id="dms"
223 title="Direct messages"
224 folded={folded}
225 onFold={fold}
226 action={<NewMessageButton slug={slug} />}
227 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)228 >
229 {dms.map(conversation)}
Merge branch 'chat-sidebar' into fast-push230 {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)231 </Section>
Merge branch 'chat-sidebar' into fast-push232 {(agents.length > 0 || (filter === "all" && !query)) && (
233 <Section
234 id="agents"
235 title="Agents"
236 folded={folded}
237 onFold={fold}
238 action={
239 owner ? (
240 <Hint label="New agent">
241 <Link
242 to={`/${slug}/-/agents/new`}
243 aria-label="New agent"
244 className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
245 >
246 <Plus size={14} />
247 </Link>
248 </Hint>
249 ) : null
250 }
251 >
252 {agents.map((agent) => (
253 <AgentRow
254 key={agent.id}
255 agent={agent}
256 busy={opening === agent.id}
257 onOpen={() => void openAgent(agent)}
258 onPin={() => void openAgent(agent, true)}
259 />
260 ))}
261 {filter === "all" && !query && (
262 <ExtraRow to={`/${slug}/-/agents`} icon={<Bot size={15} />}>
263 {(data?.agents ?? []).length === 0 ? (owner ? "Hire an agent" : "No agents yet") : "All agents"}
264 </ExtraRow>
265 )}
266 </Section>
267 )}
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)268 {shown.length === 0 && agents.length === 0 && (filter !== "all" || query) && (
269 <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 to270 )}
271 </>
272 )}
273 </nav>
274 </div>
275 );
276}
277
Merge branch 'chat-sidebar' into fast-push278const FOLDED_KEY = (slug: string) => `g1t:chat-folded:${slug}`;
279
280/** The sections folded in this workspace, as last left; none if storage is not there. */
281function readFolded(slug: string): ReadonlySet<string> {
282 try {
283 const saved = JSON.parse(localStorage.getItem(FOLDED_KEY(slug)) ?? "[]");
284 return new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === "string") : []);
285 } catch {
286 return new Set();
287 }
288}
289
290function writeFolded(slug: string, folded: ReadonlySet<string>) {
291 try {
292 if (folded.size === 0) localStorage.removeItem(FOLDED_KEY(slug));
293 else localStorage.setItem(FOLDED_KEY(slug), JSON.stringify([...folded]));
294 } catch {
295 // Private windows and blocked storage: folding still works until a reload.
296 }
297}
298
299/**
300 * A titled part of the sidebar that folds away. Folded, it keeps `keep`
301 * showing: the unread conversations and the open one.
302 */
303function Section({
304 id,
305 title,
306 folded,
307 onFold,
308 action,
309 keep,
310 children,
311}: {
312 id: string;
313 title: string;
314 folded: ReadonlySet<string>;
315 onFold: (id: string) => void;
316 action?: ReactNode;
317 keep?: ReactNode[];
318 children: ReactNode;
319}) {
320 const open = !folded.has(id);
321 const list = `chat-section-${id}`;
Chat and workspace agents: channels, DMs and named agents you talk to322 return (
323 <section className="mb-3">
324 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
325 <button
326 type="button"
Merge branch 'chat-sidebar' into fast-push327 onClick={() => onFold(id)}
Chat and workspace agents: channels, DMs and named agents you talk to328 aria-expanded={open}
Merge branch 'chat-sidebar' into fast-push329 aria-controls={list}
330 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 to331 >
Merge branch 'chat-sidebar' into fast-push332 <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
Chat and workspace agents: channels, DMs and named agents you talk to333 {title}
334 </button>
Merge branch 'chat-sidebar' into fast-push335 <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 to336 </div>
Merge branch 'chat-sidebar' into fast-push337 {(open || (keep && keep.length > 0)) && (
338 <ul id={list} className="space-y-px">
339 {open ? children : keep}
340 </ul>
341 )}
Chat and workspace agents: channels, DMs and named agents you talk to342 </section>
343 );
344}
345
Merge branch 'chat-sidebar' into fast-push346/** A quieter row at the end of a section: somewhere to go rather than a conversation. */
347function ExtraRow({ to, icon, count, countLabel, children }: { to: string; icon: ReactNode; count?: number; countLabel?: string; children: ReactNode }) {
348 return (
349 <li>
350 <NavLink
351 to={to}
352 end
353 className={({ isActive }) => `${ROW} ${isActive ? "bg-raised text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"}`}
354 >
355 <span className="flex w-4.5 shrink-0 justify-center">{icon}</span>
356 <span className="min-w-0 grow truncate">{children}</span>
357 {count != null && (
358 <span className="shrink-0 text-xs tabular-nums">
359 {count}
360 {countLabel && <span className="sr-only"> {countLabel}</span>}
361 </span>
362 )}
363 </NavLink>
364 </li>
365 );
366}
367
Chat and workspace agents: channels, DMs and named agents you talk to368function Empty({ children }: { children: ReactNode }) {
369 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
370}
371
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)372/** Pin or unpin, on hover or focus; the same as the context menu's first item. */
373function PinButton({ pinned, onPin, label }: { pinned: boolean; onPin: () => void; label: string }) {
374 return (
375 <Hint label={pinned ? "Unpin" : "Pin to the top"}>
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.376 <Button
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)377 type="button"
378 aria-label={`${pinned ? "Unpin" : "Pin"} ${label}`}
379 onClick={(event) => {
380 event.preventDefault();
381 event.stopPropagation();
382 onPin();
383 }}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.384 variant="secondary" size="icon"
385 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"
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)386 >
387 {pinned ? <PinOff size={13} /> : <Pin size={13} />}
The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after.388 </Button>
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)389 </Hint>
390 );
391}
392
393/** Right-click on a row: pin it, or open it. */
394function RowMenu({ pinned, onPin, to, children }: { pinned: boolean; onPin: () => void; to?: string; children: ReactNode }) {
395 const navigate = useNavigate();
396 const item =
397 "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";
398 return (
399 <ContextMenu.Root>
400 <ContextMenu.Trigger asChild>{children}</ContextMenu.Trigger>
401 <ContextMenu.Portal>
402 <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">
403 <ContextMenu.Item className={item} onSelect={onPin}>
404 {pinned ? <PinOff /> : <Pin />}
405 {pinned ? "Unpin" : "Pin to the top"}
406 </ContextMenu.Item>
407 {to && (
408 <ContextMenu.Item className={item} onSelect={() => navigate(to)}>
409 <Hash />
410 Open
411 </ContextMenu.Item>
412 )}
413 </ContextMenu.Content>
414 </ContextMenu.Portal>
415 </ContextMenu.Root>
416 );
417}
418
419/**
420 * Reads a conversation ahead, on hover, focus or the start of a touch, so
421 * opening it draws at once (its route's `clientLoader` keeps it).
422 */
423function usePrefetchConversation(to: string) {
424 const fetcher = useFetcher();
425 const load = () => {
426 if (fetcher.state === "idle" && fetcher.data === undefined && !conversationCache.has(to)) fetcher.load(to);
427 };
428 return { onPointerEnter: load, onFocus: load, onTouchStart: load };
429}
430
431/** One channel or direct message: its mark, its name, its unread count, and a pin. */
432function ConversationRow({
433 entry,
434 slug,
435 pathname,
436 agents,
437 onPin,
438}: {
439 entry: ChatSidebarEntry;
440 slug: string;
441 pathname: string;
442 agents: WorkspaceAgent[];
443 onPin: () => void;
444}) {
Chat and workspace agents: channels, DMs and named agents you talk to445 const to = channelPath(slug, entry.channel);
446 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)447 const prefetch = usePrefetchConversation(to);
Chat and workspace agents: channels, DMs and named agents you talk to448 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)449 const agentId = agentDmOf(entry);
450 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
Chat and workspace agents: channels, DMs and named agents you talk to451 const other = entry.others[0];
Merge branch 'chat-sidebar' into fast-push452 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)453 const icon = agent ? (
Merge branch 'chat-sidebar' into fast-push454 <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)455 ) : 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 managers456 // One person: their dot, cut out of the sidebar behind it.
457 <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)458 ) : entry.channel.private ? (
459 <Lock size={14} />
460 ) : (
461 <Hash size={15} />
462 );
463 return (
464 <li className="group/row relative">
465 <RowMenu pinned={entry.starred} onPin={onPin} to={to}>
466 <NavLink
467 to={to}
468 {...prefetch}
469 aria-current={current ? "page" : undefined}
470 className={`${ROW} ${
471 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"
472 }`}
473 >
Merge branch 'chat-sidebar' into fast-push474 <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 managers475 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
Merge branch 'chat-sidebar' into fast-push476 {agent ? agent.display_name : entry.title}
477 {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 managers478 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
479 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
480 )}
481 </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)482 {entry.muted && <BellOff size={12} className="shrink-0 text-faint" aria-label="Muted" />}
483 <Count entry={entry} />
484 </NavLink>
485 </RowMenu>
486 <PinButton pinned={entry.starred} onPin={onPin} label={entry.title} />
487 </li>
488 );
489}
490
Merge branch 'chat-sidebar' into fast-push491/** An agent's face in a row; any but g1t carries its status, cut out of the sidebar behind it. */
492function AgentFace({ agent, g1t }: { agent: WorkspaceAgent; g1t: boolean }) {
493 return (
494 <span className="relative inline-flex shrink-0">
495 <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
496 {!g1t && agent.status !== "idle" && (
497 <span className="absolute -right-1 -bottom-1 flex rounded-full p-[2px]" style={{ background: "var(--chat-sidebar-bg)" }}>
498 <StatusDot status={agent.status} className="size-[7px] shadow-none" />
499 </span>
500 )}
501 </span>
502 );
503}
504
505/** An agent you have not talked to yet: its face, name and title. Opening it starts your conversation. */
506function 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)507 const g1t = isOrchestrator(agent);
Chat and workspace agents: channels, DMs and named agents you talk to508 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)509 <li className="group/row relative">
Merge branch 'chat-sidebar' into fast-push510 <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)511 <button
512 type="button"
513 onClick={onOpen}
514 aria-busy={busy || undefined}
Merge branch 'chat-sidebar' into fast-push515 aria-label={`Message ${agent.display_name}, ${g1t ? "orchestrator" : agent.title}${g1t ? "" : `, ${statusLabel(agent.status).toLowerCase()}`}`}
516 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)517 >
Merge branch 'chat-sidebar' into fast-push518 <span className="flex w-4.5 shrink-0 justify-center">
519 <AgentFace agent={agent} g1t={g1t} />
Chat and workspace agents: channels, DMs and named agents you talk to520 </span>
Merge branch 'chat-sidebar' into fast-push521 <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)522 {agent.display_name}
Merge branch 'chat-sidebar' into fast-push523 <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)524 </span>
525 </button>
526 </RowMenu>
Merge branch 'chat-sidebar' into fast-push527 <PinButton pinned={false} onPin={onPin} label={agent.display_name} />
Chat and workspace agents: channels, DMs and named agents you talk to528 </li>
529 );
530}
531
532function SidebarSkeleton() {
533 return (
534 <div className="space-y-2 px-2 pt-1" aria-busy="true">
535 <Skeleton className="h-3 w-16" />
536 {[70, 52, 84, 60].map((w) => (
537 <div key={w} className="flex h-6 items-center gap-2">
538 <Skeleton className="size-4 rounded" />
539 <Skeleton className="h-3" style={{ width: `${w}%` }} />
540 </div>
541 ))}
542 <Skeleton className="mt-4 h-3 w-24" />
543 {[64, 48, 72].map((w) => (
544 <div key={w} className="flex h-6 items-center gap-2">
545 <Skeleton className="size-4 rounded-full" />
546 <Skeleton className="h-3" style={{ width: `${w}%` }} />
547 </div>
548 ))}
549 </div>
550 );
551}

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