Skip to content
268 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
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 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,
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 thread127 ...(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. */
151export 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
158type Db = D1Database;
159type Notify = { fetch(input: string, init?: RequestInit): Promise<Response> };
160
161async 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. */
172export 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 thread181 /** 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 thread210 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 thread213 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 thread220 // 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 thread229 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 thread236 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. */
243export 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. */
263export 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.