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

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