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

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