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
367 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.

The phone app: Chat in full with phone notifications, plus Code and Customize (#4)1import type { Channel, ChatLiveEvent, ChatMessage, ChatSidebar, MemberProfile, MessagePage } from "@g1t/contracts";
2import { type InfiniteData, type QueryClient, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3import { useCallback, useEffect, useRef, useState } from "react";
4
5import type { Client } from "./client";
6import { openSocket } from "./live";
7import { useSignedIn } from "./session";
8
9/**
10 * Chat, through the site's routes, as the site's own Chat pages use them
11 * (apps/web/app/routes/workspace/chat/api.ts):
12 *
13 * - `GET /<workspace>/-/chat/api`: the sidebar.
14 * - `GET /<workspace>/-/chat/api?channel=<id>[&thread=<id>]&before=<id>`: a page of messages.
15 * - `POST /<workspace>/-/chat/api` with `{ intent, … }`: everything sent.
16 * - `wss://<site>/<workspace>/-/chat/live?channel=<id>`: what happens in a
17 * conversation, live, and the one thing a client says there: typing.
18 *
19 * The app signs in with `chat:read` and `chat:write`, which the site takes
20 * on these routes alone.
21 */
22
23const chatPath = (workspace: string) => `/${encodeURIComponent(workspace)}/-/chat/api`;
24
25export const chatKeys = {
26 all: (workspace: string) => ["chat", workspace] as const,
27 sidebar: (workspace: string) => ["chat", workspace, "sidebar"] as const,
28 browse: (workspace: string) => ["chat", workspace, "browse"] as const,
29 messages: (workspace: string, channel: string, thread: string | null) => ["chat", workspace, "messages", channel, thread] as const,
30};
31
32/** A message as the app keeps it: a sent one may still be on its way, or have failed. */
33export type Shown = ChatMessage & { local?: "sending" | "failed" };
34
35type Pages = InfiniteData<MessagePage & { messages: Shown[] }, string | null>;
36
37/** Posts an intent to the chat route and returns its value. */
38export function sendIntent<T>(client: Client, workspace: string, intent: string, body: Record<string, unknown>): Promise<T> {
39 return client.site<T>(chatPath(workspace), { method: "POST", body: JSON.stringify({ intent, ...body }) });
40}
41
42// ── Reading ────────────────────────────────────────────────────────────────
43
44export function useChatSidebar() {
45 const { client, workspace } = useSignedIn();
46 const slug = workspace?.slug ?? "";
47 return useQuery({
48 queryKey: chatKeys.sidebar(slug),
49 queryFn: () => client.site<ChatSidebar>(chatPath(slug)),
50 enabled: !!slug,
51 });
52}
53
54/** The workspace's channels, to find and join (`GET …/chat/api?browse=1`). */
55export function useBrowseChannels() {
56 const { client, workspace } = useSignedIn();
57 const slug = workspace?.slug ?? "";
58 return useQuery({
59 queryKey: chatKeys.browse(slug),
60 queryFn: () => client.site<Channel[]>(`${chatPath(slug)}?browse=1`),
61 enabled: !!slug,
62 });
63}
64
65export function useMessages(channelId: string, thread: string | null = null) {
66 const { client, workspace } = useSignedIn();
67 const slug = workspace?.slug ?? "";
68 return useInfiniteQuery({
69 queryKey: chatKeys.messages(slug, channelId, thread),
70 initialPageParam: null as string | null,
71 queryFn: ({ pageParam }) => {
72 const query = new URLSearchParams({ channel: channelId, limit: "50" });
73 if (thread) query.set("thread", thread);
74 if (pageParam) query.set("before", pageParam);
75 return client.site<MessagePage>(`${chatPath(slug)}?${query}`);
76 },
77 getNextPageParam: (page) => page.older,
78 enabled: !!slug && !!channelId,
79 });
80}
81
82// ── The cache, as messages arrive ───────────────────────────────────────────
83
84function updatePages(queries: QueryClient, key: readonly unknown[], change: (messages: Shown[], index: number) => Shown[]) {
85 queries.setQueryData<Pages>(key, (data) => (data ? { ...data, pages: data.pages.map((page, index) => ({ ...page, messages: change(page.messages, index) })) } : data));
86}
87
88/** A message, put where it belongs: replacing itself, or its local copy, or added as the newest. */
89export function upsertMessage(queries: QueryClient, workspace: string, message: Shown, localId?: string) {
90 const key = chatKeys.messages(workspace, message.channel_id, message.thread_root ?? null);
91 const data = queries.getQueryData<Pages>(key);
92 const had = !!data?.pages.some((page) => page.messages.some((one) => one.id === message.id));
93 const hadLocal = !!localId && !!data?.pages.some((page) => page.messages.some((one) => one.id === localId));
94 if (data) {
95 if (had || hadLocal) {
96 updatePages(queries, key, (messages) => {
97 const replaced = messages.map((one) => (one.id === message.id || (localId && one.id === localId) ? message : one));
98 // A local copy and the real one may both have arrived: keep one.
99 return replaced.filter((one, index) => replaced.findIndex((other) => other.id === one.id) === index);
100 });
101 } else {
102 updatePages(queries, key, (messages, index) => (index === 0 ? [...messages, message] : messages));
103 }
104 }
105 // A reply new to the app also counts on its root, in the conversation.
106 if (message.thread_root && !message.local && !had) {
107 updatePages(queries, chatKeys.messages(workspace, message.channel_id, null), (messages) =>
108 messages.map((one) => (one.id === message.thread_root ? { ...one, reply_count: one.reply_count + 1, last_reply_at: message.created_at } : one)),
109 );
110 }
111}
112
113function removeMessage(queries: QueryClient, workspace: string, channelId: string, id: string) {
114 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
115 updatePages(queries, key, (messages) => messages.filter((one) => one.id !== id));
116 }
117}
118
119function patchMessage(queries: QueryClient, workspace: string, channelId: string, id: string, change: (message: Shown) => Shown) {
120 for (const [key] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", channelId] })) {
121 updatePages(queries, key, (messages) => messages.map((one) => (one.id === id ? change(one) : one)));
122 }
123}
124
125/** A reaction added or taken away, by the viewer (`mine`) or someone else, on a message in the cache. */
126function applyReaction(queries: QueryClient, workspace: string, channelId: string, messageId: string, emoji: string, member: MemberProfile | null, added: boolean, mine: boolean) {
127 patchMessage(queries, workspace, channelId, messageId, (message) => {
128 const reactions = [...(message.reactions ?? [])];
129 const at = reactions.findIndex((one) => one.emoji === emoji);
130 if (at === -1) {
131 if (!added) return message;
132 reactions.push({ emoji, count: 1, me: mine, by: member ? [member] : [] });
133 } else {
134 const one = reactions[at];
135 if (mine && one.me === added) return message;
136 const count = one.count + (added ? 1 : -1);
137 if (count <= 0) reactions.splice(at, 1);
138 else
139 reactions[at] = {
140 ...one,
141 count,
142 me: mine ? added : one.me,
143 by: member ? (added ? [...one.by, member] : one.by.filter((m) => m.id !== member.id)) : one.by,
144 };
145 }
146 return { ...message, reactions };
147 });
148}
149
150/**
151 * Live events carry every reaction's `me` as false, since the whole room
152 * gets them: keep the viewer's own from the copy the app has.
153 */
154function keepMine(queries: QueryClient, workspace: string, message: ChatMessage): ChatMessage {
155 for (const [, data] of queries.getQueriesData<Pages>({ queryKey: ["chat", workspace, "messages", message.channel_id] })) {
156 const had = data?.pages.flatMap((page) => page.messages).find((one) => one.id === message.id);
157 if (had) {
158 const mine = new Set((had.reactions ?? []).filter((one) => one.me).map((one) => one.emoji));
159 return { ...message, reactions: (message.reactions ?? []).map((one) => ({ ...one, me: mine.has(one.emoji) })) };
160 }
161 }
162 return message;
163}
164
165// ── Sending ────────────────────────────────────────────────────────────────
166
167let localCounter = 0;
168
169/**
170 * Sends a message. It shows at once, marked as sending, and becomes the
171 * real one when the site answers (or when the live socket brings it,
172 * whichever is first); a failed one stays, marked, to send again.
173 */
174export function useSend(channelId: string, thread: string | null = null) {
175 const { client, workspace, me } = useSignedIn();
176 const queries = useQueryClient();
177 const slug = workspace?.slug ?? "";
178
179 return useCallback(
180 (body: string, retryOf?: Shown) => {
181 const localId = retryOf?.id ?? `local-${Date.now()}-${localCounter++}`;
182 const local: Shown = {
183 id: localId,
184 channel_id: channelId,
185 author: {
186 kind: "user",
187 id: me.id,
188 name: me.username,
189 display_username: me.display_username ?? null,
190 display_name: me.display_username ?? me.username,
191 avatar: me.avatar ?? null,
192 role: null,
193 } as MemberProfile,
194 kind: "text",
195 body,
196 card: null,
197 thread_root: thread,
198 reply_count: 0,
199 last_reply_at: null,
200 created_at: new Date().toISOString(),
201 edited_at: null,
202 deleted_at: null,
203 reactions: [],
204 local: "sending",
205 };
206 upsertMessage(queries, slug, local, localId);
207 sendIntent<ChatMessage>(client, slug, "post", { channel_id: channelId, body, thread_root: thread })
208 .then((message) => {
209 upsertMessage(queries, slug, message, localId);
210 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
211 })
212 .catch(() => upsertMessage(queries, slug, { ...local, local: "failed" }, localId));
213 },
214 [client, queries, slug, channelId, thread, me],
215 );
216}
217
218/** Everything else a person does to messages and conversations. */
219export function useChatActions(channelId: string) {
220 const { client, workspace } = useSignedIn();
221 const queries = useQueryClient();
222 const slug = workspace?.slug ?? "";
223
224 const react = useMutation({
225 mutationFn: ({ message, emoji, add }: { message: ChatMessage; emoji: string; add: boolean }) =>
226 sendIntent(client, slug, add ? "react" : "unreact", { channel_id: channelId, message_id: message.id, emoji }),
227 onMutate: ({ message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, add, true),
228 onError: (_error, { message, emoji, add }) => applyReaction(queries, slug, channelId, message.id, emoji, null, !add, true),
229 });
230
231 const edit = useMutation({
232 mutationFn: ({ message, body }: { message: ChatMessage; body: string }) => sendIntent<ChatMessage>(client, slug, "edit", { channel_id: channelId, id: message.id, body }),
233 onSuccess: (message) => upsertMessage(queries, slug, message),
234 });
235
236 const remove = useMutation({
237 mutationFn: (message: ChatMessage) => sendIntent(client, slug, "remove", { channel_id: channelId, id: message.id }),
238 onMutate: (message) => removeMessage(queries, slug, channelId, message.id),
239 onError: () => queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
240 });
241
242 const preferences = useMutation({
243 mutationFn: (change: { starred?: boolean; muted?: boolean }) => sendIntent(client, slug, "preferences", { channel_id: channelId, ...change }),
244 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
245 });
246
247 const leave = useMutation({
248 mutationFn: () => sendIntent(client, slug, "leave", { channel_id: channelId }),
249 onSettled: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
250 });
251
Merge branch 'mobile-agent-leftovers' into rm-agents252 return { react, edit, remove, preferences, leave };
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)253}
254
255/** Marks a conversation read up to a message; the sidebar's counts follow. */
256export function markRead(client: Client, queries: QueryClient, workspace: string, channelId: string, messageId: string) {
257 queries.setQueryData<ChatSidebar>(chatKeys.sidebar(workspace), (data) =>
258 data ? { ...data, entries: data.entries.map((entry) => (entry.channel.id === channelId ? { ...entry, unread: 0, mentions: 0 } : entry)) } : data,
259 );
260 return sendIntent(client, workspace, "read", { channel_id: channelId, id: messageId }).catch(() => undefined);
261}
262
263export function useStartConversation() {
264 const { client, workspace } = useSignedIn();
265 const queries = useQueryClient();
266 const slug = workspace?.slug ?? "";
267 return {
Merge branch 'mobile-agent-leftovers' into rm-agents268 /** Opens (or finds) a direct message with these people: `user:<username>`. */
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)269 dm: useMutation({
270 mutationFn: (members: string[]) => sendIntent<{ channel: Channel }>(client, slug, "dm", { members }),
271 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
272 }),
273 join: useMutation({
274 mutationFn: (channelId: string) => sendIntent(client, slug, "join", { channel_id: channelId }),
275 onSuccess: () => {
276 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
277 void queries.invalidateQueries({ queryKey: chatKeys.browse(slug) });
278 },
279 }),
280 create: useMutation({
281 mutationFn: (channel: { name: string; topic?: string; private?: boolean }) => sendIntent<{ channel: Channel }>(client, slug, "create_channel", channel),
282 onSuccess: () => queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) }),
283 }),
284 };
285}
286
287// ── Live ───────────────────────────────────────────────────────────────────
288
289export type LiveStatus = "connecting" | "live" | "offline";
290
291/** Who is typing, until when. */
292type Typing = { member: MemberProfile; until: number };
293
294/**
295 * Keeps a conversation live while its screen is open: applies what its
296 * socket says to the cache, says who is typing, and lets the composer say
297 * the viewer is (at most every 3 s, as the room takes it).
298 */
299export function useChannelLive(channelId: string) {
300 const { client, workspace, me } = useSignedIn();
301 const queries = useQueryClient();
302 const slug = workspace?.slug ?? "";
303 const [status, setStatus] = useState<LiveStatus>("connecting");
304 const [typing, setTyping] = useState<Typing[]>([]);
305 const send = useRef<((frame: unknown) => void) | null>(null);
306 const typedAt = useRef(0);
307
308 useEffect(() => {
309 if (!slug || !channelId) return;
310 const socket = openSocket(client, `/${encodeURIComponent(slug)}/-/chat/live`, { channel: channelId }, {
311 onStatus: setStatus,
312 onReconnect: () => void queries.invalidateQueries({ queryKey: ["chat", slug, "messages", channelId] }),
313 onMessage: (data) => {
314 const live = data as ChatLiveEvent;
315 switch (live.type) {
316 case "message.created":
317 case "message.updated":
318 upsertMessage(queries, slug, keepMine(queries, slug, live.message));
319 setTyping((now) => now.filter((one) => one.member.id !== live.message.author.id));
320 break;
321 case "message.deleted":
322 removeMessage(queries, slug, live.channel_id, live.id);
323 break;
g1t coordinates the work and the person's own sessions do it: Ask g1t reads g1t with the same tools any agent gets, answers in one conversation shared with the @g1t direct message in Chat, and hands work to sessions in the person's own harness, whose subagents g1t records, ties to issues and pull requests, and lets see each other.324 case "message.streaming":
325 // @g1t's answer as it is written: the text so far, in place.
326 updatePages(queries, chatKeys.messages(slug, live.channel_id, null), (messages) =>
327 messages.map((one) => (one.id === live.id ? { ...one, body: live.body, answer: live.answer } : one)),
328 );
329 break;
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)330 case "reaction.added":
331 case "reaction.removed":
332 if (live.member.id !== me.id) applyReaction(queries, slug, live.channel_id, live.message_id, live.emoji, live.member, live.type === "reaction.added", false);
333 break;
334 case "typing":
335 if (live.member.id === me.id) break;
336 setTyping((now) => [...now.filter((one) => one.member.id !== live.member.id), { member: live.member, until: Date.parse(live.until) }]);
337 break;
338 case "channel.updated":
339 void queries.invalidateQueries({ queryKey: chatKeys.sidebar(slug) });
340 break;
341 }
342 },
343 });
344 send.current = socket.send;
345 return () => {
346 send.current = null;
347 socket.close();
348 };
349 }, [client, queries, slug, channelId, me.id]);
350
351 // Typing lapses on its own.
352 useEffect(() => {
353 if (typing.length === 0) return;
354 const next = Math.min(...typing.map((one) => one.until)) - Date.now();
355 const timer = setTimeout(() => setTyping((now) => now.filter((one) => one.until > Date.now())), Math.max(250, next));
356 return () => clearTimeout(timer);
357 }, [typing]);
358
359 const sayTyping = useCallback(() => {
360 const now = Date.now();
361 if (now - typedAt.current < 3000) return;
362 typedAt.current = now;
363 send.current?.({ type: "typing", channel_id: channelId });
364 }, [channelId]);
365
366 return { status, typing: typing.map((one) => one.member), sayTyping };
367}

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