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 to | 1 | /** |
| 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 | */ | |
| 10 | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal } from "@g1t/contracts"; | |
| 11 | ||
| 12 | /** Two messages from the same author this close together share one header. */ | |
| 13 | export const GROUP_GAP_MS = 5 * 60 * 1000; | |
| 14 | ||
| 15 | /** A message on its way: shown at once, then swapped for what the service saved. */ | |
| 16 | export type ShownMessage = ChatMessage & { pending?: boolean; failed?: boolean; client_id?: string }; | |
| 17 | ||
| 18 | export type TimelineRow = | |
| 19 | | { kind: "day"; key: string; label: string } | |
| 20 | | { kind: "message"; key: string; message: ShownMessage; head: boolean }; | |
| 21 | ||
| 22 | function 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`). */ | |
| 27 | export 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. */ | |
| 33 | export 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 | */ | |
| 54 | export 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 | */ | |
| 83 | export 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. */ | |
| 97 | export type Mentionable = { | |
| 98 | kind: "user" | "agent"; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 99 | /** What follows the `@`: lowercased, as it is kept. */ |
| Chat and workspace agents: channels, DMs and named agents you talk to | 100 | name: string; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 101 | /** 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 to | 103 | display_name: string; |
| 104 | avatar: string | null; | |
| 105 | /** An agent's role, shown beside it. */ | |
| 106 | role?: string | null; | |
| 107 | }; | |
| 108 | ||
| 109 | export type MentionQuery = { start: number; typed: string; options: Mentionable[] }; | |
| 110 | ||
| 111 | /** Most suggestions at once. */ | |
| 112 | export 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 | */ | |
| 119 | export 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 | // --------------------------------------------------------------------------- | |
| 137 | // A message's text: a little Markdown, parsed into blocks and spans. | |
| 138 | ||
| 139 | export type Span = | |
| 140 | | { t: "text"; v: string } | |
| 141 | | { t: "code"; v: string } | |
| 142 | | { t: "strong"; c: Span[] } | |
| 143 | | { t: "em"; c: Span[] } | |
| 144 | | { t: "del"; c: Span[] } | |
| 145 | | { t: "link"; href: string; c: Span[] } | |
| 146 | | { t: "mention"; name: string } | |
| 147 | | { t: "channel"; name: string } | |
| 148 | | { t: "ref"; repo: string | null; number: number }; | |
| 149 | ||
| 150 | export type Block = | |
| 151 | | { t: "p"; lines: Span[][] } | |
| 152 | | { t: "code"; lang: string | null; v: string } | |
| 153 | | { t: "list"; ordered: boolean; items: Span[][] } | |
| 154 | | { t: "quote"; lines: Span[][] }; | |
| 155 | ||
| 156 | /** Where a link may go: the web, mail, or a page on this site. */ | |
| 157 | export function safeHref(href: string): string | null { | |
| 158 | const trimmed = href.trim(); | |
| 159 | if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed; | |
| 160 | if (/^mailto:[^\s]+$/i.test(trimmed)) return trimmed; | |
| 161 | if (/^\/(?!\/)[^\s]*$/.test(trimmed)) return trimmed; | |
| 162 | return null; | |
| 163 | } | |
| 164 | ||
| 165 | const INLINE = new RegExp( | |
| 166 | [ | |
| 167 | "`([^`\\n]+)`", // 1 code | |
| 168 | "\\*\\*([^*\\n](?:[^\\n]*?[^*\\n])?)\\*\\*", // 2 strong | |
| 169 | "~~([^~\\n]+)~~", // 3 del | |
| 170 | "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 4 em with * | |
| 171 | "(?<![\\w])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w])", // 5 em with _ | |
| 172 | "\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)", // 6 text, 7 href | |
| 173 | "(https?:\\/\\/[^\\s<>()]+[^\\s<>().,;:!?'\"])", // 8 bare link | |
| 174 | "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 9 mention, 10 team | |
| 175 | "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 11 repo, 12 number | |
| 176 | "(?<![\\w&#/])#(\\d+)\\b", // 13 number alone | |
| 177 | "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 14 channel | |
| 178 | ].join("|"), | |
| 179 | "g", | |
| 180 | ); | |
| 181 | ||
| 182 | /** One line of text as spans. */ | |
| 183 | export function inline(text: string): Span[] { | |
| 184 | const spans: Span[] = []; | |
| 185 | let at = 0; | |
| 186 | const push = (span: Span) => { | |
| 187 | const last = spans[spans.length - 1]; | |
| 188 | if (span.t === "text" && last?.t === "text") last.v += span.v; | |
| 189 | else spans.push(span); | |
| 190 | }; | |
| 191 | // Its own copy: the spans inside bold and the like are parsed on the way. | |
| 192 | const pattern = new RegExp(INLINE.source, "g"); | |
| 193 | for (let m = pattern.exec(text); m; m = pattern.exec(text)) { | |
| 194 | if (m.index > at) push({ t: "text", v: text.slice(at, m.index) }); | |
| 195 | at = m.index + m[0].length; | |
| 196 | if (m[1] != null) push({ t: "code", v: m[1] }); | |
| 197 | else if (m[2] != null) push({ t: "strong", c: inline(m[2]) }); | |
| 198 | else if (m[3] != null) push({ t: "del", c: inline(m[3]) }); | |
| 199 | else if (m[4] != null) push({ t: "em", c: inline(m[4]) }); | |
| 200 | else if (m[5] != null) push({ t: "em", c: inline(m[5]) }); | |
| 201 | else if (m[6] != null) { | |
| 202 | const href = safeHref(m[7]!); | |
| 203 | if (href) push({ t: "link", href, c: inline(m[6]) }); | |
| 204 | else push({ t: "text", v: m[0] }); | |
| 205 | } else if (m[8] != null) push({ t: "link", href: m[8], c: [{ t: "text", v: m[8] }] }); | |
| 206 | else if (m[9] != null) push({ t: "mention", name: m[10] ? `${m[9]}/${m[10]}` : m[9] }); | |
| 207 | else if (m[11] != null) push({ t: "ref", repo: m[11], number: Number(m[12]) }); | |
| 208 | else if (m[13] != null) push({ t: "ref", repo: null, number: Number(m[13]) }); | |
| 209 | else if (m[14] != null) push({ t: "channel", name: m[14] }); | |
| 210 | } | |
| 211 | if (at < text.length) push({ t: "text", v: text.slice(at) }); | |
| 212 | return spans; | |
| 213 | } | |
| 214 | ||
| 215 | /** A message's whole text as blocks: paragraphs, fenced code, lists and quotes. */ | |
| 216 | export function blocks(text: string): Block[] { | |
| 217 | const out: Block[] = []; | |
| 218 | const lines = text.replace(/\r\n?/g, "\n").split("\n"); | |
| 219 | let i = 0; | |
| 220 | let paragraph: Span[][] = []; | |
| 221 | const flush = () => { | |
| 222 | // Blank lines at either end of a paragraph say nothing. | |
| 223 | while (paragraph.length && paragraph[0]!.length === 0) paragraph.shift(); | |
| 224 | while (paragraph.length && paragraph[paragraph.length - 1]!.length === 0) paragraph.pop(); | |
| 225 | if (paragraph.length) out.push({ t: "p", lines: paragraph }); | |
| 226 | paragraph = []; | |
| 227 | }; | |
| 228 | while (i < lines.length) { | |
| 229 | const line = lines[i]!; | |
| 230 | const fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line); | |
| 231 | if (fence) { | |
| 232 | flush(); | |
| 233 | const body: string[] = []; | |
| 234 | i++; | |
| 235 | while (i < lines.length && !/^\s*```\s*$/.test(lines[i]!)) body.push(lines[i++]!); | |
| 236 | i++; // the closing fence, or the end | |
| 237 | out.push({ t: "code", lang: fence[1] || null, v: body.join("\n") }); | |
| 238 | continue; | |
| 239 | } | |
| 240 | const bullet = /^\s*[-*•]\s+(.*)$/.exec(line); | |
| 241 | const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line); | |
| 242 | if (bullet || numbered) { | |
| 243 | flush(); | |
| 244 | const ordered = !bullet; | |
| 245 | const items: Span[][] = []; | |
| 246 | while (i < lines.length) { | |
| 247 | const item = ordered ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]!) : /^\s*[-*•]\s+(.*)$/.exec(lines[i]!); | |
| 248 | if (!item) break; | |
| 249 | items.push(inline(item[1]!)); | |
| 250 | i++; | |
| 251 | } | |
| 252 | out.push({ t: "list", ordered, items }); | |
| 253 | continue; | |
| 254 | } | |
| 255 | if (/^\s*>\s?/.test(line)) { | |
| 256 | flush(); | |
| 257 | const quoted: Span[][] = []; | |
| 258 | while (i < lines.length && /^\s*>\s?/.test(lines[i]!)) quoted.push(inline(lines[i++]!.replace(/^\s*>\s?/, ""))); | |
| 259 | out.push({ t: "quote", lines: quoted }); | |
| 260 | continue; | |
| 261 | } | |
| 262 | if (line.trim() === "") { | |
| 263 | // A blank line ends a paragraph. | |
| 264 | flush(); | |
| 265 | i++; | |
| 266 | continue; | |
| 267 | } | |
| 268 | paragraph.push(inline(line)); | |
| 269 | i++; | |
| 270 | } | |
| 271 | flush(); | |
| 272 | return out; | |
| 273 | } | |
| 274 | ||
| 275 | /** Whether a message is only emoji, a few of them: shown larger, as people expect. */ | |
| 276 | export function onlyEmoji(text: string): boolean { | |
| 277 | const trimmed = text.trim(); | |
| 278 | if (!trimmed || trimmed.length > 24) return false; | |
| 279 | return /^(?:\p{Extended_Pictographic}|\p{Emoji_Component}||️|\s)+$/u.test(trimmed) && /\p{Extended_Pictographic}/u.test(trimmed); | |
| 280 | } | |
| 281 | ||
| 282 | // --------------------------------------------------------------------------- | |
| 283 | // The sidebar. | |
| 284 | ||
| 285 | export type ChatFilter = "all" | "unread" | "mentions"; | |
| 286 | ||
| 287 | /** The sidebar's rows that pass the filter and the "Jump to" text. */ | |
| 288 | export function filterEntries(entries: ChatSidebarEntry[], filter: ChatFilter, query: string): ChatSidebarEntry[] { | |
| 289 | const q = query.trim().toLowerCase().replace(/^[#@]/, ""); | |
| 290 | return entries.filter((entry) => { | |
| 291 | if (filter === "unread" && entry.unread === 0 && entry.mentions === 0) return false; | |
| 292 | if (filter === "mentions" && entry.mentions === 0) return false; | |
| 293 | if (!q) return true; | |
| 294 | if (entry.title.toLowerCase().includes(q)) return true; | |
| 295 | return entry.others.some((member) => member.name.toLowerCase().includes(q) || member.display_name.toLowerCase().includes(q)); | |
| 296 | }); | |
| 297 | } | |
| 298 | ||
| 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) | 299 | /** Whether a conversation is a direct message with one agent: it is listed under Agents, not Direct messages. */ |
| 300 | export function agentDmOf(entry: ChatSidebarEntry): string | null { | |
| 301 | const only = entry.others.length === 1 ? entry.others[0]! : null; | |
| 302 | return entry.channel.kind === "dm" && only?.kind === "agent" ? only.id : null; | |
| 303 | } | |
| 304 | ||
| 305 | /** | |
| 306 | * 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. | |
| 310 | */ | |
| 311 | export function sections(entries: ChatSidebarEntry[]): { | |
| 312 | pinned: ChatSidebarEntry[]; | |
| 313 | channels: ChatSidebarEntry[]; | |
| 314 | agentDms: Map<string, ChatSidebarEntry>; | |
| 315 | dms: ChatSidebarEntry[]; | |
| 316 | } { | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 317 | const byName = (a: ChatSidebarEntry, b: ChatSidebarEntry) => a.title.localeCompare(b.title); |
| 318 | // Direct messages: the latest conversation first, as people scan them. | |
| 319 | const byRecent = (a: ChatSidebarEntry, b: ChatSidebarEntry) => | |
| 320 | (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) | 321 | const agentDms = new Map<string, ChatSidebarEntry>(); |
| 322 | for (const entry of entries) { | |
| 323 | const agent = agentDmOf(entry); | |
| 324 | if (agent) agentDms.set(agent, entry); | |
| 325 | } | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 326 | 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) | 327 | pinned: entries.filter((e) => e.starred).sort(byName), |
| Chat and workspace agents: channels, DMs and named agents you talk to | 328 | 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) | 329 | agentDms, |
| 330 | 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 to | 331 | }; |
| 332 | } | |
| 333 | ||
| 334 | /** Where a conversation is, under the workspace. */ | |
| 335 | export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string { | |
| 336 | return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| 337 | } | |
| 338 | ||
| 339 | /** Unread across the sidebar, leaving out what is muted: the rail's badge. */ | |
| 340 | export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } { | |
| 341 | let unread = 0; | |
| 342 | let mentions = 0; | |
| 343 | for (const entry of entries) { | |
| 344 | mentions += entry.mentions; | |
| 345 | if (!entry.muted) unread += entry.unread; | |
| 346 | } | |
| 347 | return { unread, mentions }; | |
| 348 | } | |
| 349 | ||
| 350 | /** Channel names: lowercase words and digits joined by dashes, as people type them. */ | |
| 351 | export function channelName(typed: string): string { | |
| 352 | return typed | |
| 353 | .toLowerCase() | |
| 354 | .replace(/^#/, "") | |
| 355 | .replace(/[\s_]+/g, "-") | |
| 356 | .replace(/[^a-z0-9-]/g, "") | |
| 357 | .replace(/-{2,}/g, "-") | |
| 358 | .slice(0, 80); | |
| 359 | } | |
| 360 | ||
| 361 | /** Paths under `-/chat/` that are pages, not channels: never a channel's name. */ | |
| 362 | export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const; | |
| 363 | ||
| 364 | /** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */ | |
| 365 | export function principalField(kind: Principal["kind"], key: string): string { | |
| 366 | return `${kind}:${key}`; | |
| 367 | } | |
| 368 | ||
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 369 | /** |
| 370 | * A member's handle as it shows after `@`: a person's username in its | |
| 371 | * chosen case, an agent's handle. The same rule as `memberHandle` in | |
| 372 | * @g1t/contracts, kept here so this file stays pure for its tests. | |
| 373 | */ | |
| 374 | export function shownHandle(member: { name: string; display_username?: string | null }): string { | |
| 375 | const display = member.display_username; | |
| 376 | return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name; | |
| 377 | } | |
| 378 | ||
| 379 | /** | |
| 380 | * The name shown for a member, everywhere in chat (messages, the sidebar, | |
| 381 | * typing, cards): their display name, else their handle in its chosen | |
| 382 | * case. The same rule as `memberName` in @g1t/contracts, which the chat | |
| 383 | * service and its notifications follow. | |
| 384 | */ | |
| 385 | export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string { | |
| 386 | return member.display_name?.trim() || shownHandle(member); | |
| 387 | } | |
| 388 | ||
| 389 | /** | |
| 390 | * Who each lowercased handle is, for drawing `@name` as the name people | |
| 391 | * know: the workspace's people and agents. | |
| 392 | */ | |
| 393 | export function mentionNames(people: readonly Mentionable[]): Map<string, string> { | |
| 394 | const names = new Map<string, string>(); | |
| 395 | for (const person of people) names.set(person.name.toLowerCase(), shownName(person)); | |
| 396 | return names; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 397 | } |
| 398 | ||
| 399 | /** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */ | |
| 400 | export function backoff(attempt: number, random: () => number = Math.random): number { | |
| 401 | const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt)); | |
| 402 | return Math.round(base * (0.75 + random() * 0.5)); | |
| 403 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.