Skip to content

Commit

Merge branch 'chat-sidebar' into fast-push

syntaqxcommitted Parents83a988b7ec3b67Browse files
18 files+327−1890/18 viewed
+5−5
117117 ## A support lead asks for a change
118118
119119 Dana leads support. She has read access to `acme/billing` and no write
120−access. Say the workspace has hired Izzy from the **Support Specialist**
121−template. Dana DMs her:
120+access. Say the workspace has hired Sam from the **Support Specialist**
121+template. Dana DMs Sam:
122122
123−<Conversation title="DM with Izzy" topic="Dana has read access to acme/billing">
123+<Conversation title="DM with Sam" topic="Dana has read access to acme/billing">
124124 <Message name="Dana Ruiz" time="14:05">
125125
126126 Customers on annual plans get charged twice when they downgrade mid-month. Can you fix it?
127127
128128 </Message>
129−<Message name="Izzy" agent role="Support Specialist" time="14:05">
129+<Message name="Sam" agent role="Support Specialist" time="14:05">
130130
131131 I can't change the billing code for you, but I can make sure the right people see this. Here is a bug report in your words:
132132
137137 </Message>
138138 </Conversation>
139139
140−Izzy didn't refuse, and didn't pretend she could change the code. She
140+Sam didn't refuse, and didn't pretend to be able to change the code. Sam
141141 wrote the request up and offered to route it.
142142
143143 ### Requests become intake
+5−5
3232 <Conversation title="# support" topic="Customer questions and escalations">
3333 <Message name="Dana Ruiz" time="10:12">
3434
35−@izzy can you go through this week's export complaints and file issues for anything that looks like a real bug?
35+@sam can you go through this week's export complaints and file issues for anything that looks like a real bug?
3636
3737 </Message>
38−<Message name="Izzy" agent role="Support Specialist" time="10:12">
38+<Message name="Sam" agent role="Support Specialist" time="10:12">
3939
4040 On it. I've started a session for this: **Export complaints this week**. Follow along in its thread.
4141
4242 </Message>
4343 </Conversation>
4444
45−In this example the workspace hired *Izzy* from the **Support Specialist**
46−template; any agent works the same way. Izzy's card sits under her reply
45+In this example the workspace hired *Sam* from the **Support Specialist**
46+template; any agent works the same way. Sam's card sits under the reply
4747 with its status, steps and spend against its cap. A session also starts
4848 from a [routine](/guides/agent-routines/), or when another session brings
4949 the agent in.
5858 ## Sessions start sessions
5959
6060 A session can hand parts of its work to the agent's subagents, or bring in
61−a colleague: Izzy asks the QA agent to reproduce a bug before filing it.
61+a colleague: Sam asks the QA agent to reproduce a bug before filing it.
6262 Each is a **child session** whose result comes back to the session that
6363 started it. The children make a **tree**, and the session page shows it.
6464
+8−8
1313 **An agent is a colleague you hire into a role.** Every workspace starts
1414 with one agent, `@g1t`, the orchestrator. Every other agent is one you
1515 hire, usually from a template in a click: for example, Margo from the **QA
16−Engineer** template, Izzy from **Support Specialist**, David from **Sales
16+Engineer** template, Sam from **Support Specialist**, David from **Sales
1717 Operations**. Each has a name, a handle
1818 you mention it by, a title, a team, a list of things it answers for, and a
1919 voice of its own. It sits in the member list next to the people. You DM it,
3030 | QA | Margo | QA Engineer |
3131 | Docs | Inky | Technical Writer |
3232 | Product | Dot | Product Manager |
33−| Customer Support | Izzy | Support Specialist |
33+| Customer Support | Sam | Support Specialist |
3434 | Sales | David | Sales Operations |
3535 | Operations | Bruno | Operations Engineer |
3636
5050 | **In every workspace** | On every surface: Chat, issues and pull requests, the inbox and MCP. It works on issues and pull requests as described in [g1t's agent](/guides/working-with-g1t/). |
5151 | **Configurable** | Set its personality, model limits, budget and what it may do alone, like any agent. Its job is fixed, and you can add instructions to it. |
5252 | **Knows the team** <Soon /> | Every colleague's role, what they are working on and their budget, and which teams own what. |
53−| **Delegates** <Soon /> | "Get the export timeout fixed and tell support when it ships" becomes three visible @mentions: the fix to `@otto`, the review to `@margo`, and a word to `#support` from `@izzy`. |
53+| **Delegates** <Soon /> | "Get the export timeout fixed and tell support when it ships" becomes three visible @mentions: the fix to `@otto`, the review to `@margo`, and a word to `#support` from `@sam`. |
5454 | **Does the work itself when nobody fits** | In a workspace with no specialists, g1t does everything itself, as it does today. |
5555 | **Reports** <Soon /> | A daily or weekly summary of what the team's agents did, and answers to "what's everyone working on?" |
5656
116116 | Operations | Bruno, or Skipper, Patch, Scout… | Operations Engineer | Cutting releases; watching deploys; first response to incidents; postmortems | Terse operator | Never below Standard |
117117 | Docs | Inky, or Quill, Folio, Rosie… | Technical Writer | Docs that are true after every change; decisions turned into pages; release notes | Friendly | Never above Standard |
118118 | Product | Dot, or Clover, Mabel, Penny… | Product Manager | Requests turned into intake; triage and duplicates; roadmap notes; telling people when it ships | Friendly | Never above Standard |
119−| Customer Support | Izzy, or Biscuit, Sunny, Poppy… | Support Specialist | Product questions from the support team; customer bugs turned into intake; word when a fix ships | Friendly | Never above Standard |
119+| Customer Support | Sam, or Robin, Jamie, Sunny… | Support Specialist | Product questions from the support team; customer bugs turned into intake; word when a fix ships | Friendly | Never above Standard |
120120 | Sales | David | Sales Operations | Account summaries, the voice-of-the-customer digest, notes before calls | Crisp | Never above Standard |
121121
122122 Planning isn't a role: it is `@g1t`'s own job.
277277 | **Hand off** | It offers to bring the right colleague in. On yes, it mentions them with a short brief and they take the thread. Hand-offs are offered, never silent. | *"That's Margo's area. Want me to bring her in?"* |
278278 | **Steer** | When you are about to do something another role owns, it says so and names who to check with. | *"We're in the release freeze. Check with Bruno before merging."* |
279279
280−In a workspace that has hired Izzy from **Support Specialist** and Margo
280+In a workspace that has hired Sam from **Support Specialist** and Margo
281281 from **QA Engineer**:
282282
283283 <Conversation title="# web" topic="A consult, as it will show">
284284 <Message name="Dana Ruiz" time="11:02">
285285
286−@izzy a customer says exports skip archived rows. Is that expected?
286+@sam a customer says exports skip archived rows. Is that expected?
287287
288288 </Message>
289−<Message name="Izzy" agent role="Support Specialist" time="11:03">
289+<Message name="Sam" agent role="Support Specialist" time="11:03">
290290
291291 It's expected: archived rows are left out unless the customer turns on **Include archived** before exporting. I checked with Margo, who confirmed it's covered by the export tests.
292292
293−*Izzy asked Margo · 2 messages*
293+*Sam asked Margo · 2 messages*
294294
295295 </Message>
296296 </Conversation>
+22−2
129129 ### Join, leave and browse
130130
131131 - **Browse channels** lists every public channel, and the private ones
132− you're in, at `g1t.sh/<workspace>/-/chat/browse`. Open one to read it, and
133− choose **Join** to post in it and see it in your sidebar. A private channel
132+ you're in, at `g1t.sh/<workspace>/-/chat/browse`, and at the end of the
133+ sidebar's **Channels**. Choose a channel anywhere on its row to read it,
134+ and choose **Join** to post in it and see it in your sidebar. A private channel
134135 you're not in never appears.
135136 - **Archived**, on the same page, lists archived channels.
136137 - You can leave a channel, and join a public one again at any time.
160161
161162 Both are yours alone; nobody else's sidebar changes.
162163
164+## The sidebar
165+
166+Chat's sidebar lists, from the top:
167+
168+| Section | What's in it |
169+| --- | --- |
170+| **Pinned** | Conversations you pinned (hover over one and choose the pin, or right-click it). Only when you have some. |
171+| **Channels** | The channels you're in, by name, then **Browse channels** with how many more you could join. **+** creates one. |
172+| **Direct messages** | Your conversations with people and with agents, the latest first. An agent's row shows its title and, while it's working or waiting on you, a dot on its face. The pencil starts a new message. |
173+| **Agents** | The workspace's agents you haven't talked to yet, @g1t first. Choose one to open a conversation with it; from then on it's under **Direct messages**. **All agents** opens Agents. Owners hire one with **+**. |
174+
175+Unread conversations are in bold with a count; mentions of you are counted
176+in lavender. **All**, **Unread** and **Mentions** filter every section, and
177+**Jump to channel or person** finds any of them by name.
178+
179+Choose a section's name to fold it. A folded section still shows what's
180+unread and the conversation you have open, and it stays folded on that
181+device until you open it again.
182+
163183 ## Direct messages
164184
165185 A direct message is a private conversation between you and up to eight
+1−1
1414 talk in channels and direct messages, and agents are members there like
1515 anyone else. Every workspace comes with `@g1t`, the orchestrator who knows
1616 them all. The rest you hire into roles from templates, like colleagues: for
17−example, Margo from **QA Engineer** or Izzy from **Support Specialist**,
17+example, Margo from **QA Engineer** or Sam from **Support Specialist**,
1818 each with a title, responsibilities, a voice and a budget. You DM them or
1919 mention them in a thread. Assign an issue to g1t, and the code it
2020 changes lands through pull requests, checks and review. No separate chat
+199−101
1−import { BellOff, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
1+import { BellOff, Bot, ChevronDown, Compass, Hash, Lock, Pin, PinOff, Plus, Search } from "lucide-react";
22 import { ContextMenu } from "radix-ui";
33 import { type ReactNode, useEffect, useState } from "react";
44 import { Link, NavLink, useFetcher, useLocation, useNavigate } from "react-router";
4040 }
4141
4242 /**
43− * Chat mode's sidebar: what you pinned, the channels, the workspace's
44− * agents (g1t first, each opening your conversation with it), then direct
45− * messages with people. All, Unread and Mentions filter every section. It
46− * refreshes itself while the tab is shown, so unread counts move without
47− * a reload.
43+ * Chat mode's sidebar: what you pinned, the channels (and a way to browse
44+ * the rest), then direct messages, the latest first, with people and
45+ * agents alike. Last, the agents you have not talked to yet, g1t first:
46+ * one click opens a conversation with any of them. Sections fold, and
47+ * stay folded on this device. All, Unread and Mentions filter every
48+ * section. It refreshes itself while the tab is shown, so unread counts
49+ * move without a reload.
4850 */
4951 export function ChatSidebar({ slug, heading = true }: { slug: string; heading?: boolean }) {
5052 const data = useChatData();
8082 const loading = !data;
8183 const owner = data?.role === "owner";
8284
83− // Every agent, g1t first; filtered as the conversations are.
85+ // The agents you have not talked to yet, g1t first: one click opens a
86+ // conversation, which then lists under Direct messages like anyone's.
87+ // Nothing of theirs is unread, so Unread and Mentions leave them out.
8488 const q = query.trim().toLowerCase().replace(/^@/, "");
85− const agents = [...(data?.agents ?? [])]
86− .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name))
87− .filter((agent) => {
88− const dm = agentDms.get(agent.id);
89− if (filter === "unread" && !(dm && !dm.muted && (dm.unread > 0 || dm.mentions > 0))) return false;
90− if (filter === "mentions" && !(dm && dm.mentions > 0)) return false;
91− return !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q);
89+ const agents =
90+ filter !== "all"
91+ ? []
92+ : [...(data?.agents ?? [])]
93+ .filter((agent) => !agentDms.has(agent.id))
94+ .sort((a, b) => Number(isOrchestrator(b)) - Number(isOrchestrator(a)) || a.display_name.localeCompare(b.display_name))
95+ .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
96+
97+ // Folded sections, remembered per workspace on this device. A folded
98+ // section still shows what is unread and the conversation open now.
99+ const [folded, setFolded] = useState<ReadonlySet<string>>(new Set());
100+ useEffect(() => setFolded(readFolded(slug)), [slug]);
101+ const fold = (id: string) =>
102+ setFolded((now) => {
103+ const next = new Set(now);
104+ if (!next.delete(id)) next.add(id);
105+ writeFolded(slug, next);
106+ return next;
92107 });
108+ const keepShown = (entry: ChatSidebarEntry) => {
109+ const to = channelPath(slug, entry.channel);
110+ return pathname === to || pathname.startsWith(`${to}/`) || (!entry.muted && (entry.unread > 0 || entry.mentions > 0));
111+ };
93112
94113 /** The direct message with an agent: opened, or made on first use. */
95114 const openAgent = async (agent: WorkspaceAgent, pin = false) => {
177196 <p className="px-2 py-3 text-xs leading-relaxed text-faint">Chat didn&apos;t answer. Your conversations will show here once it does.</p>
178197 ) : (
179198 <>
180− {pinned.length > 0 && <Section title="Pinned">{pinned.map(conversation)}</Section>}
181− <Section title="Channels" action={<CreateChannelButton slug={slug} />}>
199+ {pinned.length > 0 && (
200+ <Section id="pinned" title="Pinned" folded={folded} onFold={fold} keep={pinned.filter(keepShown).map(conversation)}>
201+ {pinned.map(conversation)}
202+ </Section>
203+ )}
204+ <Section
205+ id="channels"
206+ title="Channels"
207+ folded={folded}
208+ onFold={fold}
209+ action={<CreateChannelButton slug={slug} />}
210+ keep={channels.filter(keepShown).map(conversation)}
211+ >
182212 {channels.map(conversation)}
183213 {channels.length === 0 && filter === "all" && !query && <Empty>No channels yet.</Empty>}
214+ {filter === "all" && !query && (
215+ <ExtraRow to={`/${slug}/-/chat/browse`} icon={<Compass size={15} />} count={sidebar.browsable > 0 ? sidebar.browsable : undefined} countLabel="to join">
216+ Browse channels
217+ </ExtraRow>
218+ )}
184219 </Section>
185220 <Section
186− title="Agents"
187− action={
188− owner ? (
189− <Hint label="New agent">
190− <Link
191− to={`/${slug}/-/agents/new`}
192− aria-label="New agent"
193− className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
194− >
195− <Plus size={14} />
196− </Link>
197− </Hint>
198− ) : null
199− }
221+ id="dms"
222+ title="Direct messages"
223+ folded={folded}
224+ onFold={fold}
225+ action={<NewMessageButton slug={slug} />}
226+ keep={dms.filter(keepShown).map(conversation)}
200227 >
201− {agents.map((agent) => (
202− <AgentRow
203− key={agent.id}
204− agent={agent}
205− dm={agentDms.get(agent.id)}
206− current={(() => {
207− const dm = agentDms.get(agent.id);
208− return dm != null && pathname === channelPath(slug, dm.channel);
209− })()}
210− busy={opening === agent.id}
211− onOpen={() => void openAgent(agent)}
212− onPin={() => void openAgent(agent, true)}
213− />
214− ))}
215− {agents.length === 0 && filter === "all" && !query && <Empty>No agents yet.</Empty>}
216− </Section>
217− <Section title="Direct messages" action={<NewMessageButton slug={slug} />}>
218228 {dms.map(conversation)}
219− {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate.</Empty>}
229+ {dms.length === 0 && filter === "all" && !query && <Empty>Message a teammate or an agent.</Empty>}
220230 </Section>
231+ {(agents.length > 0 || (filter === "all" && !query)) && (
232+ <Section
233+ id="agents"
234+ title="Agents"
235+ folded={folded}
236+ onFold={fold}
237+ action={
238+ owner ? (
239+ <Hint label="New agent">
240+ <Link
241+ to={`/${slug}/-/agents/new`}
242+ aria-label="New agent"
243+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
244+ >
245+ <Plus size={14} />
246+ </Link>
247+ </Hint>
248+ ) : null
249+ }
250+ >
251+ {agents.map((agent) => (
252+ <AgentRow
253+ key={agent.id}
254+ agent={agent}
255+ busy={opening === agent.id}
256+ onOpen={() => void openAgent(agent)}
257+ onPin={() => void openAgent(agent, true)}
258+ />
259+ ))}
260+ {filter === "all" && !query && (
261+ <ExtraRow to={`/${slug}/-/agents`} icon={<Bot size={15} />}>
262+ {(data?.agents ?? []).length === 0 ? (owner ? "Hire an agent" : "No agents yet") : "All agents"}
263+ </ExtraRow>
264+ )}
265+ </Section>
266+ )}
221267 {shown.length === 0 && agents.length === 0 && (filter !== "all" || query) && (
222268 <p className="px-2 py-2 text-xs text-faint">{query ? "Nothing matches." : filter === "unread" ? "You're all caught up." : "No mentions."}</p>
223269 )}
224− <NavLink
225− to={`/${slug}/-/chat/browse`}
226− className={({ isActive }) =>
227− `mt-3 flex h-8 items-center gap-2.5 rounded-md px-2 text-[0.8125rem] transition-colors ${isActive ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg"}`
228− }
229− >
230− <Compass size={15} className="text-faint" />
231− Browse all channels
232− <span className="ml-auto text-xs text-faint tabular-nums">{(sidebar.browsable ?? 0) + entries.filter((e) => e.channel.kind === "channel").length}</span>
233− </NavLink>
234270 </>
235271 )}
236272 </nav>
238274 );
239275 }
240276
241−function Section({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) {
242− const [open, setOpen] = useState(true);
277+const FOLDED_KEY = (slug: string) => `g1t:chat-folded:${slug}`;
278+
279+/** The sections folded in this workspace, as last left; none if storage is not there. */
280+function readFolded(slug: string): ReadonlySet<string> {
281+ try {
282+ const saved = JSON.parse(localStorage.getItem(FOLDED_KEY(slug)) ?? "[]");
283+ return new Set(Array.isArray(saved) ? saved.filter((id): id is string => typeof id === "string") : []);
284+ } catch {
285+ return new Set();
286+ }
287+}
288+
289+function writeFolded(slug: string, folded: ReadonlySet<string>) {
290+ try {
291+ if (folded.size === 0) localStorage.removeItem(FOLDED_KEY(slug));
292+ else localStorage.setItem(FOLDED_KEY(slug), JSON.stringify([...folded]));
293+ } catch {
294+ // Private windows and blocked storage: folding still works until a reload.
295+ }
296+}
297+
298+/**
299+ * A titled part of the sidebar that folds away. Folded, it keeps `keep`
300+ * showing: the unread conversations and the open one.
301+ */
302+function Section({
303+ id,
304+ title,
305+ folded,
306+ onFold,
307+ action,
308+ keep,
309+ children,
310+}: {
311+ id: string;
312+ title: string;
313+ folded: ReadonlySet<string>;
314+ onFold: (id: string) => void;
315+ action?: ReactNode;
316+ keep?: ReactNode[];
317+ children: ReactNode;
318+}) {
319+ const open = !folded.has(id);
320+ const list = `chat-section-${id}`;
243321 return (
244322 <section className="mb-3">
245323 <div className="group/head flex h-7 items-center justify-between pr-0.5 pl-1">
246324 <button
247325 type="button"
248− onClick={() => setOpen(!open)}
326+ onClick={() => onFold(id)}
249327 aria-expanded={open}
250− className="flex items-center gap-1 rounded px-1 text-xs font-medium text-faint transition-colors hover:text-muted"
328+ aria-controls={list}
329+ className="flex min-w-0 grow items-center gap-1 rounded px-1 py-1 text-left text-xs font-medium text-faint transition-colors hover:text-muted focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none max-md:text-[0.8125rem]"
251330 >
252− <ChevronDown size={12} className={`transition-transform ${open ? "" : "-rotate-90"}`} />
331+ <ChevronDown size={12} className={`shrink-0 transition-transform ${open ? "" : "-rotate-90"}`} />
253332 {title}
254333 </button>
255− <span className="opacity-70 transition-opacity group-hover/head:opacity-100">{action}</span>
334+ <span className="opacity-70 transition-opacity group-hover/head:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100">{action}</span>
256335 </div>
257− {open && <ul className="space-y-px">{children}</ul>}
336+ {(open || (keep && keep.length > 0)) && (
337+ <ul id={list} className="space-y-px">
338+ {open ? children : keep}
339+ </ul>
340+ )}
258341 </section>
259342 );
260343 }
261344
345+/** A quieter row at the end of a section: somewhere to go rather than a conversation. */
346+function ExtraRow({ to, icon, count, countLabel, children }: { to: string; icon: ReactNode; count?: number; countLabel?: string; children: ReactNode }) {
347+ return (
348+ <li>
349+ <NavLink
350+ to={to}
351+ end
352+ className={({ isActive }) => `${ROW} ${isActive ? "bg-raised text-fg" : "text-faint hover:bg-raised/60 hover:text-muted"}`}
353+ >
354+ <span className="flex w-4.5 shrink-0 justify-center">{icon}</span>
355+ <span className="min-w-0 grow truncate">{children}</span>
356+ {count != null && (
357+ <span className="shrink-0 text-xs tabular-nums">
358+ {count}
359+ {countLabel && <span className="sr-only"> {countLabel}</span>}
360+ </span>
361+ )}
362+ </NavLink>
363+ </li>
364+ );
365+}
366+
262367 function Empty({ children }: { children: ReactNode }) {
263368 return <li className="px-2 py-1 text-xs text-faint">{children}</li>;
264369 }
342447 const agentId = agentDmOf(entry);
343448 const agent = agentId ? agents.find((a) => a.id === agentId) : null;
344449 const other = entry.others[0];
450+ const g1t = agent ? isOrchestrator(agent) : false;
345451 const icon = agent ? (
346− <AgentAvatar agent={{ ...agent, builtin: isOrchestrator(agent) }} size={18} />
452+ <AgentFace agent={agent} g1t={g1t} />
347453 ) : entry.channel.kind === "dm" && other ? (
348454 // One person: their dot, cut out of the sidebar behind it.
349455 <MemberAvatar member={other} size={18} presence={entry.others.length === 1} ring="var(--chat-sidebar-bg)" />
363469 current ? "bg-raised text-fg" : unread ? "text-fg hover:bg-raised/60" : entry.muted ? "text-faint hover:bg-raised/60 hover:text-muted" : "text-muted hover:bg-raised/60 hover:text-fg"
364470 }`}
365471 >
366− <span className={`flex w-[18px] shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
472+ <span className={`flex w-4.5 shrink-0 justify-center ${current || unread ? "text-muted" : "text-faint"}`}>{icon}</span>
367473 <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
368− {entry.title}
474+ {agent ? agent.display_name : entry.title}
475+ {agent && <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>}
369476 {entry.channel.kind === "dm" && other?.kind === "user" && entry.others.length === 1 && (
370477 <PersonStatusEmoji person={{ id: other.id, username: other.name }} size={13} className="ml-1.5 align-[-2px]" inert />
371478 )}
379486 );
380487 }
381488
382−/** One agent: its face, its name and status, and unread from your conversation with it. */
383−function AgentRow({
384− agent,
385− dm,
386− current,
387− busy,
388− onOpen,
389− onPin,
390−}: {
391− agent: WorkspaceAgent;
392− dm: ChatSidebarEntry | undefined;
393− current: boolean;
394− busy: boolean;
395− onOpen: () => void;
396− onPin: () => void;
397−}) {
489+/** An agent's face in a row; any but g1t carries its status, cut out of the sidebar behind it. */
490+function AgentFace({ agent, g1t }: { agent: WorkspaceAgent; g1t: boolean }) {
491+ return (
492+ <span className="relative inline-flex shrink-0">
493+ <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
494+ {!g1t && agent.status !== "idle" && (
495+ <span className="absolute -right-1 -bottom-1 flex rounded-full p-[2px]" style={{ background: "var(--chat-sidebar-bg)" }}>
496+ <StatusDot status={agent.status} className="size-[7px] shadow-none" />
497+ </span>
498+ )}
499+ </span>
500+ );
501+}
502+
503+/** An agent you have not talked to yet: its face, name and title. Opening it starts your conversation. */
504+function AgentRow({ agent, busy, onOpen, onPin }: { agent: WorkspaceAgent; busy: boolean; onOpen: () => void; onPin: () => void }) {
398505 const g1t = isOrchestrator(agent);
399− const unread = dm != null && !dm.muted && (dm.unread > 0 || dm.mentions > 0);
400506 return (
401507 <li className="group/row relative">
402− <RowMenu pinned={dm?.starred ?? false} onPin={onPin}>
508+ <RowMenu pinned={false} onPin={onPin}>
403509 <button
404510 type="button"
405511 onClick={onOpen}
406− aria-current={current ? "page" : undefined}
407512 aria-busy={busy || undefined}
408− className={`${ROW} w-full text-left ${current ? "bg-raised text-fg" : unread ? "text-fg hover:bg-raised/60" : "text-muted hover:bg-raised/60 hover:text-fg"} ${busy ? "opacity-60" : ""}`}
513+ aria-label={`Message ${agent.display_name}, ${g1t ? "orchestrator" : agent.title}${g1t ? "" : `, ${statusLabel(agent.status).toLowerCase()}`}`}
514+ className={`${ROW} w-full text-left text-muted hover:bg-raised/60 hover:text-fg ${busy ? "opacity-60" : ""}`}
409515 >
410− <span className="relative flex w-[18px] shrink-0 justify-center">
411− <AgentAvatar agent={{ ...agent, builtin: g1t }} size={18} />
516+ <span className="flex w-4.5 shrink-0 justify-center">
517+ <AgentFace agent={agent} g1t={g1t} />
412518 </span>
413− <span className={`min-w-0 grow truncate ${unread ? "font-semibold" : current ? "font-medium" : ""}`}>
519+ <span className="min-w-0 grow truncate">
414520 {agent.display_name}
415− <span className="ml-1.5 text-[0.6875rem] font-normal text-faint">{g1t ? "orchestrator" : agent.title}</span>
521+ <span className="ml-1.5 text-[0.6875rem] text-faint">{g1t ? "orchestrator" : agent.title}</span>
416522 </span>
417− {!g1t && (
418− <Hint label={statusLabel(agent.status)}>
419− <span className="flex size-4 shrink-0 items-center justify-center">
420− <StatusDot status={agent.status} />
421− </span>
422− </Hint>
423− )}
424− <Count entry={dm} />
425523 </button>
426524 </RowMenu>
427− <PinButton pinned={dm?.starred ?? false} onPin={onPin} label={agent.display_name} />
525+ <PinButton pinned={false} onPin={onPin} label={agent.display_name} />
428526 </li>
429527 );
430528 }
+1−1
306306 { team: "QA", who: "Margo" },
307307 { team: "Docs", who: "Inky" },
308308 { team: "Product", who: "Dot" },
309− { team: "Support", who: "Izzy" },
309+ { team: "Support", who: "Sam" },
310310 { team: "Sales", who: "David" },
311311 { team: "Operations", who: "Bruno" },
312312 ];
+45−35
218218 /* ------------------------------------------------------------------ */
219219
220220 /** The people in the story; everyone else is an agent. */
221−const PEOPLE: Record<string, string> = { Priya: "Priya Shah", Dana: "Dana Ruiz", Sam: "Sam Lee" };
221+const PEOPLE: Record<string, string> = { Priya: "Priya Shah", Dana: "Dana Ruiz", Alex: "Alex Lee" };
222222
223223 /** The agents in the story: a human name, a title, and a pixel creature of their own. */
224224 const AGENTS: Record<string, { title: string; team: string }> = {
226226 Otto: { title: "Software Engineer", team: "Engineering" },
227227 Margo: { title: "QA Engineer", team: "QA" },
228228 Inky: { title: "Technical Writer", team: "Docs" },
229− Izzy: { title: "Support Specialist", team: "Customer Support" },
229+ Sam: { title: "Support Specialist", team: "Customer Support" },
230230 Dot: { title: "Product Manager", team: "Product" },
231231 David: { title: "Sales Operations", team: "Sales" },
232232 Bruno: { title: "Operations Engineer", team: "Operations" },
492492 // Otto works from the handoff until the merge; everyone else is idle.
493493 const ottoBusy = frame.handoff && !frame.merged;
494494 const margoBusy = frame.consult && !frame.approved;
495+ // As the app has it: direct messages with people and agents alike, the
496+ // latest first; then the agents not talked to yet.
497+ const dms: { who: string; away?: boolean; status?: string; working?: boolean }[] = [
498+ { who: "g1t" },
499+ { who: "Dana" },
500+ { who: "Margo", working: margoBusy },
501+ { who: "Alex", away: true, status: "🗓️" },
502+ ];
495503 const agents: { who: string; working: boolean }[] = [
496− { who: "g1t", working: frame.g1tTyping },
497504 { who: "Otto", working: ottoBusy },
498− { who: "Margo", working: margoBusy },
499505 { who: "Inky", working: frame.docUpdated },
500− { who: "Izzy", working: frame.shipped && !frame.izzy },
506+ { who: "Sam", working: frame.shipped && !frame.sam },
501507 ];
502508 return (
503509 <>
524530 {name === "support" && <span className="min-w-4 rounded-full bg-line-strong px-1 text-center text-[9.5px] font-semibold text-fg">3</span>}
525531 </SideRow>
526532 ))}
533+ <div className="flex h-7 items-center gap-2 px-2 text-[12px] text-faint">
534+ <Compass size={12} />
535+ <span className="flex-1">Browse channels</span>
536+ <span className="text-[10.5px]">3</span>
537+ </div>
538+ <SideLabel>Direct messages</SideLabel>
539+ {dms.map((dm) => {
540+ const agent = dm.who in AGENTS;
541+ return (
542+ <SideRow key={dm.who}>
543+ <span className="relative flex">
544+ <Face who={dm.who} size={16} />
545+ {!agent && <PresenceDot away={dm.away} />}
546+ {agent && dm.working && <PresenceDot />}
547+ </span>
548+ <span className="min-w-0 flex-1 truncate">
549+ {agent ? dm.who : PEOPLE[dm.who]}
550+ {agent && <span className="text-faint"> · {dm.who === "g1t" ? "orchestrator" : AGENTS[dm.who].title}</span>}
551+ {dm.status && <span className="ml-1.5 text-[11px]">{dm.status}</span>}
552+ </span>
553+ </SideRow>
554+ );
555+ })}
527556 <SideLabel>Agents</SideLabel>
528557 {agents.map((agent) => (
529558 <SideRow key={agent.who}>
530− <Face who={agent.who} size={16} />
559+ <span className="relative flex">
560+ <Face who={agent.who} size={16} />
561+ {agent.working && <PresenceDot />}
562+ </span>
531563 <span className="min-w-0 flex-1 truncate">
532564 {agent.who}
533565 <span className="text-faint"> · {AGENTS[agent.who].title}</span>
534− </span>
535− {agent.who !== "g1t" && <AgentDot working={agent.working} />}
536− </SideRow>
537− ))}
538− <SideLabel>Direct messages</SideLabel>
539− {[
540− { who: "Dana", away: false, status: null },
541− { who: "Sam", away: true, status: "🗓️" },
542− ].map((person) => (
543− <SideRow key={person.who}>
544− <span className="relative flex">
545− <Face who={person.who} size={16} />
546− <PresenceDot away={person.away} />
547− </span>
548− <span className="flex-1 truncate">
549− {PEOPLE[person.who]}
550− {person.status && <span className="ml-1.5 text-[11px]">{person.status}</span>}
551566 </span>
552567 </SideRow>
553568 ))}
554− <div className="mt-2 flex h-7 items-center gap-2 px-2 text-[12px] text-muted">
555− <Compass size={13} className="text-faint" />
556− <span className="flex-1">Browse all channels</span>
557− <span className="text-[10.5px] text-faint">6</span>
558− </div>
559569 </div>
560570 </>
561571 );
615625 {frame.shipped && (
616626 <Message who="g1t" time="09:41" enter>
617627 <p>
618− <Rich text="Shipped. A 200,000-row export now finishes in about 3 s. @izzy, can you let #support know?" />
628+ <Rich text="Shipped. A 200,000-row export now finishes in about 3 s. @sam, can you let #support know?" />
619629 </p>
620630 </Message>
621631 )}
622− {frame.izzy && (
623− <Message who="Izzy" time="09:42" enter>
632+ {frame.sam && (
633+ <Message who="Sam" time="09:42" enter>
624634 <p>
625635 <Rich text="Done. Told #support, and Dana's two customers can export again." />
626636 </p>
669679 { who: "Otto", working: true },
670680 { who: "Margo" },
671681 { who: "Inky" },
672− { who: "Izzy" },
682+ { who: "Sam" },
673683 { who: "Dot" },
674684 ];
675685
13671377 {frame.shipped && (
13681378 <Message who="g1t" time="09:41" enter>
13691379 <p>
1370− <Rich text="Shipped. @izzy, can you tell #support?" />
1380+ <Rich text="Shipped. @sam, can you tell #support?" />
13711381 </p>
13721382 </Message>
13731383 )}
1374− {frame.izzy && (
1375− <Message who="Izzy" time="09:42" enter>
1384+ {frame.sam && (
1385+ <Message who="Sam" time="09:42" enter>
13761386 <p>
13771387 <Rich text="Done. Told #support." />
13781388 </p>
+2−2
153153 const { pinned, channels, agentDms, dms } = sections(all);
154154 assert.deepEqual(pinned.map((e) => e.title), ["general"]);
155155 assert.deepEqual(channels.map((e) => e.title), ["noisy", "random"]);
156− // A direct message with one agent is the agent's, under Agents.
157− assert.deepEqual(dms.map((e) => e.title), ["ana"]);
156+ // Direct messages, the latest first: people and agents alike.
157+ assert.deepEqual(dms.map((e) => e.title), ["ana", "reviewer"]);
158158 assert.equal(agentDms.get("a1")?.title, "reviewer");
159159 assert.deepEqual(filterEntries(all, "unread", "").map((e) => e.title), ["general", "reviewer", "noisy"]);
160160 assert.deepEqual(filterEntries(all, "mentions", "").map((e) => e.title), ["reviewer"]);
+6−5
296296 });
297297 }
298298
299−/** Whether a conversation is a direct message with one agent: it is listed under Agents, not Direct messages. */
299+/** The agent a conversation is a direct message with, when it is with one agent alone; else null. */
300300 export function agentDmOf(entry: ChatSidebarEntry): string | null {
301301 const only = entry.others.length === 1 ? entry.others[0]! : null;
302302 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
304304
305305 /**
306306 * The sidebar's sections, in the order they show: what is pinned (the
307− * `starred` preference), channels, the direct message with each agent (by
308− * agent id, for the Agents section), and direct messages with people,
309− * groups that mix people and agents included.
307+ * `starred` preference), channels, and direct messages, the latest first,
308+ * with people and agents alike (an agent is a member you message like
309+ * anyone). `agentDms` finds the direct message with each agent, by agent
310+ * id, so the Agents section lists only the agents you have not talked to.
310311 */
311312 export function sections(entries: ChatSidebarEntry[]): {
312313 pinned: ChatSidebarEntry[];
327328 pinned: entries.filter((e) => e.starred).sort(byName),
328329 channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
329330 agentDms,
330− dms: entries.filter((e) => !e.starred && e.channel.kind === "dm" && !agentDmOf(e)).sort(byRecent),
331+ dms: entries.filter((e) => !e.starred && e.channel.kind === "dm").sort(byRecent),
331332 };
332333 }
333334
+4−4
5454
5555 test("each still shows its step's point", () => {
5656 assert.equal(frameAt(STILLS.code).approved, true);
57− assert.equal(frameAt(STILLS.chat).izzy, true);
57+ assert.equal(frameAt(STILLS.chat).sam, true);
5858 assert.equal(frameAt(STILLS.agents).scene, "agents");
5959 assert.equal(frameAt(STILLS.docs).docUpdated, true);
6060 });
6161
62−test("Izzy tells #support after g1t says it shipped", () => {
63− assert.equal(frameAt(BEAT.izzy - 1).izzy, false);
64− assert.equal(frameAt(BEAT.izzy).shipped, true);
62+test("Sam tells #support after g1t says it shipped", () => {
63+ assert.equal(frameAt(BEAT.sam - 1).sam, false);
64+ assert.equal(frameAt(BEAT.sam).shipped, true);
6565 });
+8−8
66 *
77 * The story opens on the code: Otto's pull request #431 runs its checks,
88 * is approved and is merged by the queue. Then where it came from: the
9− * ask in #web, handed to @otto by g1t, now merged, and Izzy telling
9+ * ask in #web, handed to @otto by g1t, now merged, and Sam telling
1010 * #support. Then Otto's sessions, the record of what he did and spent, and
11− * last Inky's docs page picking up the change. Otto, Margo, Izzy and Inky
11+ * last Inky's docs page picking up the change. Otto, Margo, Sam and Inky
1212 * are agents this workspace hired from role templates; only g1t is built in.
1313 */
1414
4949 cardMerged: boolean;
5050 /** g1t posted that it shipped. */
5151 shipped: boolean;
52− /** Izzy says she told #support. */
53− izzy: boolean;
52+ /** Sam says the support team has been told, in #support. */
53+ sam: boolean;
5454 /** Otto consulted Margo, shown as a collapsed line on the task. */
5555 consult: boolean;
5656 /** The docs page shows the changed line. */
8787 chat: 6450,
8888 cardMerged: 7100,
8989 shipped: 8400,
90− izzy: 9600,
90+ sam: 9600,
9191 railAgentsClick: 11000,
9292 agents: 11150,
9393 railDocsClick: 15000,
137137 const press = (at: number) => t >= at && t < at + 180;
138138 const moves: [number, CursorTarget][] = [
139139 [BEAT.merged + 300, "rail-chat"],
140− [BEAT.izzy + 500, "rail-agents"],
140+ [BEAT.sam + 500, "rail-agents"],
141141 [BEAT.agents + 2600, "rail-docs"],
142142 [BEAT.docs + 500, "idle"],
143143 ];
169169 merged: t >= BEAT.merged,
170170 cardMerged: t >= BEAT.cardMerged,
171171 shipped: t >= BEAT.shipped,
172− izzy: t >= BEAT.izzy,
172+ sam: t >= BEAT.sam,
173173 consult: true,
174174 docUpdated: t >= BEAT.docUpdated,
175175 cursor,
181181 /** A still frame for each pill, for reduced motion: the moment the step makes its point. */
182182 export const STILLS: Record<Scene, number> = {
183183 code: BEAT.approved + 200,
184− chat: BEAT.izzy + 400,
184+ chat: BEAT.sam + 400,
185185 agents: BEAT.agents + 400,
186186 docs: BEAT.docUpdated + 400,
187187 };
+12−3
8585 {shown.map((channel) => {
8686 const isIn = joined.has(channel.id);
8787 return (
88− <li key={channel.id} className="flex items-center gap-3 px-4 py-3">
88+ // The whole row opens the channel: its name's link reaches over
89+ // it (`after:inset-0`), and Join sits above that, so nothing
90+ // interactive nests inside anything else.
91+ <li
92+ key={channel.id}
93+ className="group/card relative flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised/50 has-[a:focus-visible]:bg-raised/50 has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-accent has-[a:focus-visible]:ring-inset"
94+ >
8995 {channel.private ? (
9096 <Lock size={15} className="shrink-0 text-faint" aria-label="Private" />
9197 ) : (
9298 <Hash size={16} className="shrink-0 text-faint" aria-label="Public" />
9399 )}
94100 <div className="min-w-0 grow">
95− <Link to={channelPath(loaderData.slug, channel)} className="font-medium hover:text-accent">
101+ <Link
102+ to={channelPath(loaderData.slug, channel)}
103+ className="font-medium outline-none group-hover/card:text-accent after:absolute after:inset-0 after:content-['']"
104+ >
96105 {channel.name}
97106 </Link>
98107 {channel.private && <span className="ml-2 text-xs text-faint">Private</span>}
112121 setJoining(null);
113122 if (done.ok) navigate(channelPath(loaderData.slug, channel));
114123 }}
115− className="h-8 rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised disabled:opacity-50"
124+ className="relative z-10 h-8 rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-raised disabled:opacity-50"
116125 >
117126 {joining === channel.id ? "Joining…" : "Join"}
118127 </button>
+3−3
8989 ### Roles, not tasks
9090
9191 An agent is hired into a role, like a person: **Margo** works in QA,
92−**Izzy** in Customer Support, **David** in Sales, **Bruno** in Operations.
92+**Sam** in Customer Support, **David** in Sales, **Bruno** in Operations.
9393 The role is broad on purpose.
9494
9595 - **A title and a team.** For example, "QA Engineer" on the QA team.
103103 - reproduce bug reports;
104104 - keep the release checklist honest.
105105
106− Izzy's:
106+ Sam's:
107107 - answer customer questions from Docs and the product;
108108 - turn bugs into intake for the owning team;
109109 - tell customers when their fix ships.
178178 - Operations: Bruno.
179179 - Docs: Inky.
180180 - Product: Dot.
181−- Support: Izzy.
181+- Support: Sam.
182182 - Sales: David.
183183
184184 g1t is the one who knows everyone. The **role templates** are organised by
+1−1
456456 export type RoutineEvent = (typeof ROUTINE_EVENTS)[number]["key"];
457457
458458 /**
459− * A routine: work an agent does on a schedule or when something happens, such as Izzy's Monday digest
459+ * A routine: work an agent does on a schedule or when something happens, such as Sam's Monday digest
460460 * of support themes. Each run is a session posted in the routine's channel,
461461 * paid from the agent's budget, and run with the access of the person who
462462 * set it up (its sponsor), never more.
+1−1
1818 });
1919
2020 test("the query is what people said last, without mentions, cut short", () => {
21− assert.equal(recallQuery(["@izzy can a customer get a refund after 40 days?", "hi", "on enterprise"]), "can a customer get a refund after 40 days? \non enterprise");
21+ assert.equal(recallQuery(["@sam can a customer get a refund after 40 days?", "hi", "on enterprise"]), "can a customer get a refund after 40 days? \non enterprise");
2222 assert.equal(recallQuery(["ok", ""]), null);
2323 assert.equal(recallQuery(["x".repeat(2000)])!.length, 600);
2424 });
+1−1
11 /**
22 * Routines (docs/WORKSPACE.md, "Routines"): work an agent does on a
3− * schedule, such as Izzy's Monday digest of support themes or Bruno's
3+ * schedule, such as Sam's Monday digest of support themes or Bruno's
44 * morning look at failed deploys. Each run is a session in the routine's
55 * channel, paid from the agent's budget, with the access of the person who
66 * set it up (its sponsor), never more: if the sponsor leaves the workspace
+3−3
162162 },
163163 {
164164 id: "support",
165− display_name: "Izzy",
166− handle: "izzy",
167− name_ideas: ["Izzy", "Biscuit", "Sunny", "Waffles", "Poppy", "Moss", "Daisy", "Nugget"],
165+ display_name: "Sam",
166+ handle: "sam",
167+ name_ideas: ["Sam", "Robin", "Jamie", "Biscuit", "Sunny", "Waffles", "Poppy", "Moss", "Daisy", "Nugget"],
168168 title: "Support Specialist",
169169 department: "Customer Support",
170170 role: "Support Specialist, Customer Support",