Skip to content
376 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";
99 /** What follows the `@`. */
100 name: string;
101 display_name: string;
102 avatar: string | null;
103 /** An agent's role, shown beside it. */
104 role?: string | null;
105};
106
107export type MentionQuery = { start: number; typed: string; options: Mentionable[] };
108
109/** Most suggestions at once. */
110export const MAX_MENTIONS = 6;
111
112/**
113 * The `@…` just before the caret and who it could be: names that start with
114 * what is typed first, then display names with a word that does. Null when
115 * the caret is not in a mention, or nothing fits.
116 */
117export function mentionQuery(text: string, caret: number, people: readonly Mentionable[]): MentionQuery | null {
118 const match = /(?:^|[\s(])@([\w.-]{0,40})$/.exec(text.slice(0, caret));
119 if (!match) return null;
120 const typed = match[1]!.toLowerCase();
121 const start = caret - match[1]!.length - 1;
122 const first: Mentionable[] = [];
123 const then: Mentionable[] = [];
124 for (const person of people) {
125 const name = person.name.toLowerCase();
126 if (name === typed) continue;
127 if (name.startsWith(typed)) first.push(person);
128 else if (typed && person.display_name.toLowerCase().split(/\s+/).some((word) => word.startsWith(typed))) then.push(person);
129 }
130 const options = [...first, ...then].slice(0, MAX_MENTIONS);
131 return options.length > 0 ? { start, typed, options } : null;
132}
133
134// ---------------------------------------------------------------------------
135// A message's text: a little Markdown, parsed into blocks and spans.
136
137export type Span =
138 | { t: "text"; v: string }
139 | { t: "code"; v: string }
140 | { t: "strong"; c: Span[] }
141 | { t: "em"; c: Span[] }
142 | { t: "del"; c: Span[] }
143 | { t: "link"; href: string; c: Span[] }
144 | { t: "mention"; name: string }
145 | { t: "channel"; name: string }
146 | { t: "ref"; repo: string | null; number: number };
147
148export type Block =
149 | { t: "p"; lines: Span[][] }
150 | { t: "code"; lang: string | null; v: string }
151 | { t: "list"; ordered: boolean; items: Span[][] }
152 | { t: "quote"; lines: Span[][] };
153
154/** Where a link may go: the web, mail, or a page on this site. */
155export function safeHref(href: string): string | null {
156 const trimmed = href.trim();
157 if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed;
158 if (/^mailto:[^\s]+$/i.test(trimmed)) return trimmed;
159 if (/^\/(?!\/)[^\s]*$/.test(trimmed)) return trimmed;
160 return null;
161}
162
163const INLINE = new RegExp(
164 [
165 "`([^`\\n]+)`", // 1 code
166 "\\*\\*([^*\\n](?:[^\\n]*?[^*\\n])?)\\*\\*", // 2 strong
167 "~~([^~\\n]+)~~", // 3 del
168 "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 4 em with *
169 "(?<![\\w])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w])", // 5 em with _
170 "\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)", // 6 text, 7 href
171 "(https?:\\/\\/[^\\s<>()]+[^\\s<>().,;:!?'\"])", // 8 bare link
172 "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 9 mention, 10 team
173 "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 11 repo, 12 number
174 "(?<![\\w&#/])#(\\d+)\\b", // 13 number alone
175 "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 14 channel
176 ].join("|"),
177 "g",
178);
179
180/** One line of text as spans. */
181export function inline(text: string): Span[] {
182 const spans: Span[] = [];
183 let at = 0;
184 const push = (span: Span) => {
185 const last = spans[spans.length - 1];
186 if (span.t === "text" && last?.t === "text") last.v += span.v;
187 else spans.push(span);
188 };
189 // Its own copy: the spans inside bold and the like are parsed on the way.
190 const pattern = new RegExp(INLINE.source, "g");
191 for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
192 if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
193 at = m.index + m[0].length;
194 if (m[1] != null) push({ t: "code", v: m[1] });
195 else if (m[2] != null) push({ t: "strong", c: inline(m[2]) });
196 else if (m[3] != null) push({ t: "del", c: inline(m[3]) });
197 else if (m[4] != null) push({ t: "em", c: inline(m[4]) });
198 else if (m[5] != null) push({ t: "em", c: inline(m[5]) });
199 else if (m[6] != null) {
200 const href = safeHref(m[7]!);
201 if (href) push({ t: "link", href, c: inline(m[6]) });
202 else push({ t: "text", v: m[0] });
203 } else if (m[8] != null) push({ t: "link", href: m[8], c: [{ t: "text", v: m[8] }] });
204 else if (m[9] != null) push({ t: "mention", name: m[10] ? `${m[9]}/${m[10]}` : m[9] });
205 else if (m[11] != null) push({ t: "ref", repo: m[11], number: Number(m[12]) });
206 else if (m[13] != null) push({ t: "ref", repo: null, number: Number(m[13]) });
207 else if (m[14] != null) push({ t: "channel", name: m[14] });
208 }
209 if (at < text.length) push({ t: "text", v: text.slice(at) });
210 return spans;
211}
212
213/** A message's whole text as blocks: paragraphs, fenced code, lists and quotes. */
214export function blocks(text: string): Block[] {
215 const out: Block[] = [];
216 const lines = text.replace(/\r\n?/g, "\n").split("\n");
217 let i = 0;
218 let paragraph: Span[][] = [];
219 const flush = () => {
220 // Blank lines at either end of a paragraph say nothing.
221 while (paragraph.length && paragraph[0]!.length === 0) paragraph.shift();
222 while (paragraph.length && paragraph[paragraph.length - 1]!.length === 0) paragraph.pop();
223 if (paragraph.length) out.push({ t: "p", lines: paragraph });
224 paragraph = [];
225 };
226 while (i < lines.length) {
227 const line = lines[i]!;
228 const fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line);
229 if (fence) {
230 flush();
231 const body: string[] = [];
232 i++;
233 while (i < lines.length && !/^\s*```\s*$/.test(lines[i]!)) body.push(lines[i++]!);
234 i++; // the closing fence, or the end
235 out.push({ t: "code", lang: fence[1] || null, v: body.join("\n") });
236 continue;
237 }
238 const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
239 const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
240 if (bullet || numbered) {
241 flush();
242 const ordered = !bullet;
243 const items: Span[][] = [];
244 while (i < lines.length) {
245 const item = ordered ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]!) : /^\s*[-*•]\s+(.*)$/.exec(lines[i]!);
246 if (!item) break;
247 items.push(inline(item[1]!));
248 i++;
249 }
250 out.push({ t: "list", ordered, items });
251 continue;
252 }
253 if (/^\s*>\s?/.test(line)) {
254 flush();
255 const quoted: Span[][] = [];
256 while (i < lines.length && /^\s*>\s?/.test(lines[i]!)) quoted.push(inline(lines[i++]!.replace(/^\s*>\s?/, "")));
257 out.push({ t: "quote", lines: quoted });
258 continue;
259 }
260 if (line.trim() === "") {
261 // A blank line ends a paragraph.
262 flush();
263 i++;
264 continue;
265 }
266 paragraph.push(inline(line));
267 i++;
268 }
269 flush();
270 return out;
271}
272
273/** Whether a message is only emoji, a few of them: shown larger, as people expect. */
274export function onlyEmoji(text: string): boolean {
275 const trimmed = text.trim();
276 if (!trimmed || trimmed.length > 24) return false;
277 return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}|‍|️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed);
278}
279
280// ---------------------------------------------------------------------------
281// The sidebar.
282
283export type ChatFilter = "all" | "unread" | "mentions";
284
285/** The sidebar's rows that pass the filter and the "Jump to" text. */
286export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string): ChatSidebarEntry[] {
287 const q = query.trim().toLowerCase().replace(/^[#@]/, "");
288 return entries.filter((entry) => {
289 if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false;
290 if (filter === "mentions" && entry.mentions === 0) return false;
291 if (!q) return true;
292 if (entry.title.toLowerCase().includes(q)) return true;
293 return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q));
294 });
295}
296
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)297/** Whether a conversation is a direct message with one agent: it is listed under Agents, not Direct messages. */
298export function agentDmOf(entry: ChatSidebarEntry): string | null {
299 const only = entry.others.length === 1 ? entry.others[0]! : null;
300 return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null;
301}
302
303/**
304 * The sidebar's sections, in the order they show: what is pinned (the
305 * `starred` preference), channels, the direct message with each agent (by
306 * agent id, for the Agents section), and direct messages with people,
307 * groups that mix people and agents included.
308 */
309export function sections(entries: ChatSidebarEntry[]): {
310 pinned: ChatSidebarEntry[];
311 channels: ChatSidebarEntry[];
312 agentDms: Map<string, ChatSidebarEntry>;
313 dms: ChatSidebarEntry[];
314} {
Chat and workspace agents: channels, DMs and named agents you talk to315 const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title);
316 // Direct messages: the latest conversation first, as people scan them.
317 const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) =>
318 (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)319 const agentDms = new Map<string, ChatSidebarEntry>();
320 for (const entry of entries) {
321 const agent = agentDmOf(entry);
322 if (agent) agentDms.set(agent, entry);
323 }
Chat and workspace agents: channels, DMs and named agents you talk to324 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)325 pinned: entries.filter((e) => e.starred).sort(byName),
Chat and workspace agents: channels, DMs and named agents you talk to326 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)327 agentDms,
328 dms: entries.filter((e) => !e.starred && e.channel.kind === "dm" && !agentDmOf(e)).sort(byRecent),
Chat and workspace agents: channels, DMs and named agents you talk to329 };
330}
331
332/** Where a conversation is, under the workspace. */
333export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string {
334 return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`;
335}
336
337/** Unread across the sidebar, leaving out what is muted: the rail's badge. */
338export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } {
339 let unread = 0;
340 let mentions = 0;
341 for (const entry of entries) {
342 mentions += entry.mentions;
343 if (!entry.muted) unread += entry.unread;
344 }
345 return { unread, mentions };
346}
347
348/** Channel names: lowercase words and digits joined by dashes, as people type them. */
349export function channelName(typed: string): string {
350 return typed
351 .toLowerCase()
352 .replace(/^#/, "")
353 .replace(/[\s_]+/g, "-")
354 .replace(/[^a-z0-9-]/g, "")
355 .replace(/-{2,}/g, "-")
356 .slice(0, 80);
357}
358
359/** Paths under `-/chat/` that are pages, not channels: never a channel's name. */
360export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const;
361
362/** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */
363export function principalField(kind: Principal["kind"], key: string): string {
364 return `${kind}:${key}`;
365}
366
367/** The name shown for a member: their display name, else their handle. */
368export function shownName(member: Pick<MemberProfile, "display_name" | "name">): string {
369 return member.display_name?.trim() || member.name;
370}
371
372/** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */
373export function backoff(attempt: number, random: () => number = Math.random): number {
374 const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt));
375 return Math.round(base * (0.75 + random() * 0.5));
376}

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