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
286 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 no longer has g1t's own agents, to match the site. The Agents tab is gone from the bar, and Customize (skills, connectors and plugins) is gone with its row in More. New message finds and messages people only, and its search and heading say so. A card an agent posted earlier still shows with its title, state, details and links, but its other buttons are no longer drawn, since chat no longer answers them; an old agent's messages and direct messages still show, under the name chat gives them, marked Deleted agent. The app stops asking for agents:run when it signs in. An old g1t:// link to Agents, the skill library, Customize or the Marketplace opens Home, while the integrations directory opens on the site. Notifications, the sign-in screen and the access-token error no longer mention agents waiting on you or agent tokens, and the sign-in screen speaks of reviewing what you and your coding agents make. Pull requests still show the coding agent they were made with.1import type { ChatMessage } from "@g1t/contracts";
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)2import { useQueryClient } from "@tanstack/react-query";
3import * as Clipboard from "expo-clipboard";
4import { router, useFocusEffect } from "expo-router";
5import { useHeaderHeight } from "expo-router/react-navigation";
6import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native";
7import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native";
9
10import { Composer } from "@/components/chat/composer";
11import { Message, runsOn } from "@/components/chat/message";
12import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet";
13import { ErrorView, Loading, Text } from "@/components/ui";
14import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat";
15import { useSignedIn } from "@/lib/session";
16import { viewing } from "@/lib/viewing";
17import { radius, space, useColors } from "@/theme";
18
19const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"];
20
21type Row = { kind: "message"; message: Shown; head: boolean } | { kind: "day"; key: string; label: string };
22
23/**
24 * A conversation, or one thread of it: messages newest at the bottom, live
25 * while open, read as they arrive, and a box to write in. Long-press a
26 * message to react, reply in a thread, copy, or change or delete your own.
27 */
28export function Conversation({ channelId, thread, title }: { channelId: string; thread: string | null; title: string }) {
29 const colors = useColors();
30 const queries = useQueryClient();
31 const { client, workspace, me } = useSignedIn();
32 const slug = workspace?.slug ?? "";
33 const messages = useMessages(channelId, thread);
34 const live = useChannelLive(channelId);
35 const send = useSend(channelId, thread);
36 const actions = useChatActions(channelId);
37 const [picked, setPicked] = useState<Shown | null>(null);
38 const [editing, setEditing] = useState<Shown | null>(null);
39 const [scrolledUp, setScrolledUp] = useState(false);
40 const list = useRef<FlatList<Row>>(null);
41 const focused = useRef(false);
42 const headerHeight = useHeaderHeight();
43
44 // While on screen, this conversation's banners and pushes are not shown.
45 useFocusEffect(
46 useCallback(() => {
47 focused.current = true;
48 viewing.channelId = channelId;
49 viewing.threadRoot = thread;
50 return () => {
51 focused.current = false;
52 if (viewing.channelId === channelId) {
53 viewing.channelId = null;
54 viewing.threadRoot = null;
55 }
56 };
57 }, [channelId, thread]),
58 );
59
60 const all = useMemo(() => {
61 const pages = messages.data?.pages ?? [];
62 // Oldest first: the last page read is the oldest.
63 const ordered = [...pages].reverse().flatMap((page) => page.messages as Shown[]);
64 // A thread's first page carries the message it is under.
65 const root = thread ? (pages[0]?.root ?? null) : null;
66 const shown = ordered.filter((message) => !message.deleted_at && message.id !== root?.id);
67 return root ? [root as Shown, ...shown] : shown;
68 }, [messages.data, thread]);
69
70 // Rows newest first, for a list drawn from the bottom up, with the day between days.
71 const rows = useMemo(() => {
72 const out: Row[] = [];
73 let day = "";
74 all.forEach((message, index) => {
75 const date = new Date(message.created_at);
76 const label = dayLabel(date);
77 if (label !== day) {
78 out.push({ kind: "day", key: `day-${label}-${index}`, label });
79 day = label;
80 }
81 out.push({ kind: "message", message, head: label !== dayLabel(new Date(all[index - 1]?.created_at ?? 0)) || !runsOn(all[index - 1], message) });
82 });
83 return out.reverse();
84 }, [all]);
85
86 // Read up to the newest while it is on screen.
87 const newest = all.filter((message) => !message.local).at(-1)?.id;
88 useEffect(() => {
89 if (slug && newest && focused.current && !thread) void markRead(client, queries, slug, channelId, newest);
90 }, [client, queries, slug, channelId, newest, thread]);
91
92 const onReact = useCallback((message: ChatMessage, emoji: string, add: boolean) => actions.react.mutate({ message, emoji, add }), [actions.react]);
93 const onRetry = useCallback((message: Shown) => send(message.body, message), [send]);
94
95 const mine = picked?.author.kind === "user" && picked.author.id === me.id;
96 const typingLine = typingText(live.typing.map((member) => member.display_name));
97
98 return (
99 // The keyboard pushes the composer up and the list shrinks from the top, on
100 // Android too: apps draw edge to edge there, so the window no longer resizes
101 // for it. The list is drawn from the bottom, so the newest stays in view.
102 <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}>
103 <View style={{ flex: 1 }}>
104 {messages.isPending ? (
105 <Loading />
106 ) : messages.error ? (
107 <ErrorView error={messages.error} retry={() => messages.refetch()} />
108 ) : (
109 <FlatList
110 ref={list}
111 inverted
112 data={rows}
113 keyExtractor={(row) => (row.kind === "day" ? row.key : row.message.id)}
114 renderItem={({ item }) =>
115 item.kind === "day" ? (
116 <View style={styles.day}>
117 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
118 <Text size="small" tone="faint" style={{ fontWeight: "600" }}>
119 {item.label}
120 </Text>
121 <View style={[styles.dayLine, { backgroundColor: colors.line }]} />
122 </View>
123 ) : (
124 <Message
125 message={item.message}
126 head={item.head}
127 inThread={!!thread}
128 onLongPress={setPicked}
129 onReact={onReact}
130 onRetry={onRetry}
131 />
132 )
133 }
134 onEndReached={() => messages.hasNextPage && !messages.isFetchingNextPage && messages.fetchNextPage()}
135 onEndReachedThreshold={0.6}
136 onScroll={(event) => setScrolledUp(event.nativeEvent.contentOffset.y > 600)}
137 scrollEventThrottle={100}
138 ListHeaderComponent={<View style={{ height: space.sm }} />}
139 ListFooterComponent={
140 messages.isFetchingNextPage ? (
141 <ActivityIndicator style={{ margin: space.lg }} color={colors.accent} />
142 ) : !messages.hasNextPage ? (
143 <Text size="small" tone="faint" style={{ textAlign: "center", padding: space.xl }}>
144 {thread ? "The start of this thread." : `The start of ${title}.`}
145 </Text>
146 ) : null
147 }
148 keyboardDismissMode="interactive"
149 keyboardShouldPersistTaps="handled"
150 />
151 )}
152 {scrolledUp ? (
153 <Pressable
154 accessibilityLabel="Go to newest"
155 onPress={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
156 style={[styles.jump, { backgroundColor: colors.surface, borderColor: colors.lineStrong }]}
157 >
158 <ArrowDown size={18} color={colors.fg} />
159 </Pressable>
160 ) : null}
161 </View>
162 <View style={styles.typing}>
163 <Text size="small" tone="faint" numberOfLines={1}>
164 {typingLine}
165 </Text>
166 {live.status !== "live" ? (
167 <Text size="small" tone="faint">
168 {live.status === "connecting" ? "Connecting…" : "Offline, trying again"}
169 </Text>
170 ) : null}
171 </View>
172 <Composer
173 draftKey={`${channelId}:${thread ?? ""}`}
174 placeholder={thread ? "Reply in thread" : `Message ${title}`}
175 onSend={(body) => {
176 send(body);
177 list.current?.scrollToOffset({ offset: 0, animated: true });
178 }}
179 onTyping={live.sayTyping}
180 onFocus={() => list.current?.scrollToOffset({ offset: 0, animated: true })}
181 />
182
183 <Sheet visible={!!picked} onClose={() => setPicked(null)}>
184 <View style={styles.quick}>
185 {QUICK.map((emoji) => {
186 const had = picked?.reactions?.find((one) => one.emoji === emoji)?.me ?? false;
187 return (
188 <Pressable
189 key={emoji}
190 onPress={() => {
191 if (picked) onReact(picked, emoji, !had);
192 setPicked(null);
193 }}
194 style={[styles.quickOne, { backgroundColor: had ? colors.accentLow : colors.raised }]}
195 >
196 <Text style={{ fontSize: 24, lineHeight: 30 }}>{emoji}</Text>
197 </Pressable>
198 );
199 })}
200 </View>
201 {!thread ? (
202 <SheetAction
203 icon={MessageSquareReply}
204 label="Reply in thread"
205 onPress={() => {
206 const message = picked;
207 setPicked(null);
208 if (message) router.push({ pathname: "/chat/thread", params: { channel: channelId, root: message.id } });
209 }}
210 />
211 ) : null}
212 <SheetAction
213 icon={Copy}
214 label="Copy text"
215 onPress={() => {
216 if (picked?.body) void Clipboard.setStringAsync(picked.body);
217 setPicked(null);
218 }}
219 />
220 {mine && picked?.kind === "text" ? (
221 <SheetAction
222 icon={Pencil}
223 label="Edit"
224 onPress={() => {
225 setEditing(picked);
226 setPicked(null);
227 }}
228 />
229 ) : null}
230 {mine ? (
231 <SheetAction
232 icon={Trash2}
233 label="Delete"
234 danger
235 onPress={() => {
236 const message = picked;
237 setPicked(null);
238 if (!message) return;
239 Alert.alert("Delete this message?", "It is gone for everyone in the conversation.", [
240 { text: "Cancel", style: "cancel" },
241 { text: "Delete", style: "destructive", onPress: () => actions.remove.mutate(message) },
242 ]);
243 }}
244 />
245 ) : null}
246 </Sheet>
247
248 <PromptSheet
249 visible={!!editing}
250 title="Edit message"
251 initial={editing?.body ?? ""}
252 multiline
253 confirm="Save"
254 onCancel={() => setEditing(null)}
255 onDone={(body) => {
256 if (editing && body !== editing.body) actions.edit.mutate({ message: editing, body });
257 setEditing(null);
258 }}
259 />
260 </KeyboardAvoidingView>
261 );
262}
263
264function dayLabel(date: Date): string {
265 const today = new Date();
266 const yesterday = new Date(today.getTime() - 86_400_000);
267 if (date.toDateString() === today.toDateString()) return "Today";
268 if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
269 return date.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", ...(date.getFullYear() !== today.getFullYear() ? { year: "numeric" } : {}) });
270}
271
272function typingText(names: string[]): string {
273 if (names.length === 0) return "";
274 if (names.length === 1) return `${names[0]} is typing…`;
275 if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
276 return "Several people are typing…";
277}
278
279const styles = StyleSheet.create({
280 day: { flexDirection: "row", alignItems: "center", gap: space.sm, paddingHorizontal: space.lg, paddingVertical: space.md },
281 dayLine: { flex: 1, height: StyleSheet.hairlineWidth },
282 jump: { position: "absolute", right: space.lg, bottom: space.md, width: 40, height: 40, borderRadius: 20, borderWidth: 1, alignItems: "center", justifyContent: "center" },
283 typing: { flexDirection: "row", justifyContent: "space-between", paddingHorizontal: space.lg, minHeight: 18 },
284 quick: { flexDirection: "row", justifyContent: "space-around", paddingHorizontal: space.md, paddingBottom: space.sm },
285 quickOne: { width: 48, height: 48, borderRadius: radius.full, alignItems: "center", justifyContent: "center" },
286});

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