Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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

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