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

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