Skip to content
430 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.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
2 * Chat as the site draws it: which messages sit together under one name,
3 * where a day starts, what an `@…` being typed could become, and a
4 * message's text as safe blocks and spans. Pure, so it is tested on its
5 * own (chat.test.ts); the components are in components/chat.
6 *
7 * Message bodies are never HTML: they are parsed here into a small tree
8 * that React renders as text, so nothing anyone writes can run.
9 */
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.10import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal, WorkspaceAgent } from "@g1t/contracts";
Chat and workspace agents: channels, DMs and named agents you talk to11
12/** Two messages from the same author this close together share one header. */
13export const GROUP_GAP_MS = 5 * 60 * 1000;
14
15/** A message on its way: shown at once, then swapped for what the service saved. */
16export type ShownMessage = ChatMessage & { pending?: boolean; failed?: boolean; client_id?: string };
17
18export type TimelineRow =
19 | { kind: "day"; key: string; label: string }
20 | { kind: "message"; key: string; message: ShownMessage; head: boolean };
21
22function sameAuthor(a: ChatMessage["author"], b: ChatMessage["author"]): boolean {
23 return a.kind === b.kind && a.id === b.id;
24}
25
26/** The calendar day of an instant, in the reader's time zone (or `zone`). */
27export function dayKey(at: string | Date, zone?: string): string {
28 const date = typeof at === "string" ? new Date(at) : at;
29 return new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
30}
31
32/** "Today", "Yesterday", or the date, for the rule between days. */
33export function dayLabel(at: string, now: Date = new Date(), zone?: string): string {
34 const key = dayKey(at, zone);
35 if (key === dayKey(now, zone)) return "Today";
36 const yesterday = new Date(now.getTime() - 86_400_000);
37 if (key === dayKey(yesterday, zone)) return "Yesterday";
38 const date = new Date(at);
39 const sameYear = dayKey(date, zone).slice(0, 4) === dayKey(now, zone).slice(0, 4);
40 return new Intl.DateTimeFormat("en-US", {
41 timeZone: zone,
42 weekday: "long",
43 month: "long",
44 day: "numeric",
45 year: sameYear ? undefined : "numeric",
46 }).format(date);
47}
48
49/**
50 * The channel's messages, oldest first, as rows: a rule where each day
51 * starts, and each message marked with whether it starts a group (and so
52 * shows its author's name and avatar). A card always starts one.
53 */
54export function timeline(messages: ShownMessage[], now: Date = new Date(), zone?: string): TimelineRow[] {
55 const rows: TimelineRow[] = [];
56 let previous: ShownMessage | null = null;
57 let day: string | null = null;
58 for (const message of messages) {
59 if (message.deleted_at) continue;
60 const today = dayKey(message.created_at, zone);
61 if (today !== day) {
62 rows.push({ kind: "day", key: `day:${today}`, label: dayLabel(message.created_at, now, zone) });
63 day = today;
64 previous = null;
65 }
66 const head =
67 previous == null ||
68 message.kind === "card" ||
69 previous.kind === "card" ||
70 !sameAuthor(previous.author, message.author) ||
71 new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() > GROUP_GAP_MS;
72 rows.push({ kind: "message", key: message.client_id ?? message.id, message, head });
73 previous = message;
74 }
75 return rows;
76}
77
78/**
79 * Messages merged by id, oldest first: what the page had, what arrived
80 * live and what a reload brought. A newer copy (an edit) wins; a pending
81 * message is dropped once the saved one with its `client_id` is there.
82 */
83export function mergeMessages(current: ShownMessage[], incoming: ShownMessage[]): ShownMessage[] {
84 const byId = new Map<string, ShownMessage>();
85 for (const message of [...current, ...incoming]) {
86 const key = message.id;
87 const had = byId.get(key);
88 if (!had || !message.pending || had.pending) byId.set(key, message);
89 }
90 const saved = new Set([...byId.values()].filter((m) => !m.pending).map((m) => m.client_id).filter(Boolean));
91 return [...byId.values()]
92 .filter((message) => !(message.pending && message.client_id && saved.has(message.client_id)))
93 .sort((a, b) => (a.pending === b.pending ? (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) : a.pending ? 1 : -1));
94}
95
96/** Someone who can be mentioned: a person by username, an agent by handle. */
97export type Mentionable = {
98 kind: "user" | "agent";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar99 /** What follows the `@`: lowercased, as it is kept. */
Chat and workspace agents: channels, DMs and named agents you talk to100 name: string;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar101 /** A person's username as they wrote it, when that differs from `name`. */
102 display_username?: string | null;
Chat and workspace agents: channels, DMs and named agents you talk to103 display_name: string;
104 avatar: string | null;
105 /** An agent's role, shown beside it. */
106 role?: string | null;
107};
108
109export type MentionQuery = { start: number; typed: string; options: Mentionable[] };
110
111/** Most suggestions at once. */
112export const MAX_MENTIONS = 6;
113
114/**
115 * The `@…` just before the caret and who it could be: names that start with
116 * what is typed first, then display names with a word that does. Null when
117 * the caret is not in a mention, or nothing fits.
118 */
119export function mentionQuery(text: string, caret: number, people: readonly Mentionable[]): MentionQuery | null {
120 const match = /(?:^|[\s(])@([\w.-]{0,40})$/.exec(text.slice(0, caret));
121 if (!match) return null;
122 const typed = match[1]!.toLowerCase();
123 const start = caret - match[1]!.length - 1;
124 const first: Mentionable[] = [];
125 const then: Mentionable[] = [];
126 for (const person of people) {
127 const name = person.name.toLowerCase();
128 if (name === typed) continue;
129 if (name.startsWith(typed)) first.push(person);
130 else if (typed && person.display_name.toLowerCase().split(/\s+/).some((word) => word.startsWith(typed))) then.push(person);
131 }
132 const options = [...first, ...then].slice(0, MAX_MENTIONS);
133 return options.length > 0 ? { start, typed, options } : null;
134}
135
136// ---------------------------------------------------------------------------
Merge main into Artifacts Phase 2137// A message's text: its Markdown, parsed by the rule every part of g1t reads it with.
Chat and workspace agents: channels, DMs and named agents you talk to138
Merge main into Artifacts Phase 2139export { type Block, type Span, blocks, inline, plainText, safeHref, spansText } from "@g1t/contracts/chat-markdown";
Chat and workspace agents: channels, DMs and named agents you talk to140
141/** Whether a message is only emoji, a few of them: shown larger, as people expect. */
142export function onlyEmoji(text: string): boolean {
143 const trimmed = text.trim();
144 if (!trimmed || trimmed.length > 24) return false;
145 return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|‍|️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed);
146}
147
148// ---------------------------------------------------------------------------
149// The sidebar.
150
151export type ChatFilter = "all" | "unread" | "mentions";
152
153/** The sidebar's rows that pass the filter and the "Jump to" text. */
A conversation with an archived agent leaves Chat's list. Until the agent is restored or deleted for good, its direct message is found by typing its name into Jump to channel or person, and it stays listed only while it is the conversation you are in; the chat and agents guides say so.154/**
155 * The conversations the sidebar lists, by filter and by what is typed in
156 * Jump to. A direct message with archived or deleted agents alone is not
157 * listed on its own: it is found by typing the agent's name, and stays
158 * while it is the conversation that is open (`current`, its channel id).
159 */
160export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string, current: string | null = null): ChatSidebarEntry[] {
Chat and workspace agents: channels, DMs and named agents you talk to161 const q = query.trim().toLowerCase().replace(/^[#@]/, "");
162 return entries.filter((entry) => {
163 if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false;
164 if (filter === "mentions" && entry.mentions === 0) return false;
A conversation with an archived agent leaves Chat's list. Until the agent is restored or deleted for good, its direct message is found by typing its name into Jump to channel or person, and it stays listed only while it is the conversation you are in; the chat and agents guides say so.165 if (!q) return entry.channel.id === current || !archivedAgentsDm(entry);
Chat and workspace agents: channels, DMs and named agents you talk to166 if (entry.title.toLowerCase().includes(q)) return true;
167 return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q));
168 });
169}
170
Merge branch 'chat-sidebar' into fast-push171/** The agent a conversation is a direct message with, when it is with one agent alone; else null. */
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)172export function agentDmOf(entry: ChatSidebarEntry): string | null {
173 const only = entry.others.length === 1 ? entry.others[0]! : null;
174 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
175}
176
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.177type Members = Pick<MemberProfile, "kind">[];
178
179/** Whether a direct message is with agents alone (one or several, nobody else). */
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.180export function agentsOnlyDm(entry: { channel: { kind: "channel" | "dm" }; others: readonly Pick<MemberProfile, "kind">[] }): boolean {
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.181 return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent");
182}
183
184/**
185 * Whether a direct message is mixed: at least one person besides you and
186 * at least one agent in it. Such a conversation lists under People (a
187 * person is in it) and says, everywhere it shows, that an agent is too.
188 */
189export function mixedDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
190 return entry.channel.kind === "dm" && isMixed(entry.others);
191}
192
193/** Whether the others in a conversation are a person or more and an agent or more. */
194export function isMixed(others: Members): boolean {
195 return others.some((member) => member.kind === "agent") && others.some((member) => member.kind === "user");
196}
197
198/** The agents among a conversation's other members. */
199export function agentsAmong<M extends Pick<MemberProfile, "kind">>(others: readonly M[]): M[] {
200 return others.filter((member) => member.kind === "agent");
201}
202
203/**
204 * The composer's placeholder in a direct message: "Message @mike and @sam
205 * (agent)", each agent said to be one, so nobody writes to a person and
206 * reaches a bot unawares. A conversation with yourself alone is for notes.
207 */
208export function dmPlaceholder(others: readonly Pick<MemberProfile, "kind" | "name" | "display_username">[]): string {
209 if (others.length === 0) return "Message yourself";
210 const names = others.map((member) => `@${shownHandle(member)}${member.kind === "agent" ? " (agent)" : ""}`);
211 const list = names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
212 return `Message ${list}`;
213}
214
215/**
216 * The one-line notice above a mixed conversation's first message, the
217 * first time a person opens it: who the agents are and that they read and
218 * can act on what is said.
219 */
220export function mixedNotice(others: readonly Pick<MemberProfile, "kind" | "name" | "display_name" | "display_username">[]): string {
221 const agents = agentsAmong(others).map(shownName);
222 if (agents.length === 0) return "";
223 if (agents.length === 1) return `${agents[0]} is an agent and reads everything here; it can act on what you say.`;
224 const list = `${agents.slice(0, -1).join(", ")} and ${agents[agents.length - 1]}`;
225 return `${list} are agents and read everything here; they can act on what you say.`;
226}
227
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 * The sidebar's sections, in the order they show: what is pinned (the
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.230 * `starred` preference), channels, then direct messages in two buckets,
231 * each the latest first: People, every conversation with a person in it
232 * (a mixed one too, since a person is in it), and Agents, conversations
233 * with agents alone. `agentDms` finds the direct message with each agent
234 * on its own, by agent id, so the Agents section lists only the agents
235 * you have not talked to yet.
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)236 */
237export function sections(entries: ChatSidebarEntry[]): {
238 pinned: ChatSidebarEntry[];
239 channels: ChatSidebarEntry[];
240 agentDms: Map<string, ChatSidebarEntry>;
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.241 people: ChatSidebarEntry[];
242 agents: ChatSidebarEntry[];
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)243} {
Chat and workspace agents: channels, DMs and named agents you talk to244 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
245 // Direct messages: the latest conversation first, as people scan them.
246 const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) =>
247 (b.channel.last_message_at ?? b.channel.created_at).localeCompare(a.channel.last_message_at ?? a.channel.created_at);
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)248 const agentDms = new Map<string, ChatSidebarEntry>();
249 for (const entry of entries) {
250 const agent = agentDmOf(entry);
251 if (agent) agentDms.set(agent, entry);
252 }
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.253 const dms = entries.filter((e) => !e.starred && e.channel.kind === "dm");
Chat and workspace agents: channels, DMs and named agents you talk to254 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)255 pinned: entries.filter((e) => e.starred).sort(byName),
Chat and workspace agents: channels, DMs and named agents you talk to256 channels: entries.filter((e) => !e.starred && e.channel.kind === "channel").sort(byName),
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)257 agentDms,
Agents have faces, and are never mistaken for people. Every agent wears a little bot face drawn from a look it owns, shape, colour, eyes, mouth, antenna, accessory and pattern, chosen in its builder and on its Profile tab with a live preview, Shuffle and a way back to the face its seed gives it; the face blinks on its own time, breathes, narrows its eyes while the agent works, shuts them asleep and bounces when it finishes, all of it still for anyone who asked for less motion. Wherever an agent shows, in chat, in a list, on a mention, on a review or a commit, its avatar carries an agent marker, and the people reading it are told so. In Chat, direct messages are two lists: People, and Agents, which also holds the agents you haven't talked to yet; a conversation with both a person and an agent in it is marked in the list, named in the conversation's header, spelled out by the composer and explained once the first time it opens. Agents keep their look in the agents service, which every service passes along. The chat and agents guides say so, and CONTRIBUTING makes the shared avatar the only way to draw an agent.258 people: dms.filter((e) => !agentsOnlyDm(e)).sort(byRecent),
259 agents: dms.filter(agentsOnlyDm).sort(byRecent),
Chat and workspace agents: channels, DMs and named agents you talk to260 };
261}
262
An archived agent can be found, brought back, or deleted for good. The Agents page ends with Archived, folded, where owners see every archived agent and members their own personal ones, each with when and by whom it was archived and what deleting it would take; Restore brings it back, under a new handle when a live agent has taken its old one, and Delete permanently asks for the handle to be typed and then removes its sessions, replies, memories, routines, versions, drafts, skills and its computer's home, leaving its messages in chat under its saved name, the workspace's spend totals and the audit log. In Chat an archived agent is deactivated the way people expect: never offered as someone to message or mention, never woken, a conversation with it kept in the list, dimmed and marked Archived, open to read with a note in place of the composer and a way to restore it for owners; after deletion the conversation still reads, its messages marked as a deleted agent's. The agents, chat and agent budgets guides say so.263/**
264 * The archived (or deleted) agent a direct message is with, when the
265 * conversation is with agents alone and every one of them is archived or
266 * deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted
267 * agents"): the row is dimmed and says Archived (or Deleted, when the
268 * agent returned is `deleted`), and a conversation with one agent opens
269 * read-only. Null for any other conversation, a mixed one included, since
270 * a person is still there to talk to.
271 */
272export function archivedAgentsDm<M extends Pick<MemberProfile, "kind" | "archived_at" | "deleted">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null {
273 if (!agentsOnlyDm(entry)) return null;
274 return entry.others.every((member) => !!member.archived_at || member.deleted === true) ? (entry.others[0] ?? null) : null;
275}
276
277/**
278 * The agents you have not talked to yet, for the sidebar's Agents section:
279 * live ones (an archived agent is never offered, though its old conversation
280 * stays listed), with no direct message yet, g1t first, then by name,
281 * narrowed by what is typed in Jump to (handle, name or role).
282 */
283export function agentsToTalkTo<A extends Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "role" | "builtin" | "archived_at">>(agents: readonly A[], agentDms: ReadonlyMap<string, unknown>, query = ""): A[] {
284 const q = query.trim().toLowerCase().replace(/^@/, "");
285 return agents
286 .filter((agent) => !agent.archived_at && !agentDms.has(agent.id))
287 .sort((a, b) => Number(!!b.builtin) - Number(!!a.builtin) || a.display_name.localeCompare(b.display_name))
288 .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q));
289}
290
291/**
292 * What an archived agent's conversation says in place of its composer:
293 * who archived it and when, as far as that is known.
294 */
295export function archivedAgentLine(agent: Pick<MemberProfile, "name" | "display_name" | "display_username" | "archived_at" | "archived_by">, now: Date = new Date()): string {
296 return `${shownName(agent)} was ${archivedAgentWhen(agent, now).replace(/^A/, "a")}. It no longer answers here.`;
297}
298
299/** "Archived 2 days ago by @ana": under an archived agent's name, where its title was. */
300export function archivedAgentWhen(agent: Pick<MemberProfile, "archived_at" | "archived_by">, now: Date = new Date()): string {
301 const when = agent.archived_at ? ` ${timeSince(agent.archived_at, now)}` : "";
302 const by = agent.archived_by ? ` by @${agent.archived_by}` : "";
303 return `Archived${when}${by}`;
304}
305
306/** "3 days ago", "2 hours ago", "just now": for a line of prose. */
307export function timeSince(at: string, now: Date = new Date()): string {
308 const elapsed = Math.max(0, now.getTime() - new Date(at).getTime());
309 const units: [string, number][] = [
310 ["day", 86_400_000],
311 ["hour", 3_600_000],
312 ["minute", 60_000],
313 ];
314 for (const [unit, size] of units) {
315 if (elapsed >= size) {
316 const n = Math.floor(elapsed / size);
317 return `${n} ${unit}${n === 1 ? "" : "s"} ago`;
318 }
319 }
320 return "just now";
321}
322
Chat and workspace agents: channels, DMs and named agents you talk to323/** Where a conversation is, under the workspace. */
324export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
325 return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
326}
327
328/** Unread across the sidebar, leaving out what is muted: the rail's badge. */
329export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } {
330 let unread = 0;
331 let mentions = 0;
332 for (const entry of entries) {
333 mentions += entry.mentions;
334 if (!entry.muted) unread += entry.unread;
335 }
336 return { unread, mentions };
337}
338
339/** Channel names: lowercase words and digits joined by dashes, as people type them. */
340export function channelName(typed: string): string {
341 return typed
342 .toLowerCase()
343 .replace(/^#/, "")
344 .replace(/[\s_]+/g, "-")
345 .replace(/[^a-z0-9-]/g, "")
346 .replace(/-{2,}/g, "-")
347 .slice(0, 80);
348}
349
350/** Paths under `-/chat/` that are pages, not channels: never a channel's name. */
351export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const;
352
353/** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */
354export function principalField(kind: Principal["kind"], key: string): string {
355 return `${kind}:${key}`;
356}
357
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar358/**
359 * A member's handle as it shows after `@`: a person's username in its
360 * chosen case, an agent's handle. The same rule as `memberHandle` in
361 * @g1t/contracts, kept here so this file stays pure for its tests.
362 */
363export function shownHandle(member: { name: string; display_username?: string | null }): string {
364 const display = member.display_username;
365 return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name;
366}
367
368/**
369 * The name shown for a member, everywhere in chat (messages, the sidebar,
370 * typing, cards): their display name, else their handle in its chosen
371 * case. The same rule as `memberName` in @g1t/contracts, which the chat
372 * service and its notifications follow.
373 */
374export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string {
375 return member.display_name?.trim() || shownHandle(member);
376}
377
378/**
379 * Who each lowercased handle is, for drawing `@name` as the name people
380 * know: the workspace's people and agents.
381 */
382export function mentionNames(people: readonly Mentionable[]): Map<string, string> {
383 const names = new Map<string, string>();
384 for (const person of people) names.set(person.name.toLowerCase(), shownName(person));
385 return names;
Chat and workspace agents: channels, DMs and named agents you talk to386}
387
In a group message with several agents, a message that names no agent goes to the one you are talking with: the agents that answered you last, else the ones you last mentioned, else, before anyone has been addressed, all of them; a message that mentions agents still goes only to those, and a conversation with one agent is as it was. Each agent is also told, when several are here, who you are talking with, so one that is woken alongside answers only what is clearly for it and does not redo a colleague's work. And an agent shows as typing for as long as it works, not for eight seconds: its desk says so every five seconds until the answer is posted, and after ten seconds the line reads that it is still working on it. The chat and agents guides say so.388/** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */
389const AGENT_DOING = ["is reading the thread", "is thinking it through", "is looking into it", "is reading the diff", "is checking the details", "is drafting a reply"];
390
391export function agentDoing(id: string): string {
392 let hash = 0;
393 for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0;
394 return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!;
395}
396
397/**
398 * After this long shown as typing, an agent is said to be still working
399 * instead: a reply that reads code, consults a colleague or starts a
400 * session takes a minute or more, and it keeps saying so the whole time.
401 */
402export const WORKING_AFTER_MS = 10_000;
403
404/** Someone shown as typing, and since when (the first typing event for them, kept across the ones that follow). */
405export type Typer = { member: Pick<MemberProfile, "kind" | "id" | "display_name" | "name"> & { display_username?: string | null }; since: number };
406
407/**
408 * The typing line: who, and what they are doing. A person is typing. An
409 * agent is doing what its phrase says, then, past `WORKING_AFTER_MS`,
410 * still working on it; several agents are on it together, and a mix of
411 * people and agents are typing. Null when nobody is.
412 */
413export function typingLabel(typers: readonly Typer[], now: number): { who: string; doing: string } | null {
414 if (!typers.length) return null;
415 const names = typers.map((t) => shownName(t.member));
416 const agents = typers.every((t) => t.member.kind === "agent");
417 const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : agents ? "Several agents" : "Several people";
418 const long = typers.some((t) => now - t.since >= WORKING_AFTER_MS);
419 if (typers.length === 1) {
420 const [one] = typers;
421 return { who, doing: one!.member.kind === "agent" ? (long ? "is still working on it" : agentDoing(one!.member.id)) : "is typing" };
422 }
423 return { who, doing: agents ? (long ? "are still working on it" : "are on it") : "are typing" };
424}
425
Chat and workspace agents: channels, DMs and named agents you talk to426/** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
427export function backoff(attempt: number, random: () => number = Math.random): number {
428 const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
429 return Math.round(base * (0.75 + random() * 0.5));
430}

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