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 managers | 8 | * - 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 thread | 13 | import 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. | 15 | import { 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. | |
| 19 | const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"]; | |
| 20 | const 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. */ | |
| 23 | const 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. */ | |
| 26 | export const STALE_MS = 70_000; | |
| 27 | ||
| 28 | /** The most workspaces with their own level. */ | |
| 29 | const MAX_OVERRIDES = 200; | |
| 30 | ||
| 31 | export 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`. */ | |
| 36 | export 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. */ | |
| 41 | export 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 | */ | |
| 51 | export 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. */ | |
| 69 | export 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. */ | |
| 79 | export type TabState = { focused: boolean; seen_at: number }; | |
| 80 | ||
| 81 | /** Whether any tab is in front of the person now. */ | |
| 82 | export function anyFocused(tabs: TabState[], now: number): boolean { | |
| 83 | return tabs.some((tab) => tab.focused && now - tab.seen_at < STALE_MS); | |
| 84 | } | |
| 85 | ||
| 86 | export 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 | */ | |
| 93 | export 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 managers | 100 | /** 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 managers | 104 | 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. */ | |
| 110 | export 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 thread | 123 | 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 thread | 142 | ...(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 thread | 147 | /** The most of a card's actions a notification carries, and a push shows (browsers show two). */ |
| 148 | const CARD_ACTIONS = 4; | |
| 149 | const PUSH_ACTIONS = 2; | |
| 150 | ||
| 151 | /** A site path, or null: a notification never links somewhere else. */ | |
| 152 | function 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 | */ | |
| 161 | export 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 | */ | |
| 197 | export type PushAction = { id: string; label: string; href: string | null }; | |
| 198 | ||
| 199 | export 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 thread | 211 | export 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 | ||
| 226 | export 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 thread | 232 | // 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 thread | 236 | 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.