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
268 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 * The rules behind live notifications in the browser, apart from the
3 * browser: which toasts show, how counts merge, what the tab title says,
4 * when to offer browser notifications, and how long to wait before
5 * reconnecting. Pure, so they are tested apart from the socket
6 * (lib/notify-client.ts) and the toasts (components/notifications/).
7 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread8import type { CardAction, ChatSidebarEntry, FeedCounts, FeedNotification } from "@g1t/contracts";
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)9
10/** The most toasts on screen; a fourth pushes the oldest out. */
11export const MAX_TOASTS = 3;
12/** How long a toast stays, unless the pointer or the keyboard is on it. */
13export const TOAST_MS = 6_000;
14/** Every so often the socket says it is still there, focus and all. */
15export const HEARTBEAT_MS = 25_000;
16/** While the socket is down, the Chat sidebar asks again this often. */
17export const FALLBACK_REFRESH_MS = 60_000;
18
19export type Toast = { notification: FeedNotification; at: number };
20
21/** The path a link goes to, without its query or fragment. */
22export function pathOf(href: string): string {
23 return href.split(/[?#]/)[0].replace(/\/+$/, "").toLowerCase();
24}
25
26/**
27 * Whether the person is looking at the conversation a notification is
28 * about right now: its page is open, or the chat page open says it shows
29 * that channel. A toast for it would only repeat what is on screen.
30 */
31export function isViewing(notification: FeedNotification, viewing: { path: string; channel_id?: string | null }): boolean {
32 if (notification.channel_id && viewing.channel_id && notification.channel_id === viewing.channel_id) return true;
33 if (!notification.channel_id) return false;
34 return pathOf(notification.href) === pathOf(viewing.path);
35}
36
37/**
38 * The toasts after one arrives: never twice, never for what is on screen,
39 * newest last, at most `MAX_TOASTS`.
40 */
41export function addToast(
42 toasts: Toast[],
43 notification: FeedNotification,
44 viewing: { path: string; channel_id?: string | null },
45 now: number,
46): Toast[] {
47 if (toasts.some((t) => t.notification.id === notification.id)) return toasts;
48 if (isViewing(notification, viewing)) return toasts;
49 // A newer message in the same conversation replaces the older toast.
50 const kept = notification.channel_id ? toasts.filter((t) => t.notification.channel_id !== notification.channel_id) : toasts;
51 return [...kept, { notification, at: now }].slice(-MAX_TOASTS);
52}
53
54/** The space between stacked toasts. */
55export const TOAST_GAP = 8;
56/** The "+2 more" pill's height. */
57export const PILL_HEIGHT = 30;
58/** A toast's height before it was measured. */
59export const TOAST_GUESS = 120;
60
61/**
62 * How many of the oldest toasts fold into "+N more" so the stack fits in
63 * `available` pixels. `heights` are oldest first; `reserved` is what
64 * always shows besides (the offer of browser notifications). The newest
65 * toast always shows; nothing ever overlaps.
66 */
67export function hiddenToFit(heights: number[], available: number, reserved = 0): number {
68 const height = (hidden: number) => {
69 const shown = heights.slice(hidden);
70 const parts = shown.length + (reserved > 0 ? 1 : 0) + (hidden > 0 ? 1 : 0);
71 return reserved + shown.reduce((sum, h) => sum + h, 0) + (hidden > 0 ? PILL_HEIGHT : 0) + TOAST_GAP * Math.max(0, parts - 1);
72 };
73 let hidden = 0;
74 while (hidden < heights.length - 1 && height(hidden) > available) hidden++;
75 return hidden;
76}
77
78export function dismissToast(toasts: Toast[], id: string): Toast[] {
79 return toasts.filter((t) => t.notification.id !== id);
80}
81
82/** Whether a notification gets a reply box: something said to you, in a conversation. */
83export function canQuickReply(notification: FeedNotification): boolean {
84 return (notification.kind === "dm" || notification.kind === "mention" || notification.kind === "thread_reply") && !!notification.channel_id && !!notification.workspace;
85}
86
87/** What a quick reply posts to the chat api route. */
88export function quickReplyRequest(notification: FeedNotification, body: string): { url: string; body: { intent: "post"; channel_id: string; body: string; thread_root: string | null } } | null {
89 const text = body.trim();
90 if (!text || !canQuickReply(notification)) return null;
91 return {
92 url: `/${notification.workspace}/-/chat/api`,
93 body: { intent: "post", channel_id: notification.channel_id!, body: text, thread_root: notification.thread_root ?? null },
94 };
95}
96
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread97// ── Cards ─────────────────────────────────────────────────────────────────
98
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.99/**
100 * A card's actions on its notification, what the toast and the panel
101 * offer: its links. Nothing answers a card's other buttons any more (they
102 * were an agent's), so they are left out.
103 */
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread104export function notificationActions(notification: FeedNotification): CardAction[] {
105 const card = notification.card;
106 if (!card || !notification.workspace || !card.channel_id || !card.message_id) return [];
The site no longer has g1t's own agents. g1t is where the coding agents you already use do their work, so the Agents mode is gone from the rail, with every page under /<workspace>/-/agents: personas, faces, skills, abilities, routines, computers, sessions and agent spend. Customize (skills, connectors, plugins, marketplaces), the context hub, model providers, the AI Gateway page, the Spend page and pill, Ask g1t, and the repository's Agents, Plans and agent settings pages go too, and their addresses now answer 404.107 return card.actions.filter((a) => !!a.id && !!a.label && !!a.href);
Write a thread up in Docs from chat; cards' buttons in notifications; a session's card stays at the top of its thread108}
109
110/** How long a card's notification stays in the panel's "Waiting on you". */
111export const WAITING_MS = 24 * 3600_000;
112/** The most notifications the panel keeps to look through. */
113export const RECENT_KEPT = 50;
114
115/** Notifications as the feed tells them, newest first, each once, at most `RECENT_KEPT`. */
116export function addRecent(recent: FeedNotification[], incoming: FeedNotification[]): FeedNotification[] {
117 const seen = new Set<string>();
118 const out: FeedNotification[] = [];
119 for (const n of [...incoming, ...recent]) {
120 if (seen.has(n.id)) continue;
121 seen.add(n.id);
122 out.push(n);
123 }
124 return out.sort((a, b) => (a.created_at < b.created_at ? 1 : a.created_at > b.created_at ? -1 : 0)).slice(0, RECENT_KEPT);
125}
126
127/**
128 * The cards waiting on the person, for the panel: notifications with
129 * actions from the last day, the newest one per card, unless it was acted
130 * on or put away here.
131 */
132export function waitingCards(recent: FeedNotification[], settled: ReadonlySet<string>, now: number, workspace?: string | null): FeedNotification[] {
133 const cards = new Set<string>();
134 const out: FeedNotification[] = [];
135 for (const n of recent) {
136 if (!notificationActions(n).length) continue;
137 if (workspace && n.workspace !== workspace.toLowerCase()) continue;
138 // The newest about a card speaks for it: once that one is acted on, older ones are moot.
139 const key = `${n.card!.channel_id}:${n.card!.message_id}`;
140 if (cards.has(key)) continue;
141 cards.add(key);
142 if (settled.has(n.id)) continue;
143 const at = Date.parse(n.created_at);
144 if (Number.isFinite(at) && now - at > WAITING_MS) continue;
145 out.push(n);
146 }
147 return out;
148}
149
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)150// ── Counts ────────────────────────────────────────────────────────────────
151
152/** What the rail shows for a workspace. */
153export type LiveBadges = { chat: number; mentions: number; inbox: number };
154
155/**
156 * The rail's numbers from the feed, or null while it cannot say: chat's
157 * only once the feed read them from chat itself (`complete`).
158 */
159export function badgesOf(counts: FeedCounts | null | undefined, inbox: number | null): Partial<LiveBadges> | null {
160 const out: Partial<LiveBadges> = {};
161 if (counts?.complete) {
162 out.chat = counts.chat_unread;
163 out.mentions = counts.chat_mentions;
164 }
165 const inboxUnread = inbox ?? (counts ? counts.inbox_unread : null);
166 if (inboxUnread != null) out.inbox = inboxUnread;
167 return Object.keys(out).length ? out : null;
168}
169
170/** A conversation read here, zeroed until the feed says so itself. */
171export function markReadLocally(counts: FeedCounts, channelId: string): FeedCounts {
172 const row = counts.per_channel.find((c) => c.channel_id === channelId);
173 if (!row) return counts;
174 // Muted unread was never in chat_unread; it is not known here, so the feed's next word settles it.
175 return {
176 ...counts,
177 chat_unread: Math.max(0, counts.chat_unread - row.unread),
178 chat_mentions: Math.max(0, counts.chat_mentions - row.mentions),
179 per_channel: counts.per_channel.filter((c) => c.channel_id !== channelId),
180 };
181}
182
183/**
184 * The Chat sidebar with the feed's counts: each conversation's unread and
185 * mentions as the feed has them (none, when it does not list it). Only
186 * once the feed's counts are complete; before, the sidebar as it was read.
187 */
188export function overlayEntries(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): ChatSidebarEntry[] {
189 if (!counts?.complete) return entries;
190 const by = new Map(counts.per_channel.map((c) => [c.channel_id, c]));
191 let changed = false;
192 const out = entries.map((entry) => {
193 const live = by.get(entry.channel.id);
194 const unread = live?.unread ?? 0;
195 const mentions = live?.mentions ?? 0;
196 if (unread === entry.unread && mentions === entry.mentions) return entry;
197 changed = true;
198 return { ...entry, unread, mentions };
199 });
200 return changed ? out : entries;
201}
202
203/** Conversations the feed counts that the sidebar does not list: a new DM, a channel just joined. */
204export function unlisted(entries: ChatSidebarEntry[], counts: FeedCounts | null | undefined): string[] {
205 if (!counts) return [];
206 const listed = new Set(entries.map((e) => e.channel.id));
207 return counts.per_channel.filter((c) => c.unread > 0 && !listed.has(c.channel_id)).map((c) => c.channel_id);
208}
209
210// ── The tab ───────────────────────────────────────────────────────────────
211
212const COUNTED = /^\(\d+\+?\) /;
213
214/** The tab's title with `count` in front, or without one at zero. */
215export function titleWith(title: string, count: number): string {
216 const bare = title.replace(COUNTED, "");
217 if (count <= 0) return bare;
218 return `(${count > 99 ? "99+" : count}) ${bare}`;
219}
220
221/** What the tab and the app icon count: chat unread (mentions when there are none) and the inbox. */
222export function attentionCount(badges: Partial<LiveBadges> | null): number {
223 if (!badges) return 0;
224 return Math.max(badges.chat ?? 0, badges.mentions ?? 0) + (badges.inbox ?? 0);
225}
226
227// ── Browser notifications ─────────────────────────────────────────────────
228
229/** What the person said to the offer, kept in the browser. */
230export type PushChoice = "declined" | "off" | "on" | null;
231
232/**
233 * Whether to offer browser notifications: after a DM or mention toast,
234 * only where the browser can, only while it has not been asked, and never
235 * again once the person said no or closed the offer.
236 */
237export function offerPush(input: {
238 kind: FeedNotification["kind"];
239 supported: boolean;
240 permission: "default" | "granted" | "denied";
241 choice: PushChoice;
242 hasKey: boolean;
243 desktop: boolean;
244}): boolean {
245 if (input.desktop || !input.supported || !input.hasKey) return false;
246 if (input.kind !== "dm" && input.kind !== "mention") return false;
247 if (input.permission !== "default") return false;
248 return input.choice === null;
249}
250
251/** Full jitter: a wait anywhere up to the doubled step, from a second up to half a minute. */
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 tab252/**
253 * How long a connection must stay open before its backoff starts over. A
254 * socket the server accepts and drops at once (a Worker being replaced, a
255 * room that can't start) would otherwise reconnect every second from every
256 * open tab, and a busy network runs into the rate limit.
257 */
258export const STABLE_MS = 10_000;
259
260/** Whether a connection that opened at `openedAt` held long enough to start the backoff over. */
261export function heldOpen(openedAt: number | null, now: number = Date.now()): boolean {
262 return openedAt !== null && now - openedAt >= STABLE_MS;
263}
264
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)265export function reconnectDelay(attempt: number, random: () => number = Math.random): number {
266 const ceiling = Math.min(30_000, 1_000 * 2 ** Math.max(0, Math.min(attempt, 10)));
267 return Math.round(500 + random() * (ceiling - 500));
268}

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