@ateam-ai-mobile/ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,494 @@
1
+ import { useState, useCallback } from 'react';
2
+ import { View, Text, Pressable, StyleSheet, Image, Platform } from 'react-native';
3
+ import Markdown from 'react-native-markdown-display';
4
+ import type { Message, ToolCall } from '@ateam-ai-mobile/core';
5
+ import { TABLE_BLOCK_OPEN, TABLE_BLOCK_CLOSE, getConfig, ada, borderRadius, fontSize } from '@ateam-ai-mobile/core';
6
+ import { AdaOrb, getHaptics } from '@ateam-ai-mobile/plugin-runtime';
7
+ import JobProgressBar from './JobProgressBar';
8
+ import UIActionsRow from './UIActionsRow';
9
+ import TableCardBlock from './TableCardBlock';
10
+ import { useLightboxStore } from '../stores/lightboxStore';
11
+ import { getClipboard } from '../providers';
12
+
13
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
14
+
15
+ interface Props {
16
+ message: Message;
17
+ }
18
+
19
+ function formatTime(date: Date): string {
20
+ const h = date.getHours();
21
+ const m = date.getMinutes().toString().padStart(2, '0');
22
+ return `${h}:${m}`;
23
+ }
24
+
25
+ export function DaySeparator({ date }: { date: Date }) {
26
+ const today = new Date();
27
+ const yesterday = new Date(today);
28
+ yesterday.setDate(yesterday.getDate() - 1);
29
+
30
+ let label: string;
31
+ if (isSameDay(date, today)) {
32
+ label = 'Today';
33
+ } else if (isSameDay(date, yesterday)) {
34
+ label = 'Yesterday';
35
+ } else {
36
+ label = date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' });
37
+ }
38
+
39
+ return (
40
+ <View style={separatorStyles.container}>
41
+ <View style={separatorStyles.pill}>
42
+ <Text style={separatorStyles.text}>{label}</Text>
43
+ </View>
44
+ </View>
45
+ );
46
+ }
47
+
48
+ function isSameDay(a: Date, b: Date): boolean {
49
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
50
+ }
51
+
52
+ const separatorStyles = StyleSheet.create({
53
+ container: {
54
+ alignItems: 'center',
55
+ marginVertical: 14,
56
+ },
57
+ pill: {
58
+ backgroundColor: ada.paperSoft,
59
+ paddingHorizontal: 14,
60
+ paddingVertical: 5,
61
+ borderRadius: 999,
62
+ },
63
+ text: {
64
+ fontSize: 12,
65
+ fontWeight: '600',
66
+ color: ada.inkMuted,
67
+ letterSpacing: 0.4,
68
+ fontFamily: fontFam,
69
+ },
70
+ });
71
+
72
+ export default function MessageBubble({ message }: Props) {
73
+ const isUser = message.role === 'user';
74
+ const isSystem = message.role === 'system';
75
+ const isNotification = !isUser && message.kind === 'notification';
76
+ const [copied, setCopied] = useState('');
77
+
78
+ const showCopied = useCallback((label: string) => {
79
+ setCopied(label);
80
+ getHaptics()?.notification('success');
81
+ setTimeout(() => setCopied(''), 1500);
82
+ }, []);
83
+
84
+ const copyText = useCallback(async (text: string) => {
85
+ const clipboard = getClipboard();
86
+ if (!clipboard) return; // no provider — silently skip (no false "Copied!")
87
+ await clipboard.setString(text);
88
+ showCopied('Copied!');
89
+ }, [showCopied]);
90
+
91
+ const handleLongPress = useCallback(() => {
92
+ copyText(message.content);
93
+ }, [message.content, copyText]);
94
+
95
+ return (
96
+ <View style={[styles.row, isUser && styles.rowUser]}>
97
+ {!isUser && !isNotification && (
98
+ <View style={styles.avatarOrb}>
99
+ <AdaOrb
100
+ size={28}
101
+ state={message.jobProgress?.status === 'running' ? 'thinking' : 'idle'}
102
+ />
103
+ </View>
104
+ )}
105
+ {isNotification && <View style={styles.notificationLeftStripe} />}
106
+ <Pressable
107
+ accessibilityRole="text"
108
+ accessibilityLabel={`${isUser ? 'You' : isNotification ? 'Notification' : 'Ada'}: ${message.content}`}
109
+ accessibilityHint="Long-press to copy"
110
+ onLongPress={handleLongPress}
111
+ style={[
112
+ styles.bubble,
113
+ isUser && styles.bubbleUser,
114
+ isSystem && styles.bubbleSystem,
115
+ isNotification && styles.bubbleNotification,
116
+ ]}
117
+ >
118
+ {isNotification && (
119
+ <View style={styles.notificationHeader}>
120
+ <Text style={styles.notificationHeaderIcon}>🔔</Text>
121
+ <Text style={styles.notificationHeaderText}>Notification</Text>
122
+ </View>
123
+ )}
124
+ {/* Attachment thumbnails */}
125
+ {message.attachments && message.attachments.length > 0 && (
126
+ <View style={styles.attachments}>
127
+ {message.attachments.map((att, i) => (
128
+ att.previewUri ? (
129
+ <Pressable
130
+ key={i}
131
+ accessibilityRole="imagebutton"
132
+ accessibilityLabel={`Photo${att.name ? `: ${att.name}` : ''}. Tap to enlarge.`}
133
+ onPress={() => useLightboxStore.getState().show(att.previewUri!)}
134
+ >
135
+ <Image
136
+ source={{
137
+ uri: att.previewUri,
138
+ ...(att.previewUri.startsWith('http') ? {
139
+ headers: { Authorization: `Bearer ${getConfig().tokenProvider.getToken()}` },
140
+ } : {}),
141
+ }}
142
+ style={styles.attachmentImage}
143
+ resizeMode="cover"
144
+ onError={(e) => console.warn('[Image] Failed to load:', att.previewUri?.slice(0, 100), e.nativeEvent.error)}
145
+ />
146
+ </Pressable>
147
+ ) : (
148
+ <View key={i} style={styles.attachmentFile}>
149
+ <Text style={styles.attachmentFileName} numberOfLines={1}>{att.name}</Text>
150
+ </View>
151
+ )
152
+ ))}
153
+ </View>
154
+ )}
155
+ {isUser ? (
156
+ // Hide the "See attachment" placeholder when the bubble already shows the photo.
157
+ message.content && !(message.content === 'See attachment' && message.attachments?.length) ? (
158
+ <Text selectable style={styles.userText}>{message.content}</Text>
159
+ ) : null
160
+ ) : (
161
+ renderAssistantContent(message.content, copyText)
162
+ )}
163
+ {!!copied && (
164
+ <View style={styles.copiedBadge}>
165
+ <Text style={styles.copiedText}>{copied}</Text>
166
+ </View>
167
+ )}
168
+ {message.toolCalls && message.toolCalls.length > 0 && (
169
+ <ToolCallsBadge calls={message.toolCalls} />
170
+ )}
171
+ {message.jobProgress && (
172
+ <JobProgressBar progress={message.jobProgress} />
173
+ )}
174
+ {!isUser && message.actions && message.actions.length > 0 && (
175
+ <UIActionsRow actions={message.actions} />
176
+ )}
177
+ <Text style={[styles.timeText, isUser && styles.timeTextUser]}>
178
+ {formatTime(message.timestamp)}
179
+ </Text>
180
+ </Pressable>
181
+ {isUser && <View style={styles.avatarSpacer} />}
182
+ </View>
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Split assistant content on table-block sentinels and render each segment:
188
+ * markdown text → <Markdown>, table payload → <TableCardBlock>. Falls back
189
+ * to plain markdown when no sentinels are present (the common case).
190
+ */
191
+ function renderAssistantContent(content: string, copyText: (s: string) => void) {
192
+ if (!content) return null;
193
+ if (!content.includes(TABLE_BLOCK_OPEN)) {
194
+ return <MarkdownBubble content={content} copyText={copyText} />;
195
+ }
196
+
197
+ const segments: Array<{ kind: 'md' | 'table'; content: string }> = [];
198
+ let cursor = 0;
199
+ while (cursor < content.length) {
200
+ const open = content.indexOf(TABLE_BLOCK_OPEN, cursor);
201
+ if (open === -1) {
202
+ segments.push({ kind: 'md', content: content.slice(cursor) });
203
+ break;
204
+ }
205
+ if (open > cursor) {
206
+ segments.push({ kind: 'md', content: content.slice(cursor, open) });
207
+ }
208
+ const close = content.indexOf(TABLE_BLOCK_CLOSE, open + TABLE_BLOCK_OPEN.length);
209
+ if (close === -1) {
210
+ // Unterminated marker — render the rest as markdown so we don't lose data.
211
+ segments.push({ kind: 'md', content: content.slice(open) });
212
+ break;
213
+ }
214
+ const payload = content.slice(open + TABLE_BLOCK_OPEN.length, close);
215
+ segments.push({ kind: 'table', content: payload });
216
+ cursor = close + TABLE_BLOCK_CLOSE.length;
217
+ }
218
+
219
+ return (
220
+ <>
221
+ {segments.map((seg, i) => {
222
+ if (seg.kind === 'md') {
223
+ const trimmed = seg.content.trim();
224
+ if (!trimmed) return null;
225
+ return <MarkdownBubble key={i} content={seg.content} copyText={copyText} />;
226
+ }
227
+ try {
228
+ const data = JSON.parse(decodeURIComponent(seg.content));
229
+ return <TableCardBlock key={i} columns={data.columns} rows={data.rows} />;
230
+ } catch {
231
+ return null;
232
+ }
233
+ })}
234
+ </>
235
+ );
236
+ }
237
+
238
+ function MarkdownBubble({ content, copyText }: { content: string; copyText: (s: string) => void }) {
239
+ return (
240
+ <Markdown
241
+ style={markdownStyles}
242
+ rules={{
243
+ code_inline: (node) => (
244
+ <Text
245
+ key={node.key}
246
+ style={markdownStyles.code_inline}
247
+ onPress={() => copyText(node.content)}
248
+ >
249
+ {node.content}
250
+ </Text>
251
+ ),
252
+ fence: (node) => (
253
+ <Pressable
254
+ key={node.key}
255
+ onPress={() => copyText(node.content)}
256
+ style={markdownStyles.fence}
257
+ >
258
+ <Text style={markdownStyles.fenceText}>{node.content}</Text>
259
+ </Pressable>
260
+ ),
261
+ }}
262
+ >
263
+ {content}
264
+ </Markdown>
265
+ );
266
+ }
267
+
268
+ function ToolCallsBadge({ calls }: { calls: ToolCall[] }) {
269
+ const [expanded, setExpanded] = useState(false);
270
+ const label = calls
271
+ .filter(c => c.name === 'send_message_to_skill')
272
+ .map(c => (c.args as any).skill_name || c.skill)
273
+ .filter(Boolean)
274
+ .join(', ') || 'Tool call';
275
+
276
+ return (
277
+ <View style={styles.toolCalls}>
278
+ <Pressable style={styles.toolToggle} onPress={() => setExpanded(!expanded)}>
279
+ <Text style={styles.toolLabel}>{label}</Text>
280
+ <Text style={styles.toolChevron}>{expanded ? '▲' : '▼'}</Text>
281
+ </Pressable>
282
+ {expanded && calls.map((tc, i) => (
283
+ <View key={i} style={styles.toolItem}>
284
+ <Text style={styles.toolName}>{tc.name}</Text>
285
+ {tc.skill && <Text style={styles.toolSkill}>{tc.skill}</Text>}
286
+ </View>
287
+ ))}
288
+ </View>
289
+ );
290
+ }
291
+
292
+ const styles = StyleSheet.create({
293
+ row: {
294
+ flexDirection: 'row',
295
+ alignItems: 'flex-end',
296
+ marginBottom: 12,
297
+ paddingHorizontal: 12,
298
+ gap: 8,
299
+ },
300
+ rowUser: {
301
+ justifyContent: 'flex-end',
302
+ },
303
+ avatarOrb: {
304
+ width: 28,
305
+ height: 28,
306
+ justifyContent: 'center',
307
+ alignItems: 'center',
308
+ },
309
+ avatarSpacer: {
310
+ width: 28,
311
+ height: 28,
312
+ },
313
+ bubble: {
314
+ maxWidth: '78%',
315
+ backgroundColor: ada.botBubble,
316
+ borderWidth: 1,
317
+ borderColor: ada.botBubbleBorder,
318
+ borderRadius: borderRadius.xl,
319
+ paddingHorizontal: 16,
320
+ paddingVertical: 12,
321
+ },
322
+ bubbleUser: {
323
+ backgroundColor: ada.userBubble,
324
+ borderColor: 'transparent',
325
+ },
326
+ bubbleSystem: {
327
+ backgroundColor: ada.orangeSoft,
328
+ borderColor: ada.orange,
329
+ maxWidth: '90%',
330
+ },
331
+ bubbleNotification: {
332
+ backgroundColor: ada.orangeSoft,
333
+ borderColor: 'rgba(255, 122, 40, 0.35)',
334
+ borderRadius: borderRadius.lg,
335
+ maxWidth: '90%',
336
+ },
337
+ notificationLeftStripe: {
338
+ width: 3,
339
+ alignSelf: 'stretch',
340
+ backgroundColor: ada.orange,
341
+ borderRadius: 2,
342
+ marginRight: 4,
343
+ },
344
+ notificationHeader: {
345
+ flexDirection: 'row',
346
+ alignItems: 'center',
347
+ gap: 6,
348
+ marginBottom: 6,
349
+ },
350
+ notificationHeaderIcon: {
351
+ fontSize: 12,
352
+ },
353
+ notificationHeaderText: {
354
+ color: ada.orangeDeep,
355
+ fontSize: 11,
356
+ fontWeight: '700',
357
+ letterSpacing: 0.6,
358
+ textTransform: 'uppercase',
359
+ fontFamily: fontFam,
360
+ },
361
+ userText: {
362
+ color: ada.userBubbleText,
363
+ fontSize: fontSize.md,
364
+ lineHeight: 22,
365
+ fontFamily: fontFam,
366
+ },
367
+ timeText: {
368
+ fontSize: 11,
369
+ color: ada.inkMuted,
370
+ alignSelf: 'flex-end',
371
+ marginTop: 4,
372
+ fontFamily: fontFam,
373
+ },
374
+ timeTextUser: {
375
+ color: 'rgba(250,246,238,0.55)',
376
+ },
377
+ attachments: {
378
+ flexDirection: 'row',
379
+ flexWrap: 'wrap',
380
+ gap: 6,
381
+ marginBottom: 8,
382
+ },
383
+ attachmentImage: {
384
+ width: 120,
385
+ height: 90,
386
+ borderRadius: 8,
387
+ },
388
+ attachmentFile: {
389
+ backgroundColor: 'rgba(255,255,255,0.15)',
390
+ borderRadius: 8,
391
+ paddingHorizontal: 10,
392
+ paddingVertical: 6,
393
+ },
394
+ attachmentFileName: {
395
+ color: 'rgba(255,255,255,0.8)',
396
+ fontSize: 11,
397
+ maxWidth: 100,
398
+ },
399
+ copiedBadge: {
400
+ position: 'absolute',
401
+ top: 6,
402
+ right: 6,
403
+ backgroundColor: '#5A8A5C',
404
+ paddingHorizontal: 8,
405
+ paddingVertical: 2,
406
+ borderRadius: borderRadius.sm,
407
+ },
408
+ copiedText: {
409
+ color: '#fff',
410
+ fontSize: fontSize.xs,
411
+ fontWeight: '600',
412
+ },
413
+ toolCalls: {
414
+ marginTop: 8,
415
+ borderTopWidth: 1,
416
+ borderTopColor: ada.border,
417
+ paddingTop: 6,
418
+ },
419
+ toolToggle: {
420
+ flexDirection: 'row',
421
+ alignItems: 'center',
422
+ gap: 6,
423
+ },
424
+ toolLabel: {
425
+ color: ada.inkSoft,
426
+ fontSize: fontSize.xs,
427
+ fontWeight: '500',
428
+ },
429
+ toolChevron: {
430
+ color: ada.inkMuted,
431
+ fontSize: 8,
432
+ },
433
+ toolItem: {
434
+ marginTop: 4,
435
+ paddingLeft: 8,
436
+ },
437
+ toolName: {
438
+ color: ada.inkSoft,
439
+ fontSize: fontSize.xs,
440
+ fontFamily: 'monospace',
441
+ },
442
+ toolSkill: {
443
+ color: ada.orangeDeep,
444
+ fontSize: fontSize.xs,
445
+ },
446
+ });
447
+
448
+ const markdownStyles = StyleSheet.create({
449
+ body: {
450
+ color: ada.botBubbleText,
451
+ fontSize: fontSize.md,
452
+ lineHeight: 22,
453
+ fontFamily: fontFam,
454
+ },
455
+ paragraph: {
456
+ marginTop: 0,
457
+ marginBottom: 4,
458
+ },
459
+ strong: {
460
+ fontWeight: '700',
461
+ color: ada.botBubbleText,
462
+ },
463
+ em: {
464
+ fontStyle: 'italic',
465
+ },
466
+ code_inline: {
467
+ backgroundColor: ada.paperDeep,
468
+ color: ada.orangeDeep,
469
+ paddingHorizontal: 5,
470
+ paddingVertical: 1,
471
+ borderRadius: 4,
472
+ fontFamily: 'monospace',
473
+ fontSize: fontSize.sm,
474
+ },
475
+ fence: {
476
+ backgroundColor: ada.paperDeep,
477
+ padding: 10,
478
+ borderRadius: 10,
479
+ },
480
+ fenceText: {
481
+ fontFamily: 'monospace',
482
+ fontSize: fontSize.sm,
483
+ color: ada.ink,
484
+ },
485
+ link: {
486
+ color: ada.orangeDeep,
487
+ },
488
+ list_item: {
489
+ color: ada.botBubbleText,
490
+ },
491
+ bullet_list: {
492
+ marginVertical: 4,
493
+ },
494
+ });
@@ -0,0 +1,96 @@
1
+ import { View, Text, StyleSheet, Platform } from 'react-native';
2
+ import { ada, borderRadius, fontSize, spacing } from '@ateam-ai-mobile/core';
3
+
4
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
5
+
6
+ interface Props {
7
+ columns: string[];
8
+ rows: any[][];
9
+ }
10
+
11
+ /**
12
+ * Renders tabular data as a vertical stack of cards — one card per row.
13
+ * The first column becomes the card title; remaining columns become
14
+ * key:value rows inside the card. Built specifically for narrow phone
15
+ * screens where horizontal markdown tables become unreadable.
16
+ */
17
+ export default function TableCardBlock({ columns, rows }: Props) {
18
+ if (!columns?.length || !rows?.length) return null;
19
+
20
+ const [titleCol, ...detailCols] = columns;
21
+
22
+ return (
23
+ <View style={styles.wrap}>
24
+ {rows.map((row, i) => {
25
+ const cells = Array.isArray(row) ? row : [row];
26
+ const title = cells[0];
27
+ return (
28
+ <View key={i} style={styles.card}>
29
+ <Text style={styles.title}>{String(title ?? '')}</Text>
30
+ {detailCols.length > 0 && (
31
+ <View style={styles.details}>
32
+ {detailCols.map((col, ci) => {
33
+ const value = cells[ci + 1];
34
+ if (value == null || value === '') return null;
35
+ return (
36
+ <View key={ci} style={styles.row}>
37
+ <Text style={styles.label}>{String(col)}</Text>
38
+ <Text style={styles.value}>{String(value)}</Text>
39
+ </View>
40
+ );
41
+ })}
42
+ </View>
43
+ )}
44
+ </View>
45
+ );
46
+ })}
47
+ </View>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ wrap: {
53
+ marginVertical: 6,
54
+ gap: 8,
55
+ },
56
+ card: {
57
+ backgroundColor: ada.paper,
58
+ borderRadius: borderRadius.md,
59
+ borderWidth: 1,
60
+ borderColor: ada.border,
61
+ padding: spacing.md,
62
+ },
63
+ title: {
64
+ color: ada.ink,
65
+ fontSize: fontSize.md,
66
+ fontWeight: '600',
67
+ fontFamily: fontFam,
68
+ letterSpacing: -0.2,
69
+ marginBottom: 6,
70
+ },
71
+ details: {
72
+ gap: 4,
73
+ },
74
+ row: {
75
+ flexDirection: 'row',
76
+ alignItems: 'flex-start',
77
+ gap: 8,
78
+ },
79
+ label: {
80
+ color: ada.inkMuted,
81
+ fontSize: fontSize.xs,
82
+ fontFamily: fontFam,
83
+ fontWeight: '500',
84
+ width: 84,
85
+ textTransform: 'uppercase',
86
+ letterSpacing: 0.6,
87
+ paddingTop: 2,
88
+ },
89
+ value: {
90
+ flex: 1,
91
+ color: ada.inkSoft,
92
+ fontSize: fontSize.sm,
93
+ fontFamily: fontFam,
94
+ lineHeight: 18,
95
+ },
96
+ });
@@ -0,0 +1,20 @@
1
+ import { View, StyleSheet } from 'react-native';
2
+ import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
3
+
4
+ export default function TypingIndicator() {
5
+ return (
6
+ <View style={styles.row}>
7
+ <AdaOrb size={32} state="thinking" />
8
+ </View>
9
+ );
10
+ }
11
+
12
+ const styles = StyleSheet.create({
13
+ row: {
14
+ flexDirection: 'row',
15
+ alignItems: 'center',
16
+ marginBottom: 12,
17
+ paddingHorizontal: 16,
18
+ paddingVertical: 4,
19
+ },
20
+ });