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-push | 1 | import { 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) | 2 | import { ContextMenu } from "radix-ui"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 3 | import { 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) | 4 | import { Link, NavLink, useFetcher, useLocation, useNavigate } from "react-router"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 5 | |
| 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) | 6 | import type { ChatSidebarEntry, WorkspaceAgent } from "@g1t/contracts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 7 | |
| 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) | 8 | import { conversationCache } from "./conversation-cache"; |
| 9 | import { CreateChannelButton, NewMessageButton, useChatData, useChatSend, useChatSidebar } from "./actions"; | |
| 10 | import { MemberAvatar, StatusDot, statusLabel } from "./marks"; | |
| 11 | import { 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 managers | 12 | import { 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) | 13 | import { isOrchestrator } from "../orchestrator"; |
| 14 | import { Hint } from "../ui/hint"; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 15 | import { 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) | 16 | import { type ChatFilter, agentDmOf, channelPath, filterEntries, sections } from "../../lib/chat"; |
| 17 | import { feedConnected } from "../../lib/notify-client"; | |
| 18 | import { 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. */ | |
| 21 | const REFRESH_MS = FALLBACK_REFRESH_MS; | |
| 22 | ||
| 23 | const 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 to | 25 | |
| 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. */ |
| 27 | function 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 to | 41 | |
| 42 | /** | |
| Merge branch 'chat-sidebar' into fast-push | 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. | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 50 | */ |
| 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) | 51 | export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) { |
| Chat and workspace agents: channels, DMs and named agents you talk to | 52 | 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 to | 55 | 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 to | 58 | 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 to | 64 | 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 to | 66 | }; |
| 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 to | 79 | 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 to | 82 | 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-push | 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. | |
| 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-push | 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; | |
| 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-push | 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 | }; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 112 | |
| 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 to | 140 | 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 managers | 141 | // 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 to | 149 | <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 to | 158 | 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 to | 162 | } 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 to | 193 | {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't answer. Your conversations will show here once it does.</p> |
| Chat and workspace agents: channels, DMs and named agents you talk to | 197 | ) : ( |
| 198 | <> | |
| Merge branch 'chat-sidebar' into fast-push | 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 | > | |
| 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 to | 213 | {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>} |
| Merge branch 'chat-sidebar' into fast-push | 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 | )} | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 219 | </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-push | 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)} | |
| 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-push | 229 | {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-push | 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 | )} | |
| 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 to | 269 | )} |
| 270 | </> | |
| 271 | )} | |
| 272 | </nav> | |
| 273 | </div> | |
| 274 | ); | |
| 275 | } | |
| 276 | ||
| Merge branch 'chat-sidebar' into fast-push | 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}`; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 321 | 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-push | 326 | onClick={() => onFold(id)} |
| Chat and workspace agents: channels, DMs and named agents you talk to | 327 | aria-expanded={open} |
| Merge branch 'chat-sidebar' into fast-push | 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]" | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 330 | > |
| Merge branch 'chat-sidebar' into fast-push | 331 | <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} /> |
| Chat and workspace agents: channels, DMs and named agents you talk to | 332 | {title} |
| 333 | </button> | |
| Merge branch 'chat-sidebar' into fast-push | 334 | <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 to | 335 | </div> |
| Merge branch 'chat-sidebar' into fast-push | 336 | {(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 to | 341 | </section> |
| 342 | ); | |
| 343 | } | |
| 344 | ||
| Merge branch 'chat-sidebar' into fast-push | 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 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 367 | function 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. */ |
| 372 | function 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. */ | |
| 392 | function 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 | */ | |
| 421 | function 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. */ | |
| 430 | function 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 to | 443 | 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 to | 446 | 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 to | 449 | const other = entry.others[0]; |
| Merge branch 'chat-sidebar' into fast-push | 450 | 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-push | 452 | <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 managers | 454 | // 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-push | 472 | <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 managers | 473 | <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}> |
| Merge branch 'chat-sidebar' into fast-push | 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>} | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 476 | {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-push | 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 }) { | |
| 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 to | 506 | 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-push | 508 | <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-push | 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" : ""}`} | |
| 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-push | 516 | <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 to | 518 | </span> |
| Merge branch 'chat-sidebar' into fast-push | 519 | <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-push | 521 | <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-push | 525 | <PinButton pinned={false} onPin={onPin} label={agent.display_name} /> |
| Chat and workspace agents: channels, DMs and named agents you talk to | 526 | </li> |
| 527 | ); | |
| 528 | } | |
| 529 | ||
| 530 | function 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.