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. | 1 | import type { ChatMessage } from "@g1t/contracts"; |
| The phone app: Chat in full with phone notifications, plus Code and Customize (#4) | 2 | import { useQueryClient } from "@tanstack/react-query"; |
| 3 | import * as Clipboard from "expo-clipboard"; | |
| 4 | import { router, useFocusEffect } from "expo-router"; | |
| 5 | import { useHeaderHeight } from "expo-router/react-navigation"; | |
| 6 | import { ArrowDown, Copy, MessageSquareReply, Pencil, Trash2 } from "lucide-react-native"; | |
| 7 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 8 | import { ActivityIndicator, Alert, FlatList, KeyboardAvoidingView, Pressable, StyleSheet, View } from "react-native"; | |
| 9 | ||
| 10 | import { Composer } from "@/components/chat/composer"; | |
| 11 | import { Message, runsOn } from "@/components/chat/message"; | |
| 12 | import { PromptSheet, Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 13 | import { ErrorView, Loading, Text } from "@/components/ui"; | |
| 14 | import { type Shown, markRead, useChannelLive, useChatActions, useMessages, useSend } from "@/lib/chat"; | |
| 15 | import { useSignedIn } from "@/lib/session"; | |
| 16 | import { viewing } from "@/lib/viewing"; | |
| 17 | import { radius, space, useColors } from "@/theme"; | |
| 18 | ||
| 19 | const QUICK = ["👍", "❤️", "😂", "🎉", "👀", "✅"]; | |
| 20 | ||
| 21 | type 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 | */ | |
| 28 | export 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 | ||
| 264 | function 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 | ||
| 272 | function 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 | ||
| 279 | const 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.