Skip to content
726 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 * Live notifications in the browser: one socket per tab to the person's
3 * feed (`/-/live`, services/notify), a small store the page reads (counts
4 * for the rail, the Chat sidebar and the tab title; toasts; whether the
5 * socket is up), and delivery through a `NotificationSink`: the web's own
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.6 * (toasts and the app badge) or, inside the desktop app, its native
7 * bridge. Sounds are apart: every notification goes to
8 * lib/sound-events.ts, which decides and plays (lib/chat-sounds.ts,
9 * lib/sounds.ts).
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)10 *
11 * Nothing here polls. The feed sends counts on connect and after every
12 * change; while the socket is down, `connected` is false and the pages
13 * that used to poll may fall back to a slow refresh.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers14 *
15 * Presence rides the same socket: the tab says when it goes idle (no input
16 * for `IDLE_MS`) or comes back, the feed sends how everyone in the
17 * workspace shows (`presence`) and your own (`me`), and `setPresence`
18 * changes yours (lib/presence.ts, components/presence.tsx).
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)19 */
20import { useSyncExternalStore } from "react";
21
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers22import type { FeedCounts, FeedEvent, FeedNotification, NotifyPreferences, OwnPresence, PresenceChange, PresenceEntry } from "@g1t/contracts";
23
Merge branch 'socket-tickets'24import { openLive } from "./live-socket";
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.25import { dndOn, isIdle, mergePeople } from "./presence";
26import { notificationArrived } from "./sound-events";
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)27
28import {
29 HEARTBEAT_MS,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread30 addRecent,
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)31 addToast,
32 attentionCount,
33 badgesOf,
34 dismissToast,
35 markReadLocally,
36 offerPush,
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 tab37 heldOpen,
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)38 reconnectDelay,
39 titleWith,
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread40 waitingCards,
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)41 type LiveBadges,
42 type PushChoice,
43 type Toast,
44} from "./notify-store";
45
46// ── The desktop bridge ───────────────────────────────────────────────────
47
48/**
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.49 * What the desktop app (an Electron shell loading this web app) exposes
50 * from its preload script with
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)51 * `contextBridge.exposeInMainWorld("g1tDesktop", …)`. When it is there,
52 * notifications go to it instead of the browser:
53 *
54 * ```ts
55 * window.g1tDesktop = {
56 * version: "1.0.0",
57 * // A native notification. Clicking it brings the window forward and
58 * // navigates to `href` (calling the `onNavigate` listeners).
59 * notify({ id, title, body, href, tag, icon }) {},
60 * // The dock or taskbar badge and the tray's count; 0 clears it.
61 * setBadge(count) {},
62 * // Opens a URL outside the app, in the system browser.
63 * openUrl(url) {},
64 * // g1t:// deep links and notification clicks, as site paths ("/acme/-/chat/dm/chn_1").
65 * onNavigate(listener) { return () => {} },
66 * };
67 * ```
68 */
69export type DesktopBridge = {
70 version?: string;
71 notify(notification: { id: string; title: string; body: string; href: string; tag: string; icon?: string | null }): void;
72 setBadge(count: number): void;
73 openUrl(url: string): void;
74 onNavigate?(listener: (href: string) => void): () => void;
75};
76
77declare global {
78 interface Window {
79 g1tDesktop?: DesktopBridge;
80 }
81}
82
83export function desktopBridge(): DesktopBridge | null {
84 if (typeof window === "undefined") return null;
85 const bridge = window.g1tDesktop;
86 return bridge && typeof bridge.notify === "function" && typeof bridge.setBadge === "function" ? bridge : null;
87}
88
89// ── Sinks ────────────────────────────────────────────────────────────────
90
91/** Where a live notification goes once it arrives. */
92export interface NotificationSink {
93 readonly kind: "web" | "desktop";
94 /** A notification arrived; `toast` says the preferences want it shown, `focused` whether this tab is in front. */
95 deliver(notification: FeedNotification, options: { toast: boolean; focused: boolean }): void;
96 /** What needs the person, for the app icon. */
97 setBadge(count: number): void;
98 /** Goes to a page on the site. */
99 open(href: string): void;
100}
101
102let navigator_: ((href: string) => void) | null = null;
103
104/** The router's navigate, so a toast or a notification click moves without a reload. */
105export function setNavigator(navigate: ((href: string) => void) | null): void {
106 navigator_ = navigate;
107}
108
109function openHref(href: string): void {
110 if (navigator_) navigator_(href);
111 else if (typeof location !== "undefined") location.assign(href);
112}
113
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.114/** The web's: a toast in the tab, the installed app's badge. */
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)115export const webSink: NotificationSink = {
116 kind: "web",
117 deliver(notification, { toast }) {
118 if (!toast) return;
119 showToast(notification);
120 },
121 setBadge(count) {
122 const nav = typeof navigator !== "undefined" ? (navigator as Navigator & { setAppBadge?(n: number): Promise<void>; clearAppBadge?(): Promise<void> }) : null;
123 try {
124 if (count > 0) void nav?.setAppBadge?.(count)?.catch(() => {});
125 else void nav?.clearAppBadge?.()?.catch(() => {});
126 } catch {
127 // Not installed, or not allowed: the tab title says it anyway.
128 }
129 },
130 open: openHref,
131};
132
133/** The desktop app's: toasts while its window is in front, native notifications while it is not. */
134export function desktopSink(bridge: DesktopBridge): NotificationSink {
135 return {
136 kind: "desktop",
137 deliver(notification, { toast, focused }) {
138 if (!toast) return;
139 if (focused) {
140 showToast(notification);
141 return;
142 }
143 bridge.notify({
144 id: notification.id,
145 title: notification.title,
146 body: notification.body,
147 href: notification.href,
148 tag: notification.channel_id ? `chat:${notification.channel_id}` : `${notification.kind}:${notification.id}`,
149 icon: null,
150 });
151 },
152 setBadge(count) {
153 bridge.setBadge(count);
154 },
155 open: openHref,
156 };
157}
158
159/** The desktop bridge's sink when there is one, else the web's. */
160export function currentSink(): NotificationSink {
161 const bridge = desktopBridge();
162 return bridge ? desktopSink(bridge) : webSink;
163}
164
165// ── The store ────────────────────────────────────────────────────────────
166
167export type NotifyState = {
168 /** Whether the feed socket is up. */
169 connected: boolean;
170 /** Counts by workspace, as the feed last sent them. */
171 counts: Record<string, FeedCounts>;
172 /** The inbox's unread, the person's whole inbox. */
173 inbox: number | null;
174 toasts: Toast[];
175 preferences: NotifyPreferences | null;
176 vapidKey: string | null;
177 /** Whether to show the offer to turn on browser notifications. */
178 offer: 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 managers179 /** How the people who share a workspace with you show, by user id. */
180 people: Record<string, PresenceEntry>;
181 /** Your own presence, status and Do Not Disturb, once the feed has said. */
182 me: OwnPresence | null;
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread183 /** The latest notifications, newest first: the panel's cards waiting on you come from these. */
184 recent: FeedNotification[];
185 /** Card notifications acted on (or put away) in this tab, by id. */
186 settled: ReadonlySet<string>;
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)187};
188
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread189let state: NotifyState = {
190 connected: false,
191 counts: {},
192 inbox: null,
193 toasts: [],
194 preferences: null,
195 vapidKey: null,
196 offer: false,
197 people: {},
198 me: null,
199 recent: [],
200 settled: new Set(),
201};
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)202const listeners = new Set<() => void>();
203const SERVER_STATE = state;
204
205function set(next: Partial<NotifyState>): void {
206 state = { ...state, ...next };
207 for (const listener of listeners) listener();
208}
209
210function subscribeStore(listener: () => void): () => void {
211 listeners.add(listener);
212 return () => listeners.delete(listener);
213}
214
215export function useNotifyState(): NotifyState {
216 return useSyncExternalStore(subscribeStore, () => state, () => SERVER_STATE);
217}
218
219/** The rail's numbers for a workspace from the feed, or null while it cannot say. */
220export function useLiveBadges(workspace: string | null | undefined): Partial<LiveBadges> | null {
221 const s = useNotifyState();
222 return badgesOf(workspace ? s.counts[workspace.toLowerCase()] : null, s.inbox);
223}
224
225/** A workspace's counts from the feed, for the Chat sidebar. */
226export function useLiveCounts(workspace: string | null | undefined): FeedCounts | null {
227 const s = useNotifyState();
228 return workspace ? (s.counts[workspace.toLowerCase()] ?? null) : null;
229}
230
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread231/** A card's notification was acted on, or put away: its toast goes, and the panel stops waiting on it. */
232export function settle(id: string): void {
233 if (state.settled.has(id)) return;
234 set({ settled: new Set(state.settled).add(id), toasts: dismissToast(state.toasts, id) });
235}
236
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.237/** The chat cards waiting on the person, newest first, for the panel. */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread238export function useWaitingCards(workspace?: string | null): FeedNotification[] {
239 const s = useNotifyState();
240 return waitingCards(s.recent, s.settled, Date.now(), workspace);
241}
242
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers243/** People's presence by user id, with an index by username (lowercased). */
244let peopleIndex: { from: Record<string, PresenceEntry>; byName: Map<string, PresenceEntry> } = { from: {}, byName: new Map() };
245
246function byName(people: Record<string, PresenceEntry>): Map<string, PresenceEntry> {
247 if (peopleIndex.from !== people) {
248 peopleIndex = { from: people, byName: new Map(Object.values(people).map((entry) => [entry.username.toLowerCase(), entry])) };
249 }
250 return peopleIndex.byName;
251}
252
253/**
254 * How a person shows, by id or username, or null while the feed has not
255 * said (they share no workspace with you, or the socket is not up yet).
256 * Your own comes from `me`, so it moves the moment you change it.
257 */
258export function usePresenceOf(person: { id?: string | null; username?: string | null } | null | undefined): PresenceEntry | null {
259 const s = useNotifyState();
260 if (!person) return null;
261 const mine = s.me && ((person.id && person.id === s.me.user_id) || (person.username && person.username.toLowerCase() === s.me.username.toLowerCase()));
262 if (mine) return s.me;
263 if (person.id && s.people[person.id]) return s.people[person.id]!;
264 return person.username ? (byName(s.people).get(person.username.toLowerCase()) ?? null) : null;
265}
266
267export function useOwnPresence(): OwnPresence | null {
268 return useNotifyState().me;
269}
270
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.271/** Whether you are not to be disturbed right now, for code outside React (the sound rules). */
272export function dndNow(): boolean {
273 return dndOn(state.me, Date.now());
274}
275
276/** Every notification, however it is shown, goes to the sound rules and the desktop toast (lib/sound-events.ts). */
277function heard(notification: FeedNotification, toast: boolean): void {
278 notificationArrived(notification, { toast, dnd: dndNow(), permission: permission(), pushOn: pushChoice() === "on", open: openHref });
279}
280
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)281/** Whether the feed is up: pages fall back to a slow refresh only while it is not. */
282export function feedConnected(): boolean {
283 return state.connected;
284}
285
286export function useFeedConnected(): boolean {
287 return useNotifyState().connected;
288}
289
290let viewing: { path: string; channel_id?: string | null } = { path: "" };
291
292/** The page the person is on, so its own conversation does not toast. */
293export function setViewing(next: { path: string; channel_id?: string | null }): void {
294 viewing = next;
295 sendState();
296}
297
298function showToast(notification: FeedNotification): void {
299 const toasts = addToast(state.toasts, notification, viewing, Date.now());
300 if (toasts === state.toasts) return;
301 const offer =
302 state.offer ||
303 offerPush({
304 kind: notification.kind,
305 supported: pushSupported(),
306 permission: permission(),
307 choice: pushChoice(),
308 hasKey: !!state.vapidKey,
309 desktop: !!desktopBridge(),
310 });
311 set({ toasts, offer });
312}
313
314export function dismiss(id: string): void {
315 set({ toasts: dismissToast(state.toasts, id) });
316}
317
318export function closeOffer(): void {
319 set({ offer: false });
320}
321
322/** For the settings page's test, and the harness: a notification shown as if it arrived. */
323export function showLocally(notification: FeedNotification): void {
324 currentSink().deliver(notification, { toast: true, focused: true });
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.325 heard(notification, true);
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)326}
327
328// ── The socket ───────────────────────────────────────────────────────────
329
330let socket: WebSocket | null = null;
331let workspace: string | null = null;
332let attempt = 0;
333let timer: ReturnType<typeof setTimeout> | null = null;
334let beat: ReturnType<typeof setInterval> | null = null;
335let running = false;
336let sink: NotificationSink = webSink;
337
338function focused(): boolean {
339 return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
340}
341
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers342// ── Idle ─────────────────────────────────────────────────────────────────
343
344let lastInput = Date.now();
345let idle = false;
346
347/** Input in this tab: back from idle at once, if it was. */
348function onInput(): void {
349 lastInput = Date.now();
350 if (idle) {
351 idle = false;
352 sendState();
353 }
354}
355
356/** Every heartbeat: gone idle since the last one? */
357function checkIdle(): void {
358 if (!idle && isIdle(lastInput, Date.now())) {
359 idle = true;
360 sendState();
361 }
362}
363
364const INPUT_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart"] as const;
365
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)366function sendState(): void {
367 if (socket?.readyState !== WebSocket.OPEN) return;
368 try {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers369 socket.send(JSON.stringify({ type: "state", focused: focused(), path: viewing.path, idle }));
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)370 } catch {
371 // Closing; it comes back.
372 }
373}
374
375/** The inbox count a page just read, so every tab shows it. */
376export function reportInbox(unread: number): void {
377 if (state.inbox === unread) return;
378 set({ inbox: unread });
379 if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "inbox", unread }));
380}
381
382function onEvent(event: FeedEvent): void {
383 switch (event.type) {
384 case "hello":
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread385 set({ preferences: event.preferences, vapidKey: event.vapid_public_key, recent: addRecent(state.recent, event.notifications ?? []) });
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)386 break;
387 case "counts": {
388 const { type: _, ...counts } = event;
389 set({ counts: { ...state.counts, [counts.workspace]: counts }, inbox: counts.inbox_unread });
390 break;
391 }
392 case "notification":
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread393 set({ recent: addRecent(state.recent, [event.notification]) });
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)394 sink.deliver(event.notification, { toast: event.toast, focused: focused() });
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.395 heard(event.notification, event.toast);
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)396 break;
397 case "preferences":
398 set({ preferences: event.preferences });
399 break;
400 case "inbox":
401 set({ inbox: event.unread });
402 break;
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers403 case "presence": {
404 const people = mergePeople(state.people, event.people, event.full);
405 if (people !== state.people) set({ people });
406 break;
407 }
408 case "me":
409 set({ me: event.me });
410 break;
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)411 }
412}
413
Merge branch 'socket-tickets'414/** Between asking for a socket ticket and opening the socket (lib/live-socket.ts). */
415let opening = false;
416
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)417function connect(): void {
Merge branch 'socket-tickets'418 if (!running || opening) return;
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)419 timer = null;
Merge branch 'socket-tickets'420 opening = true;
421 openLive(
422 "/-/live",
423 // Read when the socket opens: the workspace may change meanwhile.
424 () => ({ workspace }),
425 (address) => {
426 opening = false;
427 open(address);
428 },
429 () => {
430 if (running) return false;
431 opening = false;
432 return true;
433 },
434 );
435}
436
437function open(address: string): void {
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)438 let ws: WebSocket;
439 try {
Merge branch 'socket-tickets'440 ws = new WebSocket(address);
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)441 } catch {
442 schedule();
443 return;
444 }
445 socket = ws;
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 tab446 let openedAt: number | null = 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)447 ws.onopen = () => {
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 tab448 openedAt = Date.now();
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)449 set({ connected: true });
450 sendState();
451 };
452 ws.onmessage = (message) => {
453 if (message.data === "pong") return;
454 try {
455 onEvent(JSON.parse(String(message.data)) as FeedEvent);
456 } catch {
457 // Not an event this page knows.
458 }
459 };
460 ws.onclose = () => {
461 if (socket === ws) socket = null;
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 tab462 // Only a connection that held starts the backoff over.
463 if (heldOpen(openedAt)) attempt = 0;
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)464 set({ connected: false });
465 schedule();
466 };
467 ws.onerror = () => ws.close();
468}
469
470function schedule(): void {
471 if (!running || timer) return;
472 timer = setTimeout(connect, reconnectDelay(attempt++));
473}
474
475/** Back now: the tab is shown, or the network returned. */
476function now(): void {
477 sendState();
Merge branch 'socket-tickets'478 if (!running || opening || socket || document.visibilityState !== "visible") return;
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)479 if (timer) clearTimeout(timer);
480 timer = null;
481 attempt = 0;
482 connect();
483}
484
485function onRead(event: Event): void {
486 const channel = String((event as CustomEvent).detail ?? "");
487 if (!channel || !workspace || !state.counts[workspace]) return;
488 set({ counts: { ...state.counts, [workspace]: markReadLocally(state.counts[workspace], channel) } });
489}
490
491/**
492 * Opens the feed for the signed-in person (once per tab; calling again
493 * with another workspace reconnects, so the feed reads that workspace's
494 * counts). Returns how to close it.
495 */
496export function startFeed(forWorkspace: string | null): () => void {
497 const next = forWorkspace?.toLowerCase() ?? null;
498 if (running && next === workspace) return stopFeed;
499 workspace = next;
500 sink = currentSink();
501 if (running) {
502 // Another workspace: its counts are read on connect.
503 socket?.close(1000, "workspace");
504 return stopFeed;
505 }
506 running = true;
507 connect();
508 beat = setInterval(() => {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers509 checkIdle();
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)510 if (socket?.readyState === WebSocket.OPEN) socket.send("ping");
511 }, HEARTBEAT_MS);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers512 lastInput = Date.now();
513 idle = false;
514 for (const name of INPUT_EVENTS) window.addEventListener(name, onInput, { passive: true, capture: true });
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)515 document.addEventListener("visibilitychange", now);
516 window.addEventListener("focus", now);
517 window.addEventListener("blur", sendState);
518 window.addEventListener("online", now);
519 window.addEventListener("g1t:chat-read", onRead);
520 const bridge = desktopBridge();
521 const off = bridge?.onNavigate?.((href) => openHref(href));
522 unbridge = off ?? null;
523 return stopFeed;
524}
525
526let unbridge: (() => void) | null = null;
527
528export function stopFeed(): void {
529 running = false;
530 if (timer) clearTimeout(timer);
531 if (beat) clearInterval(beat);
532 timer = beat = null;
533 socket?.close(1000, "bye");
534 socket = null;
535 document.removeEventListener("visibilitychange", now);
536 window.removeEventListener("focus", now);
537 window.removeEventListener("blur", sendState);
538 window.removeEventListener("online", now);
539 window.removeEventListener("g1t:chat-read", onRead);
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers540 for (const name of INPUT_EVENTS) window.removeEventListener(name, onInput, { capture: true });
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)541 unbridge?.();
542 unbridge = null;
543 set({ connected: false });
544}
545
546/** Keeps the tab title's count and the app badge in step with `count`. */
547export function applyAttention(badges: Partial<LiveBadges> | null): number {
548 const count = attentionCount(badges);
549 if (typeof document !== "undefined") {
550 const next = titleWith(document.title, count);
551 if (next !== document.title) document.title = next;
552 }
553 sink.setBadge(count);
554 return count;
555}
556
557// ── Browser push ─────────────────────────────────────────────────────────
558
559const PUSH_KEY = "g1t:notify:push";
560
561export function pushChoice(): PushChoice {
562 try {
563 const value = localStorage.getItem(PUSH_KEY);
564 return value === "declined" || value === "off" || value === "on" ? value : null;
565 } catch {
566 return null;
567 }
568}
569
570function setPushChoice(choice: PushChoice): void {
571 try {
572 if (choice) localStorage.setItem(PUSH_KEY, choice);
573 else localStorage.removeItem(PUSH_KEY);
574 } catch {
575 // Asked again next time; nothing worse.
576 }
577}
578
579/** The person closed the offer or said no: never offered again. */
580export function declinePush(): void {
581 setPushChoice("declined");
582 set({ offer: false });
583}
584
585export function pushSupported(): boolean {
586 return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
587}
588
589export function permission(): "default" | "granted" | "denied" {
590 return typeof window !== "undefined" && "Notification" in window ? Notification.permission : "denied";
591}
592
593function keyBytes(base64url: string): Uint8Array<ArrayBuffer> {
594 const padded = base64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (base64url.length % 4)) % 4);
595 const raw = atob(padded);
596 const out = new Uint8Array(new ArrayBuffer(raw.length));
597 for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
598 return out;
599}
600
601async function registration(): Promise<ServiceWorkerRegistration> {
602 const existing = await navigator.serviceWorker.getRegistration("/");
603 return existing ?? navigator.serviceWorker.register("/sw.js", { scope: "/" });
604}
605
606async function api(body: object): Promise<Response> {
607 return fetch("/-/notify", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
608}
609
610/** The public key browsers subscribe with, from the feed or asked for. */
611async function vapidKey(): Promise<string | null> {
612 if (state.vapidKey) return state.vapidKey;
613 const status = await notifyStatus().catch(() => null);
614 return status?.vapid_public_key ?? null;
615}
616
617export type PushResult = "on" | "denied" | "unsupported" | "unavailable" | "failed";
618
619/**
620 * Asks the browser, then subscribes it: only ever from a click. A no is
621 * kept, so g1t never asks again.
622 */
623export async function enablePush(): Promise<PushResult> {
624 if (!pushSupported()) return "unsupported";
625 const key = await vapidKey();
626 if (!key) return "unavailable";
627 const answer = permission() === "granted" ? "granted" : await Notification.requestPermission();
628 if (answer !== "granted") {
629 setPushChoice("declined");
630 set({ offer: false });
631 return "denied";
632 }
633 try {
634 const reg = await registration();
635 await navigator.serviceWorker.ready;
636 const subscription = (await reg.pushManager.getSubscription()) ?? (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) }));
637 const response = await api({ intent: "subscribe", subscription: subscription.toJSON() });
638 if (!response.ok) return "failed";
639 setPushChoice("on");
640 set({ offer: false });
641 return "on";
642 } catch (error) {
643 console.error("notify: could not subscribe", error);
644 return "failed";
645 }
646}
647
648/** Stops pushes to this browser; kept as the person's choice. */
649export async function disablePush(): Promise<void> {
650 setPushChoice("off");
651 if (!pushSupported()) return;
652 const reg = await navigator.serviceWorker.getRegistration("/");
653 const subscription = await reg?.pushManager.getSubscription();
654 if (!subscription) return;
655 await api({ intent: "unsubscribe", endpoint: subscription.endpoint }).catch(() => null);
656 await subscription.unsubscribe().catch(() => false);
657}
658
659/** This browser's push endpoint, if subscribed. */
660export async function currentEndpoint(): Promise<string | null> {
661 if (!pushSupported()) return null;
662 const reg = await navigator.serviceWorker.getRegistration("/");
663 return (await reg?.pushManager.getSubscription())?.endpoint ?? null;
664}
665
666export async function notifyStatus(): Promise<import("@g1t/contracts").NotifyStatus | null> {
667 const endpoint = await currentEndpoint().catch(() => null);
668 const response = await fetch(`/-/notify${endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : ""}`, { headers: { accept: "application/json" } });
669 return response.ok ? response.json() : null;
670}
671
672/**
673 * On each visit, for someone who turned pushes on: the service worker is
674 * registered, and the subscription is handed over again (browsers renew
675 * them), so pushes keep arriving. Asks nothing.
676 */
677export async function refreshPush(): Promise<void> {
678 if (!pushSupported() || desktopBridge()) return;
679 if (permission() !== "granted" || pushChoice() === "off") return;
680 const key = await vapidKey();
681 if (!key) return;
682 const reg = await registration();
683 await navigator.serviceWorker.ready;
684 let subscription = await reg.pushManager.getSubscription();
685 subscription ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
686 await api({ intent: "subscribe", subscription: subscription.toJSON() });
687}
688
689export async function savePreferences(preferences: import("@g1t/contracts").NotifyPreferencesChange): Promise<NotifyPreferences | null> {
690 const response = await api({ intent: "preferences", preferences });
691 if (!response.ok) return null;
692 const saved = (await response.json()) as NotifyPreferences;
693 set({ preferences: saved });
694 return saved;
695}
696
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers697/**
698 * Changes your own presence: a status, being away, Do Not Disturb. Shown
699 * at once, and put back if the feed refuses; every tab and everyone who
700 * shares a workspace with you hear of it over the socket.
701 */
702export async function setPresence(change: PresenceChange, optimistic?: Partial<OwnPresence>): Promise<OwnPresence | null> {
703 const before = state.me;
704 if (before && optimistic) set({ me: { ...before, ...optimistic } });
705 try {
706 const response = await api({ intent: "presence", change });
707 if (!response.ok) throw new Error(`status ${response.status}`);
708 const me = (await response.json()) as OwnPresence;
709 set({ me });
710 return me;
711 } catch (error) {
712 console.error("notify: could not change presence", error);
713 if (optimistic) set({ me: before });
714 return null;
715 }
716}
717
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)718export async function sendTest(): Promise<{ ok: boolean; pushed: number } | null> {
719 const response = await api({ intent: "test" });
720 return response.ok ? response.json() : null;
721}
722
723/** Test seams: the store's state as given, for the harness. */
724export function setStateForTest(next: Partial<NotifyState>): void {
725 set(next);
726}

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