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.
| 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) | 1 | /** |
| 2 | * What the notify service is told about a new message, or a read: every | |
| 3 | * person in the conversation has its counts moved, and those it is for are | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 4 | * notified (docs.g1t.sh/guides/chat/, "Notifications"). |
| 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) | 5 | * |
| 6 | * - A direct message notifies everyone else in it, muted or not. | |
| 7 | * - An @mention notifies the person named, muted or not. | |
| 8 | * - A reply notifies the people already in its thread (who started it or | |
| 9 | * replied), unless they muted the conversation. | |
| 10 | * - Everyone else in it only has their counts moved. | |
| 11 | * - The author is never told of their own message; their own count for | |
| 12 | * the conversation goes to nothing (what you wrote, you have read). | |
| 13 | * - Agents have no feed: only people are told. | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 14 | * - An agent's card that asks someone to act (a draft issue to file, a |
| 15 | * session's Approve more: a primary action) notifies whoever asked the | |
| 16 | * agent, as waiting on them, if they are in the conversation. | |
| 17 | * - A notification about a card someone can act on carries the card's | |
| 18 | * place and actions (`card`), so its toast offers them. | |
| 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) | 19 | * |
| 20 | * `recipients` is pure, so the rules are tested apart from the service. | |
| 21 | */ | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 22 | import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts"; |
| 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) | 23 | |
| Merge main into Artifacts Phase 2 | 24 | import { plainText } from "@g1t/contracts/chat-markdown"; |
| 25 | ||
| 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) | 26 | import { tally, type UnreadRow } from "./unread.ts"; |
| 27 | ||
| 28 | export type Person = { key: string; user_id: string; username: string; muted: boolean }; | |
| 29 | ||
| 30 | export type Recipient = { user_id: string; kind: NotificationKind | null; mentioned: boolean; muted: boolean }; | |
| 31 | ||
| 32 | export function recipients(input: { | |
| 33 | /** `user:<id>` or `agent:<id>`. */ | |
| 34 | author: string; | |
| 35 | channelKind: "channel" | "dm"; | |
| 36 | /** The people in the conversation (agents left out). */ | |
| 37 | people: Person[]; | |
| 38 | /** Handles the message mentions, lowercased. */ | |
| 39 | mentioned: string[]; | |
| 40 | /** For a reply: the people in its thread, by key; null for a top-level message. */ | |
| 41 | thread: ReadonlySet<string> | null; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 42 | /** Who an agent's card asks to act (`user:<id>`, whoever asked the agent), or null. */ |
| 43 | waitingOn?: string | 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) | 44 | }): Recipient[] { |
| 45 | const named = new Set(input.mentioned.map((h) => h.toLowerCase())); | |
| 46 | const out: Recipient[] = []; | |
| 47 | for (const person of input.people) { | |
| 48 | if (person.key === input.author) continue; | |
| 49 | const mentioned = named.has(person.username.toLowerCase()); | |
| 50 | let kind: NotificationKind | null = null; | |
| 51 | if (input.channelKind === "dm") kind = "dm"; | |
| 52 | else if (mentioned) kind = "mention"; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 53 | else if (input.waitingOn === person.key) kind = "agent_waiting"; |
| 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) | 54 | else if (input.thread?.has(person.key) && !person.muted) kind = "thread_reply"; |
| 55 | out.push({ user_id: person.user_id, kind, mentioned, muted: person.muted }); | |
| 56 | } | |
| 57 | return out; | |
| 58 | } | |
| 59 | ||
| Merge main into Artifacts Phase 2 | 60 | /** A message's text as a notification shows it: one line, the Markdown's marks gone, short. */ |
| 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) | 61 | export function preview(body: string, max = 140): string { |
| Merge main into Artifacts Phase 2 | 62 | return plainText(String(body ?? ""), max); |
| 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) | 63 | } |
| 64 | ||
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 65 | /** At most this many of a card's actions ride on a notification. */ |
| 66 | const NOTIFIED_ACTIONS = 4; | |
| 67 | ||
| 68 | /** | |
| 69 | * A card's place and actions for a notification about it, from the card as | |
| 70 | * stored: only when it has an owner to answer and something to press that | |
| 71 | * is not just a link; null otherwise. | |
| 72 | */ | |
| 73 | export function notificationCard(channelId: string, messageId: string, card: unknown): NotificationCard | null { | |
| 74 | if (!card || typeof card !== "object") return null; | |
| 75 | const c = card as { owner?: unknown; actions?: unknown }; | |
| 76 | if (!c.owner || !Array.isArray(c.actions)) return null; | |
| 77 | const actions = (c.actions as CardAction[]).filter((a) => a && typeof a.id === "string" && typeof a.label === "string").slice(0, NOTIFIED_ACTIONS); | |
| 78 | if (!actions.some((a) => !a.href)) return null; | |
| 79 | return { channel_id: channelId, message_id: messageId, actions }; | |
| 80 | } | |
| 81 | ||
| 82 | /** Whether a card asks someone to act: it has a primary action that runs (File issue, Approve more). */ | |
| 83 | export function asksToAct(card: NotificationCard | null): boolean { | |
| 84 | return !!card?.actions.some((a) => a.style === "primary" && !a.href); | |
| 85 | } | |
| 86 | ||
| 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) | 87 | /** Where a conversation, or a thread in it, is on the site. */ |
| 88 | export function conversationHref(slug: string, channel: { id: string; kind: "channel" | "dm"; name: string | null }, threadRoot: string | null): string { | |
| 89 | const base = channel.kind === "dm" || !channel.name ? `/${slug}/-/chat/dm/${channel.id}` : `/${slug}/-/chat/${channel.name}`; | |
| 90 | return threadRoot ? `${base}?thread=${encodeURIComponent(threadRoot)}` : base; | |
| 91 | } | |
| 92 | ||
| 93 | /** The deliveries for one new message. */ | |
| 94 | export function messageDeliveries(input: { | |
| 95 | slug: string; | |
| 96 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| 97 | message: { id: string; author: string; body: string; card_title: string | null; thread_root: string | null; created_at: string }; | |
| 98 | author: MemberProfile; | |
| 99 | recipients: Recipient[]; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 100 | /** The card's place and actions, when it has something to press. */ |
| 101 | card?: NotificationCard | 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) | 102 | }): FeedDelivery[] { |
| 103 | const { slug, channel, message, author } = input; | |
| 104 | const where = channel.kind === "dm" ? "" : ` in #${channel.name}`; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 105 | // The service sets `display_name` by `memberName`'s rule (display name, |
| 106 | // else the username in its chosen case), so pushes match the chat. | |
| 107 | const shown = author.display_name.trim() || author.display_username || author.name; | |
| 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) | 108 | const deliveries: FeedDelivery[] = input.recipients.map((r) => { |
| 109 | const notification: FeedNotification | null = r.kind | |
| 110 | ? { | |
| 111 | id: message.id, | |
| 112 | kind: r.kind, | |
| 113 | workspace: slug, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 114 | title: r.kind === "thread_reply" ? `${shown} replied${where}` : `${shown}${where}`, |
| 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) | 115 | body: preview(message.card_title ?? message.body), |
| 116 | href: conversationHref(slug, channel, message.thread_root), | |
| 117 | actor: { | |
| 118 | kind: author.kind, | |
| 119 | id: author.id, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 120 | name: shown, |
| 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) | 121 | avatar: author.avatar, |
| 122 | avatar_seed: author.avatar_seed ?? null, | |
| 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. | 123 | look: author.look ?? 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) | 124 | }, |
| 125 | channel_id: channel.id, | |
| 126 | thread_root: message.thread_root, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 127 | ...(input.card ? { card: input.card } : {}), |
| 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) | 128 | created_at: message.created_at, |
| 129 | } | |
| 130 | : null; | |
| 131 | return { | |
| 132 | user_id: r.user_id, | |
| 133 | workspace: slug, | |
| 134 | counts: { channel_id: channel.id, unread: 1, mentions: r.mentioned ? 1 : 0, muted: r.muted }, | |
| 135 | notification, | |
| 136 | }; | |
| 137 | }); | |
| 138 | // The author has read the conversation by writing in it. | |
| 139 | if (message.author.startsWith("user:")) { | |
| 140 | deliveries.push({ | |
| 141 | user_id: message.author.slice("user:".length), | |
| 142 | workspace: slug, | |
| 143 | counts: { channel_id: channel.id, unread: 0, mentions: 0, set: true }, | |
| 144 | notification: null, | |
| 145 | }); | |
| 146 | } | |
| 147 | return deliveries; | |
| 148 | } | |
| 149 | ||
| 150 | /** A person's counts for one conversation after they read up to `lastReadId`, from the rows after it. */ | |
| 151 | export function countsAfterRead(rows: UnreadRow[], channelId: string, member: string, username: string, lastReadId: string): { unread: number; mentions: number } { | |
| 152 | return tally(rows, member, username, new Map([[channelId, lastReadId]])).get(channelId) ?? { unread: 0, mentions: 0 }; | |
| 153 | } | |
| 154 | ||
| 155 | ||
| 156 | // ── Wiring, used by src/index.ts ───────────────────────────────────────── | |
| 157 | ||
| 158 | type Db = D1Database; | |
| 159 | type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> }; | |
| 160 | ||
| 161 | async function deliver(notify: Notify, items: FeedDelivery[]): Promise<void> { | |
| 162 | if (!items.length) return; | |
| 163 | const response = await notify.fetch("https://service/rpc/deliver", { | |
| 164 | method: "POST", | |
| 165 | headers: { "content-type": "application/json" }, | |
| 166 | body: JSON.stringify({ items }), | |
| 167 | }); | |
| 168 | if (!response.ok) throw new Error(`deliver failed with status ${response.status}`); | |
| 169 | } | |
| 170 | ||
| 171 | /** Tells notify of a new message: one batched call for everyone in the conversation. */ | |
| 172 | export async function notifyMessage( | |
| 173 | db: Db, | |
| 174 | notify: Notify | undefined, | |
| 175 | profiles: (keys: string[]) => Promise<Map<string, MemberProfile>>, | |
| 176 | input: { | |
| 177 | slug: string; | |
| 178 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| 179 | row: { id: string; author: string; body: string; card: string | null; thread_root: string | null; created_at: string }; | |
| 180 | handles: string[]; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 181 | /** Who the agent posting was asked by (a user id), for a card that waits on them. */ |
| 182 | asked_by?: string | 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) | 183 | }, |
| 184 | ): Promise<void> { | |
| 185 | if (!notify) return; | |
| 186 | const { channel, row } = input; | |
| 187 | const [members, thread] = await Promise.all([ | |
| 188 | db | |
| 189 | .prepare("SELECT principal, muted FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'") | |
| 190 | .bind(channel.id) | |
| 191 | .all<{ principal: string; muted: number }>(), | |
| 192 | row.thread_root | |
| 193 | ? db | |
| 194 | .prepare("SELECT DISTINCT author FROM messages WHERE channel_id = ?1 AND (id = ?2 OR thread_root = ?2) AND author LIKE 'user:%'") | |
| 195 | .bind(channel.id, row.thread_root) | |
| 196 | .all<{ author: string }>() | |
| 197 | : Promise.resolve(null), | |
| 198 | ]); | |
| 199 | const keys = members.results.map((m) => m.principal); | |
| 200 | const found = await profiles([...keys, row.author]); | |
| 201 | const people: Person[] = members.results.map((m) => ({ | |
| 202 | key: m.principal, | |
| 203 | user_id: m.principal.slice("user:".length), | |
| 204 | username: found.get(m.principal)?.name ?? "", | |
| 205 | muted: !!m.muted, | |
| 206 | })); | |
| 207 | const author = found.get(row.author); | |
| 208 | if (!author) return; | |
| 209 | let cardTitle: string | null = null; | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 210 | let card: NotificationCard | null = 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) | 211 | if (row.card) { |
| 212 | try { | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 213 | const parsed = JSON.parse(row.card) as { title?: string }; |
| 214 | cardTitle = parsed.title ?? null; | |
| 215 | card = notificationCard(channel.id, row.id, parsed); | |
| 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) | 216 | } catch { |
| 217 | cardTitle = null; | |
| 218 | } | |
| 219 | } | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 220 | // An agent's card asking someone to act waits on whoever asked the agent. |
| 221 | const waitingOn = row.author.startsWith("agent:") && input.asked_by && asksToAct(card) ? `user:${input.asked_by}` : 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) | 222 | await deliver( |
| 223 | notify, | |
| 224 | messageDeliveries({ | |
| 225 | slug: input.slug, | |
| 226 | channel, | |
| 227 | message: { id: row.id, author: row.author, body: row.body, card_title: cardTitle, thread_root: row.thread_root, created_at: row.created_at }, | |
| 228 | author, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 229 | card, |
| 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 | recipients: recipients({ |
| 231 | author: row.author, | |
| 232 | channelKind: channel.kind, | |
| 233 | people, | |
| 234 | mentioned: input.handles, | |
| 235 | thread: thread ? new Set(thread.results.map((r) => r.author)) : null, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 236 | waitingOn, |
| 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 | }), |
| 238 | }), | |
| 239 | ); | |
| 240 | } | |
| 241 | ||
| 242 | /** After a read: the person's counts for the conversation, as they now are, in every tab. */ | |
| 243 | export async function notifyRead( | |
| 244 | db: Db, | |
| 245 | notify: Notify | undefined, | |
| 246 | input: { slug: string; channel_id: string; user_id: string; username: string; last_read_id: string }, | |
| 247 | ): Promise<void> { | |
| 248 | if (!notify) return; | |
| 249 | const member = `user:${input.user_id}`; | |
| 250 | const rows = await db | |
| 251 | .prepare( | |
| 252 | "SELECT channel_id, id, author, mentions FROM messages WHERE channel_id = ? AND id > ? AND deleted_at IS NULL AND author != ? LIMIT 5000", | |
| 253 | ) | |
| 254 | .bind(input.channel_id, input.last_read_id, member) | |
| 255 | .all<UnreadRow>(); | |
| 256 | const counts = countsAfterRead(rows.results, input.channel_id, member, input.username, input.last_read_id); | |
| 257 | await deliver(notify, [ | |
| 258 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, ...counts, set: true }, notification: null }, | |
| 259 | ]); | |
| 260 | } | |
| 261 | ||
| 262 | /** After muting or unmuting: the badge counts the conversation, or leaves it out, in every tab. */ | |
| 263 | export async function notifyMuted(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string; muted: boolean }): Promise<void> { | |
| 264 | if (!notify) return; | |
| 265 | await deliver(notify, [ | |
| 266 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null }, | |
| 267 | ]); | |
| 268 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.