Skip to content
289 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.

Chat is the phone app's first part, in full and live, with notifications on the phone. Conversations draw messages as the site does, from the same Markdown parser, run one author's messages together and mark the days; new messages, edits, deletions, reactions and typing arrive over the conversation's socket, and what you send shows at once and can be sent again if it fails. Agents' cards work: their buttons act from the phone, asking first or for an amount when the card does. Long-press a message to react, reply in a thread, copy it, or edit or delete your own; threads open with the message they are under; a conversation can be starred, muted or left; New message messages people and agents or makes a channel, and Browse joins channels. While the app is in front it keeps the person's feed open as a tab does: unread counts move on the list and the Chat tab, notifications show as a banner unless that conversation is open, and DMs show who is online. The bar is the site's phone bar: Home, Notifications and Agents say they are coming and open the site, and Customize and the account move under More.1import type { CardAction, ChatMessage, MessageCard } from "@g1t/contracts";
2import * as Haptics from "expo-haptics";
3import { router } from "expo-router";
4import * as WebBrowser from "expo-web-browser";
5import { AlertCircle, Clock, MessageSquareReply } from "lucide-react-native";
6import { memo, useState } from "react";
7import { ActivityIndicator, Alert, Pressable, StyleSheet, View } from "react-native";
8
9import { Markdown } from "@/components/chat/markdown";
10import { PromptSheet } from "@/components/chat/sheet";
11import { Avatar, Badge, Text } from "@/components/ui";
12import type { Shown } from "@/lib/chat";
13import { routeFor } from "@/lib/links";
14import { siteUrl } from "@/lib/server";
15import { useSignedIn } from "@/lib/session";
16import { clock } from "@/lib/time";
17import { radius, space, useColors } from "@/theme";
18
19type Props = {
20 message: Shown;
21 /** The first of a run by one author: draws the avatar and name. */
22 head: boolean;
23 /** In a thread: no "replies" line under it. */
24 inThread?: boolean;
25 onLongPress: (message: Shown) => void;
26 onReact: (message: ChatMessage, emoji: string, add: boolean) => void;
27 onRetry: (message: Shown) => void;
28 onCardAction: (message: ChatMessage, action: CardAction, input?: string | null) => Promise<string | null>;
29};
30
31/**
32 * One message in a conversation. Messages by the same author within a few
33 * minutes run together under one name, as on the site; an agent carries
34 * its marker and an Agent badge, and its cards' buttons work here.
35 */
36export const Message = memo(function Message({ message, head, inThread, onLongPress, onReact, onRetry, onCardAction }: Props) {
37 const colors = useColors();
38 const { server } = useSignedIn();
39 const { author } = message;
40 const agent = author.kind === "agent";
41 const failed = message.local === "failed";
42
43 const openThread = () =>
44 router.push({ pathname: "/chat/thread", params: { channel: message.channel_id, root: message.id } });
45
46 return (
47 <Pressable
48 onLongPress={() => {
49 if (message.local) return;
50 void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => undefined);
51 onLongPress(message);
52 }}
53 onPress={failed ? () => onRetry(message) : undefined}
54 delayLongPress={300}
55 style={({ pressed }) => [styles.row, { paddingTop: head ? space.md : 2, backgroundColor: pressed ? colors.raised : "transparent" }]}
56 >
57 <View style={styles.gutter}>
58 {head ? (
59 <Avatar name={author.display_name} uri={author.avatar ? `${server.site}/avatars/${author.avatar}` : null} agent={agent} size={36} />
60 ) : null}
61 </View>
62 <View style={{ flex: 1, gap: 4, opacity: message.local === "sending" ? 0.6 : 1 }}>
63 {head ? (
64 <View style={styles.header}>
65 <Text style={{ fontWeight: "700" }} numberOfLines={1}>
66 {author.display_name}
67 </Text>
68 {agent ? <Badge label={author.title ?? "Agent"} tone="accent" /> : null}
69 <Text size="small" tone="faint">
70 {clock(message.created_at)}
71 </Text>
72 </View>
73 ) : null}
74 {message.body ? <Markdown text={message.body} /> : null}
75 {message.edited_at ? (
76 <Text size="small" tone="faint">
77 (edited)
78 </Text>
79 ) : null}
80 {message.kind === "card" && message.card ? <Card card={message.card} message={message} onAction={onCardAction} site={server.site} /> : null}
81 {message.reactions && message.reactions.length > 0 ? (
82 <View style={styles.reactions}>
83 {message.reactions.map((reaction) => (
84 <Pressable
85 key={reaction.emoji}
86 accessibilityLabel={`${reaction.emoji} ${reaction.count}${reaction.me ? ", yours" : ""}`}
87 onPress={() => {
88 void Haptics.selectionAsync().catch(() => undefined);
89 onReact(message, reaction.emoji, !reaction.me);
90 }}
91 style={[
92 styles.reaction,
93 { borderColor: reaction.me ? colors.accent : colors.line, backgroundColor: reaction.me ? colors.accentLow : colors.surface },
94 ]}
95 >
96 <Text size="small">{reaction.emoji}</Text>
97 <Text size="small" style={{ color: reaction.me ? colors.accent : colors.muted, fontWeight: "600" }}>
98 {reaction.count}
99 </Text>
100 </Pressable>
101 ))}
102 </View>
103 ) : null}
104 {!inThread && message.reply_count > 0 ? (
105 <Pressable onPress={openThread} style={styles.replies} hitSlop={8}>
106 <MessageSquareReply size={14} color={colors.accent} />
107 <Text size="small" tone="accent" style={{ fontWeight: "600" }}>
108 {message.reply_count === 1 ? "1 reply" : `${message.reply_count} replies`}
109 </Text>
110 </Pressable>
111 ) : null}
112 {message.local === "sending" ? (
113 <View style={styles.status}>
114 <Clock size={12} color={colors.faint} />
115 <Text size="small" tone="faint">
116 Sending
117 </Text>
118 </View>
119 ) : failed ? (
120 <View style={styles.status}>
121 <AlertCircle size={12} color={colors.danger} />
122 <Text size="small" tone="danger">
123 Not sent. Tap to try again.
124 </Text>
125 </View>
126 ) : null}
127 </View>
128 </Pressable>
129 );
130});
131
132/**
133 * An agent's card: what it is about, its state, and the buttons that act
134 * on it (approve more budget, stop a session, file a draft issue). A button
135 * may ask first, or ask for a value; its owner answers, and the card
136 * updates in place through the live socket.
137 */
138function Card({
139 card,
140 message,
141 onAction,
142 site,
143}: {
144 card: MessageCard;
145 message: ChatMessage;
146 onAction: Props["onCardAction"];
147 site: string;
148}) {
149 const colors = useColors();
150 const [busy, setBusy] = useState<string | null>(null);
151 const [asking, setAsking] = useState<CardAction | null>(null);
152 const [said, setSaid] = useState<string | null>(null);
153
154 const open = (href: string) => {
155 const route = href.startsWith("/") ? routeFor(href) : null;
156 if (route) router.push(route as never);
157 else void WebBrowser.openBrowserAsync(href.startsWith("/") ? siteUrl({ site, api: "" }, href) : href);
158 };
159
160 const run = async (action: CardAction, input?: string | null) => {
161 setBusy(action.id);
162 setSaid(null);
163 try {
164 const answer = await onAction(message, action, input);
165 if (answer) setSaid(answer);
166 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => undefined);
167 } catch (error) {
168 setSaid(error instanceof Error ? error.message : "That didn't work.");
169 void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => undefined);
170 } finally {
171 setBusy(null);
172 }
173 };
174
175 const press = (action: CardAction) => {
176 if (action.href) return open(action.href);
177 if (action.input) return setAsking(action);
178 if (action.confirm) {
179 return Alert.alert(action.label, action.confirm, [
180 { text: "Cancel", style: "cancel" },
181 { text: action.label, style: action.style === "danger" ? "destructive" : "default", onPress: () => void run(action) },
182 ]);
183 }
184 void run(action);
185 };
186
187 return (
188 <View style={[styles.card, { borderColor: colors.lineStrong, backgroundColor: colors.surface }]}>
189 <Pressable disabled={!card.href} onPress={() => card.href && open(card.href)} style={{ gap: 4 }}>
190 <View style={{ flexDirection: "row", gap: space.sm, alignItems: "flex-start" }}>
191 <Text style={{ flex: 1, fontWeight: "700" }}>{card.title}</Text>
192 {card.state ? <Badge label={card.state} tone={/fail|stop|denied|error/i.test(card.state) ? "danger" : /merged|done|approved|passed/i.test(card.state) ? "success" : "accent"} /> : null}
193 </View>
194 {card.detail ? (
195 <Text size="small" tone="muted">
196 {card.detail}
197 </Text>
198 ) : null}
199 {card.body ? <Markdown text={card.body} muted /> : null}
200 {card.fields?.length ? (
201 <View style={styles.fields}>
202 {card.fields.map((field) => (
203 <View key={field.label} style={{ width: "48%" }}>
204 <Text size="small" tone="faint">
205 {field.label}
206 </Text>
207 <Text size="small" numberOfLines={2}>
208 {field.value}
209 </Text>
210 </View>
211 ))}
212 </View>
213 ) : null}
214 </Pressable>
215 {card.actions?.length ? (
216 <View style={styles.actions}>
217 {card.actions.map((action) => {
218 const primary = action.style === "primary";
219 const danger = action.style === "danger";
220 return (
221 <Pressable
222 key={action.id}
223 disabled={!!busy}
224 onPress={() => press(action)}
225 style={({ pressed }) => [
226 styles.action,
227 {
228 backgroundColor: primary ? colors.accent : colors.bg,
229 borderColor: primary ? colors.accent : danger ? colors.danger : colors.lineStrong,
230 opacity: pressed || (busy && busy !== action.id) ? 0.6 : 1,
231 },
232 ]}
233 >
234 {busy === action.id ? (
235 <ActivityIndicator size="small" color={primary ? colors.onAccent : colors.accent} />
236 ) : (
237 <Text size="small" style={{ fontWeight: "700", color: primary ? colors.onAccent : danger ? colors.danger : colors.fg }}>
238 {action.label}
239 </Text>
240 )}
241 </Pressable>
242 );
243 })}
244 </View>
245 ) : null}
246 {said ? (
247 <Text size="small" tone="muted">
248 {said}
249 </Text>
250 ) : null}
251 <PromptSheet
252 visible={!!asking}
253 title={asking?.label ?? ""}
254 label={asking?.input?.label ?? ""}
255 placeholder={asking?.input?.placeholder ?? undefined}
256 initial={asking?.input?.initial ?? ""}
257 numeric={asking?.input?.kind === "money"}
258 confirm={asking?.label ?? "Done"}
259 onCancel={() => setAsking(null)}
260 onDone={(value) => {
261 const action = asking;
262 setAsking(null);
263 if (action) void run(action, value);
264 }}
265 />
266 </View>
267 );
268}
269
270/** Whether two messages run together: the same author, within five minutes, neither a card. */
271export function runsOn(previous: ChatMessage | undefined, message: ChatMessage): boolean {
272 if (!previous || previous.kind === "card" || message.kind === "card") return false;
273 if (previous.author.id !== message.author.id || previous.author.kind !== message.author.kind) return false;
274 return Date.parse(message.created_at) - Date.parse(previous.created_at) < 5 * 60_000;
275}
276
277const styles = StyleSheet.create({
278 row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingBottom: 2 },
279 gutter: { width: 36 },
280 header: { flexDirection: "row", alignItems: "center", gap: space.sm },
281 reactions: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 },
282 reaction: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: radius.full, paddingHorizontal: 8, paddingVertical: 3 },
283 replies: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 2 },
284 status: { flexDirection: "row", alignItems: "center", gap: 4 },
285 card: { borderWidth: 1, borderRadius: radius.md, padding: space.md, gap: space.sm, marginTop: 2 },
286 fields: { flexDirection: "row", flexWrap: "wrap", gap: space.sm, justifyContent: "space-between" },
287 actions: { flexDirection: "row", flexWrap: "wrap", gap: space.sm },
288 action: { minHeight: 36, minWidth: 72, paddingHorizontal: space.md, borderRadius: radius.sm, borderWidth: 1, alignItems: "center", justifyContent: "center" },
289});