Skip to content
240 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 * Who hears of what, and how: preferences, and the toast-or-push decision.
3 * Pure, so the rules are tested apart from the feed.
4 *
5 * - Counts always move, whatever the preferences.
6 * - A notification is shown (toasted in open tabs, pushed to browsers) when
7 * the person's level for its workspace wants its kind.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers8 * - Under Do Not Disturb nothing is toasted or pushed.
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)9 * - It is pushed only when no tab of theirs is in front of them: a tab
10 * says it has focus over its socket, and a tab that has said nothing for
11 * `STALE_MS` counts as gone (a laptop lid closed on it).
12 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread13import type { CardAction, FeedNotification, NotificationCard, NotificationKind, NotifyLevel, NotifyPreferences } 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)14
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.15import { readLook } from "../../../packages/contracts/src/agent-look.ts";
16
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)17// The same as NOTIFY_LEVELS and DEFAULT_NOTIFY_PREFERENCES in @g1t/contracts, kept here so
18// Node runs the tests on this file without the contracts package.
19const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
20const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
21
22/** What `dms_mentions`, the default, lets through: what was said to you, or waits on you. */
23const DIRECT: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "thread_reply", "agent_waiting", "approval"]);
24
25/** A tab whose last word is older than this is not in front of anyone. Pings come every 25 s. */
26export const STALE_MS = 70_000;
27
28/** The most workspaces with their own level. */
29const MAX_OVERRIDES = 200;
30
31export function isLevel(value: unknown): value is NotifyLevel {
32 return typeof value === "string" && (NOTIFY_LEVELS as readonly string[]).includes(value);
33}
34
35/** The level that applies in `workspace`. */
36export function levelFor(prefs: NotifyPreferences, workspace: string): NotifyLevel {
37 return prefs.workspaces[workspace.toLowerCase()] ?? prefs.level;
38}
39
40/** Whether `level` lets a notification of `kind` be shown. */
41export function wants(level: NotifyLevel, kind: NotificationKind): boolean {
42 if (level === "none") return false;
43 if (level === "all") return true;
44 return DIRECT.has(kind);
45}
46
47/**
48 * Preferences after a change: `change` as sent, checked, over `current`.
49 * A workspace set to `null` (or to the general level) goes back to it.
50 */
51export function mergePreferences(current: NotifyPreferences, change: unknown): NotifyPreferences {
52 const c = (change && typeof change === "object" ? change : {}) as Record<string, unknown>;
53 const level = isLevel(c.level) ? c.level : current.level;
54 const workspaces: Record<string, NotifyLevel> = { ...current.workspaces };
55 if (c.workspaces && typeof c.workspaces === "object") {
56 for (const [slug, value] of Object.entries(c.workspaces as Record<string, unknown>)) {
57 const key = slug.toLowerCase().slice(0, 100);
58 if (!key) continue;
59 if (isLevel(value)) workspaces[key] = value;
60 else if (value === null) delete workspaces[key];
61 }
62 }
63 for (const [slug, value] of Object.entries(workspaces)) if (value === level) delete workspaces[slug];
64 const kept = Object.fromEntries(Object.entries(workspaces).slice(0, MAX_OVERRIDES));
65 return { level, workspaces: kept };
66}
67
68/** Preferences as kept, or the default for anything that is not them. */
69export function readPreferences(json: string | null | undefined): NotifyPreferences {
70 if (!json) return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
71 try {
72 return mergePreferences({ ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} }, JSON.parse(json));
73 } catch {
74 return { ...DEFAULT_NOTIFY_PREFERENCES, workspaces: {} };
75 }
76}
77
78/** A tab's state as its socket last told it. `seen_at` is when it last said anything, a ping included. */
79export type TabState = { focused: boolean; seen_at: number };
80
81/** Whether any tab is in front of the person now. */
82export function anyFocused(tabs: TabState[], now: number): boolean {
83 return tabs.some((tab) => tab.focused && now - tab.seen_at < STALE_MS);
84}
85
86export type Decision = { toast: boolean; push: boolean };
87
88/**
89 * What to do with one notification: toast it in open tabs, push it to
90 * browsers, both or neither. A test is shown and pushed whatever the
91 * preferences and focus say.
92 */
93export function decide(input: {
94 prefs: NotifyPreferences;
95 notification: Pick<FeedNotification, "kind" | "workspace">;
96 tabs: TabState[];
97 subscriptions: number;
98 now: number;
99 test?: boolean;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers100 /** Do Not Disturb holds: nothing is toasted or pushed (a test still is). */
101 dnd?: 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)102}): Decision {
103 if (input.test) return { toast: true, push: input.subscriptions > 0 };
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers104 if (input.dnd) return { toast: false, push: false };
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)105 const shown = wants(levelFor(input.prefs, input.notification.workspace), input.notification.kind);
106 return { toast: shown, push: shown && input.subscriptions > 0 && !anyFocused(input.tabs, input.now) };
107}
108
109/** A notification as sent, checked and trimmed; null when it is not one. */
110export function cleanNotification(value: unknown): FeedNotification | null {
111 if (!value || typeof value !== "object") return null;
112 const n = value as Record<string, unknown>;
113 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
114 const kinds: NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
115 const kind = kinds.find((k) => k === n.kind);
116 const id = text(n.id, 200);
117 const workspace = text(n.workspace, 100).toLowerCase();
118 const title = text(n.title, 200);
119 if (!kind || !id || !title) return null;
120 const href = text(n.href, 2000);
121 const a = (n.actor && typeof n.actor === "object" ? n.actor : {}) as Record<string, unknown>;
122 const actorKind = a.kind === "user" || a.kind === "agent" ? a.kind : "system";
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread123 const card = cleanCard(n.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)124 return {
125 id,
126 kind,
127 workspace,
128 title,
129 body: text(n.body, 300),
130 // Relative to the site only: a notification never links somewhere else.
131 href: href.startsWith("/") && !href.startsWith("//") ? href : "/",
132 actor: {
133 kind: actorKind,
134 id: text(a.id, 100) || "g1t",
135 name: text(a.name, 100) || "g1t",
136 avatar: text(a.avatar, 100) || null,
137 avatar_seed: text(a.avatar_seed, 100) || 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.138 look: readLook(a.look),
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)139 },
140 channel_id: text(n.channel_id, 100) || null,
141 thread_root: text(n.thread_root, 100) || null,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread142 ...(card ? { 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)143 created_at: text(n.created_at, 40) || new Date().toISOString(),
144 };
145}
146
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread147/** The most of a card's actions a notification carries, and a push shows (browsers show two). */
148const CARD_ACTIONS = 4;
149const PUSH_ACTIONS = 2;
150
151/** A site path, or null: a notification never links somewhere else. */
152function sitePath(value: unknown): string | null {
153 const href = typeof value === "string" ? value.trim().slice(0, 2000) : "";
154 return href.startsWith("/") && !href.startsWith("//") ? href : null;
155}
156
157/**
158 * The card a notification is about, checked: where it is and its actions
159 * as the card offers them. Null when it is not one, or has nothing to press.
160 */
161export function cleanCard(value: unknown): NotificationCard | null {
162 if (!value || typeof value !== "object") return null;
163 const c = value as Record<string, unknown>;
164 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
165 const channel_id = text(c.channel_id, 100);
166 const message_id = text(c.message_id, 100);
167 if (!channel_id || !message_id || !Array.isArray(c.actions)) return null;
168 const actions: CardAction[] = [];
169 for (const raw of c.actions.slice(0, CARD_ACTIONS)) {
170 if (!raw || typeof raw !== "object") continue;
171 const a = raw as Record<string, unknown>;
172 const id = text(a.id, 40);
173 const label = text(a.label, 60);
174 if (!id || !label) continue;
175 const style = a.style === "primary" || a.style === "danger" ? a.style : "default";
176 const input = a.input && typeof a.input === "object" ? (a.input as Record<string, unknown>) : null;
177 actions.push({
178 id,
179 label,
180 style,
181 confirm: text(a.confirm, 200) || null,
182 input:
183 input && (input.kind === "money" || input.kind === "text")
184 ? { kind: input.kind, label: text(input.label, 100), placeholder: text(input.placeholder, 100) || null, initial: text(input.initial, 40) || null }
185 : null,
186 href: sitePath(a.href),
187 });
188 }
189 return actions.length ? { channel_id, message_id, actions } : null;
190}
191
192/**
193 * A push's buttons: a card's actions that need nothing typed (a push has no
194 * field) and nothing confirmed (a push can't ask first, so Stop waits for
195 * the app), links and all.
196 */
197export type PushAction = { id: string; label: string; href: string | null };
198
199export function pushActions(card: NotificationCard | null | undefined): PushAction[] {
200 if (!card) return [];
201 return card.actions
202 .filter((a) => !a.input && !a.confirm && a.style !== "danger")
203 .slice(0, PUSH_ACTIONS)
204 .map((a) => ({ id: a.id, label: a.label, href: a.href ?? null }));
205}
206
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)207/**
208 * What a push carries: little, under the 4 KB a push may hold. `tag` makes
209 * the notifications of one conversation replace each other.
210 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread211export type PushPayload = {
212 title: string;
213 body: string;
214 href: string;
215 tag: string;
216 kind: NotificationKind;
217 urgent: boolean;
218 /** The workspace's slug, for a card action's request. */
219 workspace: string;
220 /** Buttons on the notification: a card's actions that need nothing typed. */
221 actions: PushAction[];
222 /** Where the card is, for those that run (public/sw.js posts `card_action`). */
223 card: { channel_id: string; message_id: string } | null;
224};
225
226export function pushPayload(n: FeedNotification): PushPayload {
227 const actions = pushActions(n.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)228 return {
229 title: n.title,
230 body: n.body.slice(0, 240),
231 href: n.href,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread232 // A card's notification is its own, so a newer message does not replace it.
233 tag: n.channel_id && !actions.length ? `chat:${n.channel_id}` : `${n.kind}:${n.id}`,
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)234 kind: n.kind,
235 urgent: DIRECT.has(n.kind),
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread236 workspace: n.workspace,
237 actions,
238 card: actions.length && n.card ? { channel_id: n.card.channel_id, message_id: n.card.message_id } : 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)239 };
240}

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