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

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