Skip to content
289 linesCodeBlameRaw
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});