Skip to content
326 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 */
10import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts";
11
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. */
154export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string): ChatSidebarEntry[] {
155 const q = query.trim().toLowerCase().replace(/^[#@]/, "");
156 return entries.filter((entry) => {
157 if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false;
158 if (filter === "mentions" && entry.mentions === 0) return false;
159 if (!q) return true;
160 if (entry.title.toLowerCase().includes(q)) return true;
161 return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q));
162 });
163}
164
Merge branch 'chat-sidebar' into fast-push165/** 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)166export function agentDmOf(entry: ChatSidebarEntry): string | null {
167 const only = entry.others.length === 1 ? entry.others[0]! : null;
168 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
169}
170
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.171type Members = Pick<MemberProfile, "kind">[];
172
173/** Whether a direct message is with agents alone (one or several, nobody else). */
174export function agentsOnlyDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
175 return entry.channel.kind === "dm" && entry.others.length > 0 && entry.others.every((member) => member.kind === "agent");
176}
177
178/**
179 * Whether a direct message is mixed: at least one person besides you and
180 * at least one agent in it. Such a conversation lists under People (a
181 * person is in it) and says, everywhere it shows, that an agent is too.
182 */
183export function mixedDm(entry: Pick<ChatSidebarEntry, "channel" | "others">): boolean {
184 return entry.channel.kind === "dm" && isMixed(entry.others);
185}
186
187/** Whether the others in a conversation are a person or more and an agent or more. */
188export function isMixed(others: Members): boolean {
189 return others.some((member) => member.kind === "agent") && others.some((member) => member.kind === "user");
190}
191
192/** The agents among a conversation's other members. */
193export function agentsAmong<M extends Pick<MemberProfile, "kind">>(others: readonly M[]): M[] {
194 return others.filter((member) => member.kind === "agent");
195}
196
197/**
198 * The composer's placeholder in a direct message: "Message @mike and @sam
199 * (agent)", each agent said to be one, so nobody writes to a person and
200 * reaches a bot unawares. A conversation with yourself alone is for notes.
201 */
202export function dmPlaceholder(others: readonly Pick<MemberProfile, "kind" | "name" | "display_username">[]): string {
203 if (others.length === 0) return "Message yourself";
204 const names = others.map((member) => `@${shownHandle(member)}${member.kind === "agent" ? " (agent)" : ""}`);
205 const list = names.length <= 2 ? names.join(" and ") : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
206 return `Message ${list}`;
207}
208
209/**
210 * The one-line notice above a mixed conversation's first message, the
211 * first time a person opens it: who the agents are and that they read and
212 * can act on what is said.
213 */
214export function mixedNotice(others: readonly Pick<MemberProfile, "kind" | "name" | "display_name" | "display_username">[]): string {
215 const agents = agentsAmong(others).map(shownName);
216 if (agents.length === 0) return "";
217 if (agents.length === 1) return `${agents[0]} is an agent and reads everything here; it can act on what you say.`;
218 const list = `${agents.slice(0, -1).join(", ")} and ${agents[agents.length - 1]}`;
219 return `${list} are agents and read everything here; they can act on what you say.`;
220}
221
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)222/**
223 * 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.224 * `starred` preference), channels, then direct messages in two buckets,
225 * each the latest first: People, every conversation with a person in it
226 * (a mixed one too, since a person is in it), and Agents, conversations
227 * with agents alone. `agentDms` finds the direct message with each agent
228 * on its own, by agent id, so the Agents section lists only the agents
229 * 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)230 */
231export function sections(entries: ChatSidebarEntry[]): {
232 pinned: ChatSidebarEntry[];
233 channels: ChatSidebarEntry[];
234 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.235 people: ChatSidebarEntry[];
236 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)237} {
Chat and workspace agents: channels, DMs and named agents you talk to238 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
239 // Direct messages: the latest conversation first, as people scan them.
240 const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) =>
241 (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)242 const agentDms = new Map<string, ChatSidebarEntry>();
243 for (const entry of entries) {
244 const agent = agentDmOf(entry);
245 if (agent) agentDms.set(agent, entry);
246 }
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.247 const dms = entries.filter((e) => !e.starred && e.channel.kind === "dm");
Chat and workspace agents: channels, DMs and named agents you talk to248 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)249 pinned: entries.filter((e) => e.starred).sort(byName),
Chat and workspace agents: channels, DMs and named agents you talk to250 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)251 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.252 people: dms.filter((e) => !agentsOnlyDm(e)).sort(byRecent),
253 agents: dms.filter(agentsOnlyDm).sort(byRecent),
Chat and workspace agents: channels, DMs and named agents you talk to254 };
255}
256
257/** Where a conversation is, under the workspace. */
258export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
259 return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
260}
261
262/** Unread across the sidebar, leaving out what is muted: the rail's badge. */
263export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } {
264 let unread = 0;
265 let mentions = 0;
266 for (const entry of entries) {
267 mentions += entry.mentions;
268 if (!entry.muted) unread += entry.unread;
269 }
270 return { unread, mentions };
271}
272
273/** Channel names: lowercase words and digits joined by dashes, as people type them. */
274export function channelName(typed: string): string {
275 return typed
276 .toLowerCase()
277 .replace(/^#/, "")
278 .replace(/[\s_]+/g, "-")
279 .replace(/[^a-z0-9-]/g, "")
280 .replace(/-{2,}/g, "-")
281 .slice(0, 80);
282}
283
284/** Paths under `-/chat/` that are pages, not channels: never a channel's name. */
285export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const;
286
287/** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */
288export function principalField(kind: Principal["kind"], key: string): string {
289 return `${kind}:${key}`;
290}
291
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar292/**
293 * A member's handle as it shows after `@`: a person's username in its
294 * chosen case, an agent's handle. The same rule as `memberHandle` in
295 * @g1t/contracts, kept here so this file stays pure for its tests.
296 */
297export function shownHandle(member: { name: string; display_username?: string | null }): string {
298 const display = member.display_username;
299 return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name;
300}
301
302/**
303 * The name shown for a member, everywhere in chat (messages, the sidebar,
304 * typing, cards): their display name, else their handle in its chosen
305 * case. The same rule as `memberName` in @g1t/contracts, which the chat
306 * service and its notifications follow.
307 */
308export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string {
309 return member.display_name?.trim() || shownHandle(member);
310}
311
312/**
313 * Who each lowercased handle is, for drawing `@name` as the name people
314 * know: the workspace's people and agents.
315 */
316export function mentionNames(people: readonly Mentionable[]): Map<string, string> {
317 const names = new Map<string, string>();
318 for (const person of people) names.set(person.name.toLowerCase(), shownName(person));
319 return names;
Chat and workspace agents: channels, DMs and named agents you talk to320}
321
322/** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
323export function backoff(attempt: number, random: () => number = Math.random): number {
324 const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
325 return Math.round(base * (0.75 + random() * 0.5));
326}

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