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 | * The rules behind live notifications in the browser, apart from the | |
| 3 | * browser: which toasts show, how counts merge, what the tab title says, | |
| 4 | * when to offer browser notifications, and how long to wait before | |
| 5 | * reconnecting. Pure, so they are tested apart from the socket | |
| 6 | * (lib/notify-client.ts) and the toasts (components/notifications/). | |
| 7 | */ | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 8 | import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } 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) | 9 | |
| 10 | /** The most toasts on screen; a fourth pushes the oldest out. */ | |
| 11 | export const MAX_TOASTS = 3; | |
| 12 | /** How long a toast stays, unless the pointer or the keyboard is on it. */ | |
| 13 | export const TOAST_MS = 6_000; | |
| 14 | /** Every so often the socket says it is still there, focus and all. */ | |
| 15 | export const HEARTBEAT_MS = 25_000; | |
| 16 | /** While the socket is down, the Chat sidebar asks again this often. */ | |
| 17 | export const FALLBACK_REFRESH_MS = 60_000; | |
| 18 | ||
| 19 | export type Toast = { notification: FeedNotification; at: number }; | |
| 20 | ||
| 21 | /** The path a link goes to, without its query or fragment. */ | |
| 22 | export function pathOf(href: string): string { | |
| 23 | return href.split(/[?#]/)[0].replace(/\/+$/, "").toLowerCase(); | |
| 24 | } | |
| 25 | ||
| 26 | /** | |
| 27 | * Whether the person is looking at the conversation a notification is | |
| 28 | * about right now: its page is open, or the chat page open says it shows | |
| 29 | * that channel. A toast for it would only repeat what is on screen. | |
| 30 | */ | |
| 31 | export function isViewing(notification: FeedNotification, viewing: { path: string; channel_id?: string | null }): boolean { | |
| 32 | if (notification.channel_id && viewing.channel_id && notification.channel_id === viewing.channel_id) return true; | |
| 33 | if (!notification.channel_id) return false; | |
| 34 | return pathOf(notification.href) === pathOf(viewing.path); | |
| 35 | } | |
| 36 | ||
| 37 | /** | |
| 38 | * The toasts after one arrives: never twice, never for what is on screen, | |
| 39 | * newest last, at most `MAX_TOASTS`. | |
| 40 | */ | |
| 41 | export function addToast( | |
| 42 | toasts: Toast[], | |
| 43 | notification: FeedNotification, | |
| 44 | viewing: { path: string; channel_id?: string | null }, | |
| 45 | now: number, | |
| 46 | ): Toast[] { | |
| 47 | if (toasts.some((t) => t.notification.id === notification.id)) return toasts; | |
| 48 | if (isViewing(notification, viewing)) return toasts; | |
| 49 | // A newer message in the same conversation replaces the older toast. | |
| 50 | const kept = notification.channel_id ? toasts.filter((t) => t.notification.channel_id !== notification.channel_id) : toasts; | |
| 51 | return [...kept, { notification, at: now }].slice(-MAX_TOASTS); | |
| 52 | } | |
| 53 | ||
| 54 | /** The space between stacked toasts. */ | |
| 55 | export const TOAST_GAP = 8; | |
| 56 | /** The "+2 more" pill's height. */ | |
| 57 | export const PILL_HEIGHT = 30; | |
| 58 | /** A toast's height before it was measured. */ | |
| 59 | export const TOAST_GUESS = 120; | |
| 60 | ||
| 61 | /** | |
| 62 | * How many of the oldest toasts fold into "+N more" so the stack fits in | |
| 63 | * `available` pixels. `heights` are oldest first; `reserved` is what | |
| 64 | * always shows besides (the offer of browser notifications). The newest | |
| 65 | * toast always shows; nothing ever overlaps. | |
| 66 | */ | |
| 67 | export function hiddenToFit(heights: number[], available: number, reserved = 0): number { | |
| 68 | const height = (hidden: number) => { | |
| 69 | const shown = heights.slice(hidden); | |
| 70 | const parts = shown.length + (reserved > 0 ? 1 : 0) + (hidden > 0 ? 1 : 0); | |
| 71 | return reserved + shown.reduce((sum, h) => sum + h, 0) + (hidden > 0 ? PILL_HEIGHT : 0) + TOAST_GAP * Math.max(0, parts - 1); | |
| 72 | }; | |
| 73 | let hidden = 0; | |
| 74 | while (hidden < heights.length - 1 && height(hidden) > available) hidden++; | |
| 75 | return hidden; | |
| 76 | } | |
| 77 | ||
| 78 | export function dismissToast(toasts: Toast[], id: string): Toast[] { | |
| 79 | return toasts.filter((t) => t.notification.id !== id); | |
| 80 | } | |
| 81 | ||
| 82 | /** Whether a notification gets a reply box: something said to you, in a conversation. */ | |
| 83 | export function canQuickReply(notification: FeedNotification): boolean { | |
| 84 | return (notification.kind === "dm" || notification.kind === "mention" || notification.kind === "thread_reply") && !!notification.channel_id && !!notification.workspace; | |
| 85 | } | |
| 86 | ||
| 87 | /** What a quick reply posts to the chat api route. */ | |
| 88 | export function quickReplyRequest(notification: FeedNotification, body: string): { url: string; body: { intent: "post"; channel_id: string; body: string; thread_root: string | null } } | null { | |
| 89 | const text = body.trim(); | |
| 90 | if (!text || !canQuickReply(notification)) return null; | |
| 91 | return { | |
| 92 | url: `/${notification.workspace}/-/chat/api`, | |
| 93 | body: { intent: "post", channel_id: notification.channel_id!, body: text, thread_root: notification.thread_root ?? null }, | |
| 94 | }; | |
| 95 | } | |
| 96 | ||
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 97 | // ── Cards ───────────────────────────────────────────────────────────────── |
| 98 | ||
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 99 | /** |
| 100 | * A card's actions on its notification, what the toast and the panel | |
| 101 | * offer: its links. Nothing answers a card's other buttons any more (they | |
| 102 | * were an agent's), so they are left out. | |
| 103 | */ | |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 104 | export function notificationActions(notification: FeedNotification): CardAction[] { |
| 105 | const card = notification.card; | |
| 106 | if (!card || !notification.workspace || !card.channel_id || !card.message_id) return []; | |
| The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404. | 107 | return card.actions.filter((a) => !!a.id && !!a.label && !!a.href); |
| Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread | 108 | } |
| 109 | ||
| 110 | /** How long a card's notification stays in the panel's "Waiting on you". */ | |
| 111 | export const WAITING_MS = 24 * 3600_000; | |
| 112 | /** The most notifications the panel keeps to look through. */ | |
| 113 | export const RECENT_KEPT = 50; | |
| 114 | ||
| 115 | /** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */ | |
| 116 | export function addRecent(recent: FeedNotification[], incoming: FeedNotification[]): FeedNotification[] { | |
| 117 | const seen = new Set<string>(); | |
| 118 | const out: FeedNotification[] = []; | |
| 119 | for (const n of [...incoming, ...recent]) { | |
| 120 | if (seen.has(n.id)) continue; | |
| 121 | seen.add(n.id); | |
| 122 | out.push(n); | |
| 123 | } | |
| 124 | return out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)).slice(0, RECENT_KEPT); | |
| 125 | } | |
| 126 | ||
| 127 | /** | |
| 128 | * The cards waiting on the person, for the panel: notifications with | |
| 129 | * actions from the last day, the newest one per card, unless it was acted | |
| 130 | * on or put away here. | |
| 131 | */ | |
| 132 | export function waitingCards(recent: FeedNotification[], settled: ReadonlySet<string>, now: number, workspace?: string | null): FeedNotification[] { | |
| 133 | const cards = new Set<string>(); | |
| 134 | const out: FeedNotification[] = []; | |
| 135 | for (const n of recent) { | |
| 136 | if (!notificationActions(n).length) continue; | |
| 137 | if (workspace && n.workspace !== workspace.toLowerCase()) continue; | |
| 138 | // The newest about a card speaks for it: once that one is acted on, older ones are moot. | |
| 139 | const key = `${n.card!.channel_id}:${n.card!.message_id}`; | |
| 140 | if (cards.has(key)) continue; | |
| 141 | cards.add(key); | |
| 142 | if (settled.has(n.id)) continue; | |
| 143 | const at = Date.parse(n.created_at); | |
| 144 | if (Number.isFinite(at) && now - at > WAITING_MS) continue; | |
| 145 | out.push(n); | |
| 146 | } | |
| 147 | return out; | |
| 148 | } | |
| 149 | ||
| 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) | 150 | // ── Counts ──────────────────────────────────────────────────────────────── |
| 151 | ||
| 152 | /** What the rail shows for a workspace. */ | |
| 153 | export type LiveBadges = { chat: number; mentions: number; inbox: number }; | |
| 154 | ||
| 155 | /** | |
| 156 | * The rail's numbers from the feed, or null while it cannot say: chat's | |
| 157 | * only once the feed read them from chat itself (`complete`). | |
| 158 | */ | |
| 159 | export function badgesOf(counts: FeedCounts | null | undefined, inbox: number | null): Partial<LiveBadges> | null { | |
| 160 | const out: Partial<LiveBadges> = {}; | |
| 161 | if (counts?.complete) { | |
| 162 | out.chat = counts.chat_unread; | |
| 163 | out.mentions = counts.chat_mentions; | |
| 164 | } | |
| 165 | const inboxUnread = inbox ?? (counts ? counts.inbox_unread : null); | |
| 166 | if (inboxUnread != null) out.inbox = inboxUnread; | |
| 167 | return Object.keys(out).length ? out : null; | |
| 168 | } | |
| 169 | ||
| 170 | /** A conversation read here, zeroed until the feed says so itself. */ | |
| 171 | export function markReadLocally(counts: FeedCounts, channelId: string): FeedCounts { | |
| 172 | const row = counts.per_channel.find((c) => c.channel_id === channelId); | |
| 173 | if (!row) return counts; | |
| 174 | // Muted unread was never in chat_unread; it is not known here, so the feed's next word settles it. | |
| 175 | return { | |
| 176 | ...counts, | |
| 177 | chat_unread: Math.max(0, counts.chat_unread - row.unread), | |
| 178 | chat_mentions: Math.max(0, counts.chat_mentions - row.mentions), | |
| 179 | per_channel: counts.per_channel.filter((c) => c.channel_id !== channelId), | |
| 180 | }; | |
| 181 | } | |
| 182 | ||
| 183 | /** | |
| 184 | * The Chat sidebar with the feed's counts: each conversation's unread and | |
| 185 | * mentions as the feed has them (none, when it does not list it). Only | |
| 186 | * once the feed's counts are complete; before, the sidebar as it was read. | |
| 187 | */ | |
| 188 | export function overlayEntries(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): ChatSidebarEntry[] { | |
| 189 | if (!counts?.complete) return entries; | |
| 190 | const by = new Map(counts.per_channel.map((c) => [c.channel_id, c])); | |
| 191 | let changed = false; | |
| 192 | const out = entries.map((entry) => { | |
| 193 | const live = by.get(entry.channel.id); | |
| 194 | const unread = live?.unread ?? 0; | |
| 195 | const mentions = live?.mentions ?? 0; | |
| 196 | if (unread === entry.unread && mentions === entry.mentions) return entry; | |
| 197 | changed = true; | |
| 198 | return { ...entry, unread, mentions }; | |
| 199 | }); | |
| 200 | return changed ? out : entries; | |
| 201 | } | |
| 202 | ||
| 203 | /** Conversations the feed counts that the sidebar does not list: a new DM, a channel just joined. */ | |
| 204 | export function unlisted(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): string[] { | |
| 205 | if (!counts) return []; | |
| 206 | const listed = new Set(entries.map((e) => e.channel.id)); | |
| 207 | return counts.per_channel.filter((c) => c.unread > 0 && !listed.has(c.channel_id)).map((c) => c.channel_id); | |
| 208 | } | |
| 209 | ||
| 210 | // ── The tab ─────────────────────────────────────────────────────────────── | |
| 211 | ||
| 212 | const COUNTED = /^\(\d+\+?\) /; | |
| 213 | ||
| 214 | /** The tab's title with `count` in front, or without one at zero. */ | |
| 215 | export function titleWith(title: string, count: number): string { | |
| 216 | const bare = title.replace(COUNTED, ""); | |
| 217 | if (count <= 0) return bare; | |
| 218 | return `(${count > 99 ? "99+" : count}) ${bare}`; | |
| 219 | } | |
| 220 | ||
| 221 | /** What the tab and the app icon count: chat unread (mentions when there are none) and the inbox. */ | |
| 222 | export function attentionCount(badges: Partial<LiveBadges> | null): number { | |
| 223 | if (!badges) return 0; | |
| 224 | return Math.max(badges.chat ?? 0, badges.mentions ?? 0) + (badges.inbox ?? 0); | |
| 225 | } | |
| 226 | ||
| 227 | // ── Browser notifications ───────────────────────────────────────────────── | |
| 228 | ||
| 229 | /** What the person said to the offer, kept in the browser. */ | |
| 230 | export type PushChoice = "declined" | "off" | "on" | null; | |
| 231 | ||
| 232 | /** | |
| 233 | * Whether to offer browser notifications: after a DM or mention toast, | |
| 234 | * only where the browser can, only while it has not been asked, and never | |
| 235 | * again once the person said no or closed the offer. | |
| 236 | */ | |
| 237 | export function offerPush(input: { | |
| 238 | kind: FeedNotification["kind"]; | |
| 239 | supported: boolean; | |
| 240 | permission: "default" | "granted" | "denied"; | |
| 241 | choice: PushChoice; | |
| 242 | hasKey: boolean; | |
| 243 | desktop: boolean; | |
| 244 | }): boolean { | |
| 245 | if (input.desktop || !input.supported || !input.hasKey) return false; | |
| 246 | if (input.kind !== "dm" && input.kind !== "mention") return false; | |
| 247 | if (input.permission !== "default") return false; | |
| 248 | return input.choice === null; | |
| 249 | } | |
| 250 | ||
| 251 | /** Full jitter: a wait anywhere up to the doubled step, from a second up to half a minute. */ | |
| Live sockets back off until a connection holds: one the server accepts and drops at once (a Worker being replaced) no longer reconnects every second from every open tab | 252 | /** |
| 253 | * How long a connection must stay open before its backoff starts over. A | |
| 254 | * socket the server accepts and drops at once (a Worker being replaced, a | |
| 255 | * room that can't start) would otherwise reconnect every second from every | |
| 256 | * open tab, and a busy network runs into the rate limit. | |
| 257 | */ | |
| 258 | export const STABLE_MS = 10_000; | |
| 259 | ||
| 260 | /** Whether a connection that opened at `openedAt` held long enough to start the backoff over. */ | |
| 261 | export function heldOpen(openedAt: number | null, now: number = Date.now()): boolean { | |
| 262 | return openedAt !== null && now - openedAt >= STABLE_MS; | |
| 263 | } | |
| 264 | ||
| 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) | 265 | export function reconnectDelay(attempt: number, random: () => number = Math.random): number { |
| 266 | const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10))); | |
| 267 | return Math.round(500 + random() * (ceiling - 500)); | |
| 268 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.