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 | */ | |
| 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. | 10 | import type { ChatMessage, ChatSidebarEntry, MemberProfile, Principal, WorkspaceAgent } from "@g1t/contracts"; |
| Chat and workspace agents: channels, DMs and named agents you talk to | 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 | // --------------------------------------------------------------------------- | |
| Merge main into Artifacts Phase 2 | 137 | // 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 to | 138 | |
| Merge main into Artifacts Phase 2 | 139 | export { 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 to | 140 | |
| 141 | /** Whether a message is only emoji, a few of them: shown larger, as people expect. */ | |
| 142 | export 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 | ||
| 151 | export type ChatFilter = "all" | "unread" | "mentions"; | |
| 152 | ||
| 153 | /** The sidebar's rows that pass the filter and the "Jump to" text. */ | |
| 154 | export 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-push | 165 | /** 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) | 166 | export 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. | 171 | type Members = Pick<MemberProfile, "kind">[]; |
| 172 | ||
| 173 | /** 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. | 174 | export 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. | 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 | */ | |
| 183 | export 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. */ | |
| 188 | export 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. */ | |
| 193 | export 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 | */ | |
| 202 | export 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 | */ | |
| 214 | export 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 | */ |
| 231 | export 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 to | 238 | 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 to | 248 | 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 to | 250 | 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 to | 254 | }; |
| 255 | } | |
| 256 | ||
| 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. | 257 | /** |
| 258 | * The archived (or deleted) agent a direct message is with, when the | |
| 259 | * conversation is with agents alone and every one of them is archived or | |
| 260 | * deleted for good (docs.g1t.sh/guides/chat/, "Archived and deleted | |
| 261 | * agents"): the row is dimmed and says Archived (or Deleted, when the | |
| 262 | * agent returned is `deleted`), and a conversation with one agent opens | |
| 263 | * read-only. Null for any other conversation, a mixed one included, since | |
| 264 | * a person is still there to talk to. | |
| 265 | */ | |
| 266 | export function archivedAgentsDm<M extends Pick<MemberProfile, "kind" | "archived_at" | "deleted">>(entry: { channel: { kind: "channel" | "dm" }; others: readonly M[] }): M | null { | |
| 267 | if (!agentsOnlyDm(entry)) return null; | |
| 268 | return entry.others.every((member) => !!member.archived_at || member.deleted === true) ? (entry.others[0] ?? null) : null; | |
| 269 | } | |
| 270 | ||
| 271 | /** | |
| 272 | * The agents you have not talked to yet, for the sidebar's Agents section: | |
| 273 | * live ones (an archived agent is never offered, though its old conversation | |
| 274 | * stays listed), with no direct message yet, g1t first, then by name, | |
| 275 | * narrowed by what is typed in Jump to (handle, name or role). | |
| 276 | */ | |
| 277 | export function agentsToTalkTo<A extends Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "role" | "builtin" | "archived_at">>(agents: readonly A[], agentDms: ReadonlyMap<string, unknown>, query = ""): A[] { | |
| 278 | const q = query.trim().toLowerCase().replace(/^@/, ""); | |
| 279 | return agents | |
| 280 | .filter((agent) => !agent.archived_at && !agentDms.has(agent.id)) | |
| 281 | .sort((a, b) => Number(!!b.builtin) - Number(!!a.builtin) || a.display_name.localeCompare(b.display_name)) | |
| 282 | .filter((agent) => !q || agent.handle.includes(q) || agent.display_name.toLowerCase().includes(q) || agent.role.toLowerCase().includes(q)); | |
| 283 | } | |
| 284 | ||
| 285 | /** | |
| 286 | * What an archived agent's conversation says in place of its composer: | |
| 287 | * who archived it and when, as far as that is known. | |
| 288 | */ | |
| 289 | export function archivedAgentLine(agent: Pick<MemberProfile, "name" | "display_name" | "display_username" | "archived_at" | "archived_by">, now: Date = new Date()): string { | |
| 290 | return `${shownName(agent)} was ${archivedAgentWhen(agent, now).replace(/^A/, "a")}. It no longer answers here.`; | |
| 291 | } | |
| 292 | ||
| 293 | /** "Archived 2 days ago by @ana": under an archived agent's name, where its title was. */ | |
| 294 | export function archivedAgentWhen(agent: Pick<MemberProfile, "archived_at" | "archived_by">, now: Date = new Date()): string { | |
| 295 | const when = agent.archived_at ? ` ${timeSince(agent.archived_at, now)}` : ""; | |
| 296 | const by = agent.archived_by ? ` by @${agent.archived_by}` : ""; | |
| 297 | return `Archived${when}${by}`; | |
| 298 | } | |
| 299 | ||
| 300 | /** "3 days ago", "2 hours ago", "just now": for a line of prose. */ | |
| 301 | export function timeSince(at: string, now: Date = new Date()): string { | |
| 302 | const elapsed = Math.max(0, now.getTime() - new Date(at).getTime()); | |
| 303 | const units: [string, number][] = [ | |
| 304 | ["day", 86_400_000], | |
| 305 | ["hour", 3_600_000], | |
| 306 | ["minute", 60_000], | |
| 307 | ]; | |
| 308 | for (const [unit, size] of units) { | |
| 309 | if (elapsed >= size) { | |
| 310 | const n = Math.floor(elapsed / size); | |
| 311 | return `${n} ${unit}${n === 1 ? "" : "s"} ago`; | |
| 312 | } | |
| 313 | } | |
| 314 | return "just now"; | |
| 315 | } | |
| 316 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 317 | /** Where a conversation is, under the workspace. */ |
| 318 | export function channelPath(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }): string { | |
| 319 | return channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| 320 | } | |
| 321 | ||
| 322 | /** Unread across the sidebar, leaving out what is muted: the rail's badge. */ | |
| 323 | export function unreadTotals(entries: ChatSidebarEntry[]): { unread: number; mentions: number } { | |
| 324 | let unread = 0; | |
| 325 | let mentions = 0; | |
| 326 | for (const entry of entries) { | |
| 327 | mentions += entry.mentions; | |
| 328 | if (!entry.muted) unread += entry.unread; | |
| 329 | } | |
| 330 | return { unread, mentions }; | |
| 331 | } | |
| 332 | ||
| 333 | /** Channel names: lowercase words and digits joined by dashes, as people type them. */ | |
| 334 | export function channelName(typed: string): string { | |
| 335 | return typed | |
| 336 | .toLowerCase() | |
| 337 | .replace(/^#/, "") | |
| 338 | .replace(/[\s_]+/g, "-") | |
| 339 | .replace(/[^a-z0-9-]/g, "") | |
| 340 | .replace(/-{2,}/g, "-") | |
| 341 | .slice(0, 80); | |
| 342 | } | |
| 343 | ||
| 344 | /** Paths under `-/chat/` that are pages, not channels: never a channel's name. */ | |
| 345 | export const RESERVED_CHANNEL_NAMES = ["dm", "browse", "live", "api", "new"] as const; | |
| 346 | ||
| 347 | /** A principal as a form field carries it: `user:<username>` or `agent:<id>`. */ | |
| 348 | export function principalField(kind: Principal["kind"], key: string): string { | |
| 349 | return `${kind}:${key}`; | |
| 350 | } | |
| 351 | ||
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 352 | /** |
| 353 | * A member's handle as it shows after `@`: a person's username in its | |
| 354 | * chosen case, an agent's handle. The same rule as `memberHandle` in | |
| 355 | * @g1t/contracts, kept here so this file stays pure for its tests. | |
| 356 | */ | |
| 357 | export function shownHandle(member: { name: string; display_username?: string | null }): string { | |
| 358 | const display = member.display_username; | |
| 359 | return display && display.toLowerCase() === member.name.toLowerCase() ? display : member.name; | |
| 360 | } | |
| 361 | ||
| 362 | /** | |
| 363 | * The name shown for a member, everywhere in chat (messages, the sidebar, | |
| 364 | * typing, cards): their display name, else their handle in its chosen | |
| 365 | * case. The same rule as `memberName` in @g1t/contracts, which the chat | |
| 366 | * service and its notifications follow. | |
| 367 | */ | |
| 368 | export function shownName(member: Pick<MemberProfile, "display_name" | "name"> & { display_username?: string | null }): string { | |
| 369 | return member.display_name?.trim() || shownHandle(member); | |
| 370 | } | |
| 371 | ||
| 372 | /** | |
| 373 | * Who each lowercased handle is, for drawing `@name` as the name people | |
| 374 | * know: the workspace's people and agents. | |
| 375 | */ | |
| 376 | export function mentionNames(people: readonly Mentionable[]): Map<string, string> { | |
| 377 | const names = new Map<string, string>(); | |
| 378 | for (const person of people) names.set(person.name.toLowerCase(), shownName(person)); | |
| 379 | return names; | |
| Chat and workspace agents: channels, DMs and named agents you talk to | 380 | } |
| 381 | ||
| 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. | 382 | /** What an agent working on a reply says it is doing: one phrase per agent, so each sounds like itself. */ |
| 383 | const 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"]; | |
| 384 | ||
| 385 | export function agentDoing(id: string): string { | |
| 386 | let hash = 0; | |
| 387 | for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) | 0; | |
| 388 | return AGENT_DOING[Math.abs(hash) % AGENT_DOING.length]!; | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * After this long shown as typing, an agent is said to be still working | |
| 393 | * instead: a reply that reads code, consults a colleague or starts a | |
| 394 | * session takes a minute or more, and it keeps saying so the whole time. | |
| 395 | */ | |
| 396 | export const WORKING_AFTER_MS = 10_000; | |
| 397 | ||
| 398 | /** Someone shown as typing, and since when (the first typing event for them, kept across the ones that follow). */ | |
| 399 | export type Typer = { member: Pick<MemberProfile, "kind" | "id" | "display_name" | "name"> & { display_username?: string | null }; since: number }; | |
| 400 | ||
| 401 | /** | |
| 402 | * The typing line: who, and what they are doing. A person is typing. An | |
| 403 | * agent is doing what its phrase says, then, past `WORKING_AFTER_MS`, | |
| 404 | * still working on it; several agents are on it together, and a mix of | |
| 405 | * people and agents are typing. Null when nobody is. | |
| 406 | */ | |
| 407 | export function typingLabel(typers: readonly Typer[], now: number): { who: string; doing: string } | null { | |
| 408 | if (!typers.length) return null; | |
| 409 | const names = typers.map((t) => shownName(t.member)); | |
| 410 | const agents = typers.every((t) => t.member.kind === "agent"); | |
| 411 | const who = names.length === 1 ? names[0]! : names.length === 2 ? `${names[0]} and ${names[1]}` : agents ? "Several agents" : "Several people"; | |
| 412 | const long = typers.some((t) => now - t.since >= WORKING_AFTER_MS); | |
| 413 | if (typers.length === 1) { | |
| 414 | const [one] = typers; | |
| 415 | return { who, doing: one!.member.kind === "agent" ? (long ? "is still working on it" : agentDoing(one!.member.id)) : "is typing" }; | |
| 416 | } | |
| 417 | return { who, doing: agents ? (long ? "are still working on it" : "are on it") : "are typing" }; | |
| 418 | } | |
| 419 | ||
| Chat and workspace agents: channels, DMs and named agents you talk to | 420 | /** Reconnect delays for the live socket: doubling from one second to thirty, with jitter. */ |
| 421 | export function backoff(attempt: number, random: () => number = Math.random): number { | |
| 422 | const base = Math.min(30_000, 1000 * 2 ** Math.max(0, attempt)); | |
| 423 | return Math.round(base * (0.75 + random() * 0.5)); | |
| 424 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.