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 | |
| 4 | * notified (docs/WORKSPACE.md, "Live notifications"). | |
| 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, | |
| 123 | }, | |
| 124 | channel_id: channel.id, | |
| 125 | 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 | 126 | ...(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) | 127 | created_at: message.created_at, |
| 128 | } | |
| 129 | : null; | |
| 130 | return { | |
| 131 | user_id: r.user_id, | |
| 132 | workspace: slug, | |
| 133 | counts: { channel_id: channel.id, unread: 1, mentions: r.mentioned ? 1 : 0, muted: r.muted }, | |
| 134 | notification, | |
| 135 | }; | |
| 136 | }); | |
| 137 | // The author has read the conversation by writing in it. | |
| 138 | if (message.author.startsWith("user:")) { | |
| 139 | deliveries.push({ | |
| 140 | user_id: message.author.slice("user:".length), | |
| 141 | workspace: slug, | |
| 142 | counts: { channel_id: channel.id, unread: 0, mentions: 0, set: true }, | |
| 143 | notification: null, | |
| 144 | }); | |
| 145 | } | |
| 146 | return deliveries; | |
| 147 | } | |
| 148 | ||
| 149 | /** A person's counts for one conversation after they read up to `lastReadId`, from the rows after it. */ | |
| 150 | export function countsAfterRead(rows: UnreadRow[], channelId: string, member: string, username: string, lastReadId: string): { unread: number; mentions: number } { | |
| 151 | return tally(rows, member, username, new Map([[channelId, lastReadId]])).get(channelId) ?? { unread: 0, mentions: 0 }; | |
| 152 | } | |
| 153 | ||
| 154 | ||
| 155 | // ── Wiring, used by src/index.ts ───────────────────────────────────────── | |
| 156 | ||
| 157 | type Db = D1Database; | |
| 158 | type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> }; | |
| 159 | ||
| 160 | async function deliver(notify: Notify, items: FeedDelivery[]): Promise<void> { | |
| 161 | if (!items.length) return; | |
| 162 | const response = await notify.fetch("https://service/rpc/deliver", { | |
| 163 | method: "POST", | |
| 164 | headers: { "content-type": "application/json" }, | |
| 165 | body: JSON.stringify({ items }), | |
| 166 | }); | |
| 167 | if (!response.ok) throw new Error(`deliver failed with status ${response.status}`); | |
| 168 | } | |
| 169 | ||
| 170 | /** Tells notify of a new message: one batched call for everyone in the conversation. */ | |
| 171 | export async function notifyMessage( | |
| 172 | db: Db, | |
| 173 | notify: Notify | undefined, | |
| 174 | profiles: (keys: string[]) => Promise<Map<string, MemberProfile>>, | |
| 175 | input: { | |
| 176 | slug: string; | |
| 177 | channel: { id: string; kind: "channel" | "dm"; name: string | null }; | |
| 178 | row: { id: string; author: string; body: string; card: string | null; thread_root: string | null; created_at: string }; | |
| 179 | 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 | 180 | /** Who the agent posting was asked by (a user id), for a card that waits on them. */ |
| 181 | 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) | 182 | }, |
| 183 | ): Promise<void> { | |
| 184 | if (!notify) return; | |
| 185 | const { channel, row } = input; | |
| 186 | const [members, thread] = await Promise.all([ | |
| 187 | db | |
| 188 | .prepare("SELECT principal, muted FROM channel_members WHERE channel_id = ? AND principal LIKE 'user:%'") | |
| 189 | .bind(channel.id) | |
| 190 | .all<{ principal: string; muted: number }>(), | |
| 191 | row.thread_root | |
| 192 | ? db | |
| 193 | .prepare("SELECT DISTINCT author FROM messages WHERE channel_id = ?1 AND (id = ?2 OR thread_root = ?2) AND author LIKE 'user:%'") | |
| 194 | .bind(channel.id, row.thread_root) | |
| 195 | .all<{ author: string }>() | |
| 196 | : Promise.resolve(null), | |
| 197 | ]); | |
| 198 | const keys = members.results.map((m) => m.principal); | |
| 199 | const found = await profiles([...keys, row.author]); | |
| 200 | const people: Person[] = members.results.map((m) => ({ | |
| 201 | key: m.principal, | |
| 202 | user_id: m.principal.slice("user:".length), | |
| 203 | username: found.get(m.principal)?.name ?? "", | |
| 204 | muted: !!m.muted, | |
| 205 | })); | |
| 206 | const author = found.get(row.author); | |
| 207 | if (!author) return; | |
| 208 | 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 | 209 | 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) | 210 | if (row.card) { |
| 211 | try { | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 212 | const parsed = JSON.parse(row.card) as { title?: string }; |
| 213 | cardTitle = parsed.title ?? null; | |
| 214 | 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) | 215 | } catch { |
| 216 | cardTitle = null; | |
| 217 | } | |
| 218 | } | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 219 | // An agent's card asking someone to act waits on whoever asked the agent. |
| 220 | 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) | 221 | await deliver( |
| 222 | notify, | |
| 223 | messageDeliveries({ | |
| 224 | slug: input.slug, | |
| 225 | channel, | |
| 226 | message: { id: row.id, author: row.author, body: row.body, card_title: cardTitle, thread_root: row.thread_root, created_at: row.created_at }, | |
| 227 | author, | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 228 | 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) | 229 | recipients: recipients({ |
| 230 | author: row.author, | |
| 231 | channelKind: channel.kind, | |
| 232 | people, | |
| 233 | mentioned: input.handles, | |
| 234 | 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 | 235 | 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) | 236 | }), |
| 237 | }), | |
| 238 | ); | |
| 239 | } | |
| 240 | ||
| 241 | /** After a read: the person's counts for the conversation, as they now are, in every tab. */ | |
| 242 | export async function notifyRead( | |
| 243 | db: Db, | |
| 244 | notify: Notify | undefined, | |
| 245 | input: { slug: string; channel_id: string; user_id: string; username: string; last_read_id: string }, | |
| 246 | ): Promise<void> { | |
| 247 | if (!notify) return; | |
| 248 | const member = `user:${input.user_id}`; | |
| 249 | const rows = await db | |
| 250 | .prepare( | |
| 251 | "SELECT channel_id, id, author, mentions FROM messages WHERE channel_id = ? AND id > ? AND deleted_at IS NULL AND author != ? LIMIT 5000", | |
| 252 | ) | |
| 253 | .bind(input.channel_id, input.last_read_id, member) | |
| 254 | .all<UnreadRow>(); | |
| 255 | const counts = countsAfterRead(rows.results, input.channel_id, member, input.username, input.last_read_id); | |
| 256 | await deliver(notify, [ | |
| 257 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, ...counts, set: true }, notification: null }, | |
| 258 | ]); | |
| 259 | } | |
| 260 | ||
| 261 | /** After muting or unmuting: the badge counts the conversation, or leaves it out, in every tab. */ | |
| 262 | export async function notifyMuted(notify: Notify | undefined, input: { slug: string; channel_id: string; user_id: string; muted: boolean }): Promise<void> { | |
| 263 | if (!notify) return; | |
| 264 | await deliver(notify, [ | |
| 265 | { user_id: input.user_id, workspace: input.slug, counts: { channel_id: input.channel_id, unread: 0, mentions: 0, muted: input.muted }, notification: null }, | |
| 266 | ]); | |
| 267 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.