Skip to content
288 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 * Notifications: the notify service (`services/notify`) keeps one feed per
3 * person, a Durable Object holding their open tabs' sockets, their latest
4 * notifications, live unread counts and their browser push subscriptions.
5 *
6 * Chat tells it about every message a person should count or hear of, the
7 * events service about every inbox item, and the site forwards each tab's
8 * `/-/live` socket to it. Wire shapes are snake_case end to end.
9 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread10import type { CardAction } from "./chat";
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)11import type { ServiceBinding } from "./clients";
12import type { User } from "./identity";
13
14/** What a notification is about; the preferences decide which ones toast and push. */
15export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval";
16
17export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
18
19/** Who it is from: a person, an agent, or g1t itself. */
20export type NotificationActor = {
21 kind: "user" | "agent" | "system";
22 id: string;
23 /** How they show: a display name. */
24 name: string;
25 /** A person's uploaded avatar hash, or null for the letter avatar. */
26 avatar?: string | null;
27 /** What an agent's pixel creature is drawn from. */
28 avatar_seed?: string | null;
29};
30
31export type FeedNotification = {
32 /** Unique per notification: the same id twice is told once. */
33 id: string;
34 kind: NotificationKind;
35 /** The workspace's slug. */
36 workspace: string;
37 title: string;
38 /** A short preview, a line or two. */
39 body: string;
40 /** Where clicking it goes, relative to the site. */
41 href: string;
42 actor: NotificationActor;
43 /** The conversation it is in, for a chat notification: toasts for the open one are skipped, and pushes collapse by it. */
44 channel_id?: string | null;
45 /** The thread it is in, for a reply: quick replies go there. */
46 thread_root?: string | null;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread47 /**
48 * The chat card it is about, when that card has something to press: a
49 * session at its cap (Approve more, Stop, Open), a draft issue (File
50 * issue, Discard). The toast and the notifications panel show these
51 * actions, and pressing one goes to the site's `card_action`, exactly as
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.52 * on the card itself.
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread53 */
54 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)55 created_at: string;
56};
57
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread58/** A card's place and its main actions, carried on a notification about it. */
59export type NotificationCard = {
60 /** The conversation the card is in. */
61 channel_id: string;
62 /** The message that is the card. */
63 message_id: string;
64 /** The card's actions as it offers them: ids, labels, inputs and links, the same as on the card. */
65 actions: CardAction[];
66};
67
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)68/** How much a person hears of. Counts always move; this governs toasts and pushes. */
69export type NotifyLevel = "all" | "dms_mentions" | "none";
70
71export const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
72
73export type NotifyPreferences = {
74 level: NotifyLevel;
75 /** A different level for a workspace, by slug. */
76 workspaces: Record<string, NotifyLevel>;
77};
78
79/** A change to them: a workspace set to null goes back to the general level. */
80export type NotifyPreferencesChange = { level?: NotifyLevel; workspaces?: Record<string, NotifyLevel | null> };
81
82export const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
83
84/** A browser's push subscription, as `PushSubscription.toJSON()` gives it. */
85export type PushSubscriptionJson = {
86 endpoint: string;
87 expiration_time?: number | null;
88 keys: { p256dh: string; auth: string };
89};
90
91/** A conversation's unread counts for one person. */
92export type ChannelCounts = { channel_id: string; unread: number; mentions: number; muted: boolean };
93
94/**
95 * What a person has unread in a workspace. `chat_unread` leaves out muted
96 * conversations, as the rail's badge does; mentions count everywhere.
97 * `inbox_unread` is the person's whole inbox, which is not per workspace.
98 * `complete` says `per_channel` was read from chat itself (on connect), so
99 * a conversation left out of it has nothing unread.
100 */
101export type FeedCounts = {
102 workspace: string;
103 chat_unread: number;
104 chat_mentions: number;
105 inbox_unread: number;
106 per_channel: Omit<ChannelCounts, "muted">[];
107 complete: boolean;
108};
109
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers110// ── Presence and status ──────────────────────────────────────────────────
111//
112// Presence is worked out live by the person's feed from their open tabs:
113// `active` while a tab has been used in the last few minutes, `away` when
114// every tab has sat idle (or they set themselves away), `offline` once no
115// tab is open. Status is what they say about themselves: an emoji and a
116// few words, cleared at a time they chose. Do Not Disturb silences toasts
117// and pushes until a time; counts still move.
118//
119// Both are kept by the notify service (the feed, one per person) and told
120// to everyone who shares a workspace with them, over the same socket,
121// through one presence room per workspace. Nothing polls.
122
123export type Presence = "active" | "away" | "offline";
124
125/**
126 * Who set a status: the person, or something acting for them. Calendars
127 * and other integrations set `calendar` or `integration` (with their own
128 * `clear_at`); a status the person set by hand is never replaced by one.
129 */
130export type StatusSource = "manual" | "calendar" | "integration";
131
132export const STATUS_SOURCES: readonly StatusSource[] = ["manual", "calendar", "integration"];
133
134export type PersonStatus = {
135 /** One emoji, or a custom emoji's `:name:`; null for none. */
136 emoji: string | null;
137 /** A few words: "In a meeting". At most 100 characters. */
138 text: string;
139 /** When it clears itself (RFC 3339); null keeps it until changed. */
140 clear_at: string | null;
141 source: StatusSource;
142 /** When it was set (RFC 3339). */
143 set_at: string;
144};
145
146/** How a person shows to the people who share a workspace with them. */
147export type PresenceEntry = {
148 user_id: string;
149 username: string;
150 presence: Presence;
151 /** Do Not Disturb, until then (RFC 3339); null when off. */
152 dnd_until: string | null;
153 status: PersonStatus | null;
154 /** When this last changed, in ms: a later word wins. */
155 at: number;
156};
157
158/** The signed-in person's own: what everyone sees, and whether they set themselves away. */
159export type OwnPresence = PresenceEntry & { away_manual: boolean };
160
161/**
162 * A change to your own. `status: null` clears it; `away` sets (or ends)
163 * being away by hand; `dnd_until: null` resumes notifications.
164 */
165export type PresenceChange = {
166 status?: { emoji?: string | null; text: string; clear_at?: string | null; source?: StatusSource } | null;
167 away?: boolean;
168 dnd_until?: string | null;
169};
170
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)171/** What the feed socket sends a tab. */
172export type FeedEvent =
173 | { type: "hello"; notifications: FeedNotification[]; vapid_public_key: string | null; preferences: NotifyPreferences }
174 | { type: "notification"; notification: FeedNotification; toast: boolean }
175 | ({ type: "counts" } & FeedCounts)
176 | { type: "preferences"; preferences: NotifyPreferences }
177 /** The person's inbox count as it now is, after items arrive or are marked anywhere. */
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers178 | { type: "inbox"; unread: number }
179 /**
180 * People in a workspace: everyone known on connect (`full`), then each
181 * one as they change.
182 */
183 | { type: "presence"; workspace: string; people: PresenceEntry[]; full: boolean }
184 /** Your own presence, status and Do Not Disturb, on connect and after every change. */
185 | { type: "me"; me: OwnPresence };
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)186
187/**
188 * What a tab sends over the feed socket: plain `ping` every 25 s (answered
189 * without waking the feed), and a state frame whenever it gains or loses
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers190 * focus, moves to another page, or goes idle (no input for a while) or
191 * back, and the inbox count the page last read.
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)192 */
193export type FeedClientFrame =
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers194 | { type: "state"; focused: boolean; path: string; idle?: boolean }
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)195 | { type: "inbox"; unread: number };
196
197/**
198 * One delivery from chat: counts to move for one person in one
199 * conversation, and maybe a notification. `set` replaces the counts (after
200 * reading, or writing); otherwise they are added.
201 */
202export type FeedDelivery = {
203 user_id: string;
204 workspace: string;
205 counts?: {
206 channel_id: string;
207 unread: number;
208 mentions: number;
209 muted?: boolean | null;
210 set?: boolean;
211 } | null;
212 notification?: FeedNotification | null;
213};
214
215/** What the site hands the feed with a socket: who, and the counts read from chat and the inbox just now. */
216export type FeedSeed = {
217 workspace: string | null;
218 per_channel: ChannelCounts[] | null;
219 inbox_unread: number | null;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers220 /** Every workspace the person belongs to, by slug: whose rooms hear of their presence. */
221 workspaces?: 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)222};
223
224/** Headers the site sets on a forwarded feed socket. */
225export const NOTIFY_VIEWER_HEADER = "x-g1t-notify-viewer";
226export const NOTIFY_SEED_HEADER = "x-g1t-notify-seed";
227
228export type NotifyStatus = {
229 preferences: NotifyPreferences;
230 /** How many browsers get pushes. */
231 subscriptions: number;
232 /** Whether this browser's endpoint is one of them, when it was asked about. */
233 subscribed: boolean;
234 /** The public key a browser subscribes with; null when push is not set up. */
235 vapid_public_key: string | null;
236};
237
238export type NotifyApi = {
239 /** Tells a person: their open tabs at once, and a push when none is in front of them. */
240 notify(target: { user_id?: string; username?: string }, notification: FeedNotification): Promise<{ ok: boolean }>;
241 /** Many at once, as chat sends them for a message. */
242 deliver(items: FeedDelivery[]): Promise<{ ok: boolean }>;
243 subscribe(user: User, subscription: PushSubscriptionJson, userAgent?: string | null): Promise<{ ok: boolean }>;
244 unsubscribe(user: User, endpoint: string): Promise<{ ok: boolean }>;
245 status(user: User, endpoint?: string | null): Promise<NotifyStatus>;
246 setPreferences(user: User, preferences: NotifyPreferencesChange): Promise<NotifyPreferences>;
247 /** Sends the person a test notification, toasted and pushed whatever their focus. */
248 test(user: User): Promise<{ ok: boolean; pushed: number }>;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers249 /** Your own presence, status and Do Not Disturb. */
250 presence(user: User): Promise<OwnPresence>;
251 /**
252 * Changes your own, and tells everyone who shares a workspace with you.
253 * Integrations set a status for someone the same way, with their own
254 * `source`; one the person set by hand is kept over theirs.
255 */
256 setPresence(user: Pick<User, "id" | "username">, change: PresenceChange): Promise<OwnPresence>;
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.257 /**
258 * For services: how a workspace's people show now (`userIds` narrows it).
259 * Someone the room has never heard from is not listed: they are offline.
260 */
261 workspacePresence(workspace: string, userIds?: string[] | null): Promise<PresenceEntry[]>;
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)262};
263
264async function rpc<T>(service: ServiceBinding, method: string, args: object): Promise<T> {
265 const response = await service.fetch(`https://service/rpc/${method}`, {
266 method: "POST",
267 headers: { "content-type": "application/json" },
268 body: JSON.stringify(args),
269 });
270 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
271 return (await response.json()) as T;
272}
273
274export function notifyClient(service: ServiceBinding): NotifyApi {
275 const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
276 return {
277 notify: (target, notification) => call("notify", { user_id: target.user_id ?? null, username: target.username ?? null, notification }),
278 deliver: (items) => call("deliver", { items }),
279 subscribe: (user, subscription, userAgent) => call("subscribe", { user_id: user.id, subscription, user_agent: userAgent ?? null }),
280 unsubscribe: (user, endpoint) => call("unsubscribe", { user_id: user.id, endpoint }),
281 status: (user, endpoint) => call("status", { user_id: user.id, endpoint: endpoint ?? null }),
282 setPreferences: (user, preferences) => call("set_preferences", { user_id: user.id, preferences }),
283 test: (user) => call("test", { user_id: user.id, username: user.username }),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers284 presence: (user) => call("presence", { user_id: user.id, username: user.username }),
285 setPresence: (user, change) => call("set_presence", { user_id: user.id, username: user.username, change }),
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.286 workspacePresence: (workspace, userIds) => call("workspace_presence", { workspace, user_ids: userIds ?? 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)287 };
288}

This file's history is long; its oldest lines are credited to the oldest commit read.