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 | ||
| 99 | /** A card's actions on its notification: what the toast and the panel offer (docs/WORKSPACE.md, "Cards"). */ | |
| 100 | export function notificationActions(notification: FeedNotification): CardAction[] { | |
| 101 | const card = notification.card; | |
| 102 | if (!card || !notification.workspace || !card.channel_id || !card.message_id) return []; | |
| 103 | return card.actions.filter((a) => !!a.id && !!a.label); | |
| 104 | } | |
| 105 | ||
| 106 | /** What pressing one posts: the same `card_action` the card itself sends, to the chat api route. */ | |
| 107 | export function cardActionRequest( | |
| 108 | notification: FeedNotification, | |
| 109 | action: CardAction, | |
| 110 | input: string | null, | |
| 111 | ): { url: string; body: { intent: "card_action"; channel_id: string; message_id: string; action_id: string; input: string | null } } | null { | |
| 112 | const card = notification.card; | |
| 113 | if (action.href || !card || !notificationActions(notification).some((a) => a.id === action.id)) return null; | |
| 114 | return { | |
| 115 | url: `/${notification.workspace}/-/chat/api`, | |
| 116 | body: { intent: "card_action", channel_id: card.channel_id, message_id: card.message_id, action_id: action.id, input }, | |
| 117 | }; | |
| 118 | } | |
| 119 | ||
| 120 | /** How long a card's notification stays in the panel's "Waiting on you". */ | |
| 121 | export const WAITING_MS = 24 * 3600_000; | |
| 122 | /** The most notifications the panel keeps to look through. */ | |
| 123 | export const RECENT_KEPT = 50; | |
| 124 | ||
| 125 | /** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */ | |
| 126 | export function addRecent(recent: FeedNotification[], incoming: FeedNotification[]): FeedNotification[] { | |
| 127 | const seen = new Set<string>(); | |
| 128 | const out: FeedNotification[] = []; | |
| 129 | for (const n of [...incoming, ...recent]) { | |
| 130 | if (seen.has(n.id)) continue; | |
| 131 | seen.add(n.id); | |
| 132 | out.push(n); | |
| 133 | } | |
| 134 | return out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)).slice(0, RECENT_KEPT); | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * The cards waiting on the person, for the panel: notifications with | |
| 139 | * actions from the last day, the newest one per card, unless it was acted | |
| 140 | * on or put away here. | |
| 141 | */ | |
| 142 | export function waitingCards(recent: FeedNotification[], settled: ReadonlySet<string>, now: number, workspace?: string | null): FeedNotification[] { | |
| 143 | const cards = new Set<string>(); | |
| 144 | const out: FeedNotification[] = []; | |
| 145 | for (const n of recent) { | |
| 146 | if (!notificationActions(n).length) continue; | |
| 147 | if (workspace && n.workspace !== workspace.toLowerCase()) continue; | |
| 148 | // The newest about a card speaks for it: once that one is acted on, older ones are moot. | |
| 149 | const key = `${n.card!.channel_id}:${n.card!.message_id}`; | |
| 150 | if (cards.has(key)) continue; | |
| 151 | cards.add(key); | |
| 152 | if (settled.has(n.id)) continue; | |
| 153 | const at = Date.parse(n.created_at); | |
| 154 | if (Number.isFinite(at) && now - at > WAITING_MS) continue; | |
| 155 | out.push(n); | |
| 156 | } | |
| 157 | return out; | |
| 158 | } | |
| 159 | ||
| 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) | 160 | // ── Counts ──────────────────────────────────────────────────────────────── |
| 161 | ||
| 162 | /** What the rail shows for a workspace. */ | |
| 163 | export type LiveBadges = { chat: number; mentions: number; inbox: number }; | |
| 164 | ||
| 165 | /** | |
| 166 | * The rail's numbers from the feed, or null while it cannot say: chat's | |
| 167 | * only once the feed read them from chat itself (`complete`). | |
| 168 | */ | |
| 169 | export function badgesOf(counts: FeedCounts | null | undefined, inbox: number | null): Partial<LiveBadges> | null { | |
| 170 | const out: Partial<LiveBadges> = {}; | |
| 171 | if (counts?.complete) { | |
| 172 | out.chat = counts.chat_unread; | |
| 173 | out.mentions = counts.chat_mentions; | |
| 174 | } | |
| 175 | const inboxUnread = inbox ?? (counts ? counts.inbox_unread : null); | |
| 176 | if (inboxUnread != null) out.inbox = inboxUnread; | |
| 177 | return Object.keys(out).length ? out : null; | |
| 178 | } | |
| 179 | ||
| 180 | /** A conversation read here, zeroed until the feed says so itself. */ | |
| 181 | export function markReadLocally(counts: FeedCounts, channelId: string): FeedCounts { | |
| 182 | const row = counts.per_channel.find((c) => c.channel_id === channelId); | |
| 183 | if (!row) return counts; | |
| 184 | // Muted unread was never in chat_unread; it is not known here, so the feed's next word settles it. | |
| 185 | return { | |
| 186 | ...counts, | |
| 187 | chat_unread: Math.max(0, counts.chat_unread - row.unread), | |
| 188 | chat_mentions: Math.max(0, counts.chat_mentions - row.mentions), | |
| 189 | per_channel: counts.per_channel.filter((c) => c.channel_id !== channelId), | |
| 190 | }; | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * The Chat sidebar with the feed's counts: each conversation's unread and | |
| 195 | * mentions as the feed has them (none, when it does not list it). Only | |
| 196 | * once the feed's counts are complete; before, the sidebar as it was read. | |
| 197 | */ | |
| 198 | export function overlayEntries(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): ChatSidebarEntry[] { | |
| 199 | if (!counts?.complete) return entries; | |
| 200 | const by = new Map(counts.per_channel.map((c) => [c.channel_id, c])); | |
| 201 | let changed = false; | |
| 202 | const out = entries.map((entry) => { | |
| 203 | const live = by.get(entry.channel.id); | |
| 204 | const unread = live?.unread ?? 0; | |
| 205 | const mentions = live?.mentions ?? 0; | |
| 206 | if (unread === entry.unread && mentions === entry.mentions) return entry; | |
| 207 | changed = true; | |
| 208 | return { ...entry, unread, mentions }; | |
| 209 | }); | |
| 210 | return changed ? out : entries; | |
| 211 | } | |
| 212 | ||
| 213 | /** Conversations the feed counts that the sidebar does not list: a new DM, a channel just joined. */ | |
| 214 | export function unlisted(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): string[] { | |
| 215 | if (!counts) return []; | |
| 216 | const listed = new Set(entries.map((e) => e.channel.id)); | |
| 217 | return counts.per_channel.filter((c) => c.unread > 0 && !listed.has(c.channel_id)).map((c) => c.channel_id); | |
| 218 | } | |
| 219 | ||
| 220 | // ── The tab ─────────────────────────────────────────────────────────────── | |
| 221 | ||
| 222 | const COUNTED = /^\(\d+\+?\) /; | |
| 223 | ||
| 224 | /** The tab's title with `count` in front, or without one at zero. */ | |
| 225 | export function titleWith(title: string, count: number): string { | |
| 226 | const bare = title.replace(COUNTED, ""); | |
| 227 | if (count <= 0) return bare; | |
| 228 | return `(${count > 99 ? "99+" : count}) ${bare}`; | |
| 229 | } | |
| 230 | ||
| 231 | /** What the tab and the app icon count: chat unread (mentions when there are none) and the inbox. */ | |
| 232 | export function attentionCount(badges: Partial<LiveBadges> | null): number { | |
| 233 | if (!badges) return 0; | |
| 234 | return Math.max(badges.chat ?? 0, badges.mentions ?? 0) + (badges.inbox ?? 0); | |
| 235 | } | |
| 236 | ||
| 237 | // ── Browser notifications ───────────────────────────────────────────────── | |
| 238 | ||
| 239 | /** What the person said to the offer, kept in the browser. */ | |
| 240 | export type PushChoice = "declined" | "off" | "on" | null; | |
| 241 | ||
| 242 | /** | |
| 243 | * Whether to offer browser notifications: after a DM or mention toast, | |
| 244 | * only where the browser can, only while it has not been asked, and never | |
| 245 | * again once the person said no or closed the offer. | |
| 246 | */ | |
| 247 | export function offerPush(input: { | |
| 248 | kind: FeedNotification["kind"]; | |
| 249 | supported: boolean; | |
| 250 | permission: "default" | "granted" | "denied"; | |
| 251 | choice: PushChoice; | |
| 252 | hasKey: boolean; | |
| 253 | desktop: boolean; | |
| 254 | }): boolean { | |
| 255 | if (input.desktop || !input.supported || !input.hasKey) return false; | |
| 256 | if (input.kind !== "dm" && input.kind !== "mention") return false; | |
| 257 | if (input.permission !== "default") return false; | |
| 258 | return input.choice === null; | |
| 259 | } | |
| 260 | ||
| 261 | /** Full jitter: a wait anywhere up to the doubled step, from a second up to half a minute. */ | |
| 262 | export function reconnectDelay(attempt: number, random: () => number = Math.random): number { | |
| 263 | const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10))); | |
| 264 | return Math.round(500 + random() * (ceiling - 500)); | |
| 265 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.