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