Skip to content
267 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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 thread14 * - 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 thread22import 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 224import { 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)26import { tally, type UnreadRow } from "./unread.ts";
27
28export type Person = { key: string; user_id: string; username: string; muted: boolean };
29
30export type Recipient = { user_id: string; kind: NotificationKind | null; mentioned: boolean; muted: boolean };
31
32export 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 thread42 /** 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 thread53 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 260/** 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)61export function preview(body: string, max = 140): string {
Merge main into Artifacts Phase 262 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 thread65/** At most this many of a card's actions ride on a notification. */
66const 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 */
73export 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). */
83export 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. */
88export 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. */
94export 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 thread100 /** 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 calendar105 // 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 calendar114 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 calendar120 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 thread126 ...(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. */
150export 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
157type Db = D1Database;
158type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> };
159
160async 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. */
171export 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 thread180 /** 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 thread209 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 thread212 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 thread219 // 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 thread228 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 thread235 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. */
242export 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. */
262export 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.