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

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