@alia.onl/sdk 2.0.0 → 3.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.
Files changed (37) hide show
  1. package/package.json +25 -3
  2. package/src/components/AliaChatContent.tsx +356 -0
  3. package/src/components/AliaChatInput.tsx +173 -26
  4. package/src/components/AliaChatMessageList.tsx +459 -168
  5. package/src/components/AliaChatScreen.tsx +140 -0
  6. package/src/components/AliaChatSheet.tsx +41 -44
  7. package/src/components/AliaWelcomeMessage.tsx +75 -0
  8. package/src/components/Markdown.tsx +58 -114
  9. package/src/components/PlanPreviewCard.tsx +76 -0
  10. package/src/components/ResearchProgressCard.tsx +159 -0
  11. package/src/components/ui/button.tsx +104 -0
  12. package/src/components/ui/chat-text-input.tsx +164 -0
  13. package/src/components/ui/dropdown-menu.tsx +2 -0
  14. package/src/components/ui/dropdown-menu.web.tsx +355 -0
  15. package/src/components/ui/prompt-input/actions.tsx +28 -0
  16. package/src/components/ui/prompt-input/add-menu.tsx +83 -0
  17. package/src/components/ui/prompt-input/attachments.tsx +183 -0
  18. package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
  19. package/src/components/ui/prompt-input/context.tsx +71 -0
  20. package/src/components/ui/prompt-input/mic-button.tsx +53 -0
  21. package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
  22. package/src/components/ui/prompt-input/submit-button.tsx +49 -0
  23. package/src/components/ui/prompt-input/textarea.tsx +64 -0
  24. package/src/components/ui/text.tsx +29 -0
  25. package/src/hooks/useAliaChat.ts +18 -8
  26. package/src/hooks/useDocumentPicker.ts +37 -0
  27. package/src/hooks/useImagePicker.ts +38 -0
  28. package/src/hooks/useSpeechToText.ts +25 -9
  29. package/src/index.ts +41 -3
  30. package/src/lib/content-utils.ts +23 -0
  31. package/src/lib/keyboard.native.tsx +6 -0
  32. package/src/lib/keyboard.tsx +25 -0
  33. package/src/lib/tool-registry.ts +55 -0
  34. package/src/lib/utils.ts +14 -0
  35. package/src/optional-deps.d.ts +17 -0
  36. package/src/types.ts +27 -10
  37. package/src/components/AliaChatSuggestions.tsx +0 -70
@@ -1,9 +1,5 @@
1
- import React, { useRef, useEffect } from 'react';
2
- import {
3
- View,
4
- Text,
5
- StyleSheet,
6
- } from 'react-native';
1
+ import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
+ import { View, Pressable, TextInput, StyleSheet, Platform } from 'react-native';
7
3
  import { BlurView } from 'expo-blur';
8
4
  import Animated, {
9
5
  useAnimatedScrollHandler,
@@ -13,88 +9,190 @@ import Animated, {
13
9
  withTiming,
14
10
  withSequence,
15
11
  cancelAnimation,
16
- Easing,
17
12
  FadeInUp,
18
13
  type SharedValue,
19
14
  } from 'react-native-reanimated';
15
+ import {
16
+ Copy,
17
+ Check,
18
+ Volume2,
19
+ Square,
20
+ ThumbsUp,
21
+ ThumbsDown,
22
+ Pencil,
23
+ } from 'lucide-react-native';
20
24
  import { useAliaColors } from '../theme';
25
+ import { Text } from './ui/text';
21
26
  import { AliaMarkdown } from './Markdown';
22
27
  import { ThinkingIndicator } from './ThinkingIndicator';
23
28
  import { Reasoning, ReasoningTrigger, ReasoningContent } from './Reasoning';
29
+ import { ResearchProgressCard } from './ResearchProgressCard';
30
+ import { PlanPreviewCard } from './PlanPreviewCard';
31
+ import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
32
+ import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
24
33
  import type { ChatMessage, ToolInvocation } from '../types';
25
34
 
26
- const ENTER_ANIMATION = FadeInUp.duration(200);
35
+ // Resolve expo-image once at module load (optional peer dep)
36
+ let ExpoImage: any = null;
37
+ try { ExpoImage = require('expo-image').Image; } catch {}
27
38
 
28
- interface AliaChatMessageListProps {
39
+ type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
40
+
41
+ export interface AliaChatMessageListProps {
29
42
  messages: ChatMessage[];
30
43
  isStreaming: boolean;
31
44
  scrollOffsetY: SharedValue<number>;
45
+ onReadAloud?: (messageId: string, text: string) => void;
46
+ ttsActiveMessageId?: string | null;
47
+ ttsPlaybackState?: PlaybackState;
48
+ // Injectable callbacks
49
+ onEditMessage?: (messageId: string, newContent: string) => void;
50
+ onThumbsUp?: (messageId: string) => void;
51
+ onThumbsDown?: (messageId: string) => void;
52
+ onApprovePlan?: (planId: string) => void;
53
+ onRejectPlan?: (planId: string) => void;
54
+ onToolResultPress?: (messageId: string) => void;
55
+ // Welcome
56
+ welcomeComponent?: React.ReactNode;
57
+ conversationLoading?: boolean;
58
+ // Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
59
+ renderMarkdown?: (content: string) => React.ReactNode;
32
60
  }
33
61
 
34
62
  // ── Tool Bullet ──
35
63
 
36
- function ToolBullet({ invocation }: { invocation: ToolInvocation }) {
37
- const colors = useAliaColors();
38
-
39
- // Clean up tool name for display: "oxy_inbox__searchEmails" → "Searching emails"
40
- const displayName = invocation.toolName
41
- .replace(/^oxy_\w+__/, '')
42
- .replace(/([A-Z])/g, ' $1')
43
- .replace(/^./, (s) => s.toUpperCase())
44
- .trim();
45
-
46
- const isActive = invocation.state === 'call' || invocation.state === 'partial-call';
47
- const label = isActive ? `${displayName}...` : displayName;
48
-
49
- return (
50
- <View style={styles.toolStatus}>
51
- {isActive ? (
52
- <PulsingDot color={colors.tint} />
53
- ) : (
54
- <Text style={[styles.toolCheckmark, { color: colors.tint }]}>{'\u2713'}</Text>
55
- )}
56
- <Text style={[styles.toolLabel, { color: colors.secondaryText }]}>{label}</Text>
57
- </View>
58
- );
59
- }
60
-
61
- function PulsingDot({ color }: { color: string }) {
64
+ function ToolBullet({ isRunning }: { isRunning: boolean }) {
62
65
  const opacity = useSharedValue(1);
63
-
64
66
  useEffect(() => {
65
- opacity.value = withRepeat(
66
- withSequence(
67
- withTiming(0.3, { duration: 600, easing: Easing.inOut(Easing.sin) }),
68
- withTiming(1, { duration: 600, easing: Easing.inOut(Easing.sin) }),
69
- ),
70
- -1,
71
- );
67
+ if (isRunning) {
68
+ opacity.value = withRepeat(
69
+ withSequence(
70
+ withTiming(0.3, { duration: 500 }),
71
+ withTiming(1, { duration: 500 }),
72
+ ),
73
+ -1,
74
+ );
75
+ } else {
76
+ opacity.value = 1;
77
+ }
72
78
  return () => cancelAnimation(opacity);
73
- }, []);
74
-
75
- const animStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
76
-
79
+ }, [isRunning]);
80
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
77
81
  return (
78
- <Animated.View style={[styles.toolDot, { backgroundColor: color }, animStyle]} />
82
+ <Animated.View style={style}>
83
+ <Text style={{ color: isRunning ? '#eab308' : '#22c55e', fontSize: 10 }}>
84
+ {'\u25CF'}
85
+ </Text>
86
+ </Animated.View>
79
87
  );
80
88
  }
81
89
 
82
90
  // ── User Bubble ──
83
91
 
84
- function UserBubble({ content }: { content: string }) {
85
- const colors = useAliaColors();
86
- const isDark = colors.isDark;
92
+ function UserBubble({
93
+ message,
94
+ text,
95
+ isEditing,
96
+ editedContent,
97
+ onEditedContentChange,
98
+ onSaveEdit,
99
+ onCancelEdit,
100
+ onStartEdit,
101
+ onCopy,
102
+ copiedMessageId,
103
+ showEditButton,
104
+ }: {
105
+ message: ChatMessage;
106
+ text: string;
107
+ isEditing: boolean;
108
+ editedContent: string;
109
+ onEditedContentChange: (text: string) => void;
110
+ onSaveEdit: () => void;
111
+ onCancelEdit: () => void;
112
+ onStartEdit: () => void;
113
+ onCopy: () => void;
114
+ copiedMessageId: string | null;
115
+ showEditButton: boolean;
116
+ }) {
117
+ const images = getImagesFromContent(message.content);
87
118
 
88
119
  return (
89
- <View style={styles.userMessage}>
90
- <View style={[styles.userBubble, { borderColor: colors.border }]}>
91
- <BlurView
92
- intensity={60}
93
- tint={isDark ? 'dark' : 'light'}
94
- style={StyleSheet.absoluteFill}
95
- />
96
- <Text style={[styles.userText, { color: colors.text }]}>{content}</Text>
97
- </View>
120
+ <View className="flex-col items-end gap-0.5">
121
+ {isEditing ? (
122
+ <View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
123
+ <BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
124
+ <View className="px-4 py-2">
125
+ <TextInput
126
+ value={editedContent}
127
+ onChangeText={onEditedContentChange}
128
+ multiline
129
+ className="text-base text-foreground leading-7"
130
+ autoFocus
131
+ />
132
+ <View className="flex-row gap-2 mt-2">
133
+ <Pressable
134
+ className="px-3 py-1.5 rounded-lg bg-primary"
135
+ onPress={onSaveEdit}
136
+ >
137
+ <Text className="text-xs text-primary-foreground">Save</Text>
138
+ </Pressable>
139
+ <Pressable
140
+ className="px-3 py-1.5 rounded-lg bg-muted-foreground"
141
+ onPress={onCancelEdit}
142
+ >
143
+ <Text className="text-xs text-background">Cancel</Text>
144
+ </Pressable>
145
+ </View>
146
+ </View>
147
+ </View>
148
+ ) : (
149
+ <View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
150
+ <BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
151
+ <View className="px-4 py-2">
152
+ {/* Inline images from multi-part content */}
153
+ {images.length > 0 && (
154
+ <View className="flex-row flex-wrap gap-2 mb-2">
155
+ {images.map((imgUrl, imgIdx) => {
156
+ if (!ExpoImage) return null;
157
+ return (
158
+ <View key={`img-${imgIdx}`} className="rounded-xl overflow-hidden" style={{ width: 120, height: 120 }}>
159
+ <ExpoImage
160
+ source={{ uri: imgUrl }}
161
+ className="w-full h-full"
162
+ contentFit="cover"
163
+ />
164
+ </View>
165
+ );
166
+ })}
167
+ </View>
168
+ )}
169
+ <Text className="text-base text-foreground leading-7">{text}</Text>
170
+ </View>
171
+ </View>
172
+ )}
173
+ {/* User message actions */}
174
+ {!isEditing && (
175
+ <View className="flex-row gap-1">
176
+ <Pressable
177
+ className="p-1.5 rounded-lg active:bg-muted"
178
+ onPress={onCopy}
179
+ >
180
+ {copiedMessageId === message.id ? (
181
+ <Check size={14} className="text-green-500" />
182
+ ) : (
183
+ <Copy size={14} className="text-muted-foreground" />
184
+ )}
185
+ </Pressable>
186
+ {showEditButton && (
187
+ <Pressable
188
+ className="p-1.5 rounded-lg active:bg-muted"
189
+ onPress={onStartEdit}
190
+ >
191
+ <Pencil size={14} className="text-muted-foreground" />
192
+ </Pressable>
193
+ )}
194
+ </View>
195
+ )}
98
196
  </View>
99
197
  );
100
198
  }
@@ -103,47 +201,203 @@ function UserBubble({ content }: { content: string }) {
103
201
 
104
202
  function AssistantMessage({
105
203
  message,
204
+ messageText,
106
205
  isStreamingThis,
206
+ isLastMessage,
207
+ isLoading,
208
+ onReadAloud,
209
+ ttsActiveMessageId,
210
+ ttsPlaybackState,
211
+ onCopy,
212
+ copiedMessageId,
213
+ onThumbsUp,
214
+ onThumbsDown,
215
+ onApprovePlan,
216
+ onRejectPlan,
217
+ onToolResultPress,
218
+ renderMarkdown,
107
219
  }: {
108
220
  message: ChatMessage;
221
+ messageText: string;
109
222
  isStreamingThis: boolean;
223
+ isLastMessage: boolean;
224
+ isLoading: boolean;
225
+ onReadAloud?: (messageId: string, text: string) => void;
226
+ ttsActiveMessageId?: string | null;
227
+ ttsPlaybackState?: PlaybackState;
228
+ onCopy: () => void;
229
+ copiedMessageId: string | null;
230
+ onThumbsUp?: (messageId: string) => void;
231
+ onThumbsDown?: (messageId: string) => void;
232
+ onApprovePlan?: (planId: string) => void;
233
+ onRejectPlan?: (planId: string) => void;
234
+ onToolResultPress?: (messageId: string) => void;
235
+ renderMarkdown?: (content: string) => React.ReactNode;
110
236
  }) {
111
- const colors = useAliaColors();
112
- const hasContent = !!message.content;
237
+ const hasContent = messageText.length > 0;
113
238
  const hasThinking = !!message.thinking;
114
- const showThinkingIndicator = isStreamingThis && !hasContent && !message.toolInvocations?.length && !hasThinking;
239
+
240
+ const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
241
+ const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
242
+ const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
243
+
244
+ // ThinkingIndicator — context-aware status
245
+ let thinkingIndicator: React.ReactNode = null;
246
+ if (isLoading && isLastMessage && !hasContent) {
247
+ const activeTool = message.toolInvocations?.find(
248
+ t => t.state === 'call' || t.state === 'partial-call',
249
+ );
250
+ const rp = message.researchProgress;
251
+ let activeStatus: string | undefined;
252
+ if (activeTool) {
253
+ activeStatus = getToolActiveLabel(activeTool.toolName);
254
+ } else if (rp?.phase && rp.phase !== 'complete') {
255
+ activeStatus = getResearchActiveLabel(rp.phase);
256
+ } else if (message.thinking) {
257
+ activeStatus = 'Reasoning...';
258
+ }
259
+ thinkingIndicator = (
260
+ <ThinkingIndicator
261
+ isWorking={(message.toolInvocations?.length ?? 0) > 0}
262
+ statusText={activeStatus}
263
+ />
264
+ );
265
+ }
115
266
 
116
267
  return (
117
- <Animated.View
118
- entering={ENTER_ANIMATION}
119
- style={styles.assistantMessage}
120
- >
121
- {/* Extended thinking / reasoning */}
122
- {hasThinking && (
123
- <Reasoning isStreaming={isStreamingThis && !hasContent}>
124
- <ReasoningTrigger />
125
- <ReasoningContent>{message.thinking!}</ReasoningContent>
126
- </Reasoning>
268
+ <View className="w-full">
269
+ {/* Plan Preview */}
270
+ {message.pendingPlan && onApprovePlan && onRejectPlan && (
271
+ <PlanPreviewCard
272
+ steps={message.pendingPlan.steps}
273
+ approved={message.pendingPlan.approved}
274
+ rejected={message.pendingPlan.rejected}
275
+ onApprove={() => onApprovePlan(message.pendingPlan!.planId)}
276
+ onReject={() => onRejectPlan(message.pendingPlan!.planId)}
277
+ />
127
278
  )}
128
279
 
129
- {/* Tool invocations */}
130
- {message.toolInvocations?.map((tool, i) => (
131
- <ToolBullet key={`${tool.toolCallId || tool.toolName}-${i}`} invocation={tool} />
132
- ))}
133
-
134
- {/* Thinking indicator (no content yet) */}
135
- {showThinkingIndicator && <ThinkingIndicator />}
280
+ {/* Tool Invocations */}
281
+ {message.toolInvocations?.map((t, ti) => {
282
+ const key = t.toolCallId || `tool-${message.id}-${ti}`;
283
+ const toolLabel = getToolLabel(t.toolName);
284
+ const isRunning = t.state === 'call' || t.state === 'partial-call';
285
+ const isDone = t.state === 'result';
286
+
287
+ let description = '';
288
+ if (t.args?.url) {
289
+ const url = String(t.args.url);
290
+ description = url.length > 40 ? url.substring(0, 40) + '...' : url;
291
+ } else if (t.args?.query) {
292
+ const q = String(t.args.query);
293
+ description = `"${q.length > 30 ? q.substring(0, 30) + '...' : q}"`;
294
+ }
295
+
296
+ return (
297
+ <Pressable
298
+ key={key}
299
+ className="flex-row items-center gap-2 py-1 active:opacity-70"
300
+ onPress={isDone && onToolResultPress ? () => onToolResultPress(message.id) : undefined}
301
+ disabled={!isDone || !onToolResultPress}
302
+ >
303
+ <ToolBullet isRunning={isRunning} />
304
+ <Text className="text-sm text-foreground flex-1 flex-shrink">
305
+ <Text className="font-bold">{toolLabel}</Text>
306
+ {description ? (
307
+ <Text className="text-muted-foreground"> {description}</Text>
308
+ ) : null}
309
+ </Text>
310
+ </Pressable>
311
+ );
312
+ })}
313
+
314
+ {/* Research Progress */}
315
+ {message.researchProgress && (
316
+ <ResearchProgressCard progress={message.researchProgress} />
317
+ )}
136
318
 
137
- {/* Text content — rendered as markdown */}
138
- {hasContent && (
139
- <AliaMarkdown content={message.content} />
319
+ {/* Extended Thinking */}
320
+ {hasThinking && (
321
+ <View className="mb-3 w-full">
322
+ <Reasoning isStreaming={isStreamingThis && !hasContent}>
323
+ <ReasoningTrigger />
324
+ <ReasoningContent>{message.thinking!}</ReasoningContent>
325
+ </Reasoning>
326
+ </View>
140
327
  )}
141
328
 
142
- {/* Streaming cursor */}
143
- {isStreamingThis && hasContent && (
144
- <Text style={[styles.cursor, { color: colors.secondaryText }]}>{'\u2758'}</Text>
329
+ {/* Message Content */}
330
+ {(hasContent || message.isStreaming) && (
331
+ <View className="flex-col items-start gap-0.5">
332
+ {/* Voice cohost label */}
333
+ {message.source === 'voice' && message.speaker === 'cohost' && (
334
+ <Text className="text-xs text-indigo-400 mb-0.5">Cohost</Text>
335
+ )}
336
+ {/* Agent identity */}
337
+ {message.agentInfo && (
338
+ <View className="flex-row items-center gap-2 mb-0.5">
339
+ <View className="w-5 h-5 rounded-full bg-orange-500/20 items-center justify-center">
340
+ <Text style={{ fontSize: 8, color: '#f97316' }}>{'\u25CF'}</Text>
341
+ </View>
342
+ <Text className="text-xs font-semibold" style={{ color: '#f97316' }}>
343
+ {message.agentInfo.name}
344
+ </Text>
345
+ </View>
346
+ )}
347
+ <View className="w-full">
348
+ {message.source === 'voice' ? (
349
+ <Text className="text-base text-foreground leading-7">
350
+ {messageText}
351
+ {message.isStreaming ? '\u258C' : ''}
352
+ </Text>
353
+ ) : renderMarkdown ? (
354
+ renderMarkdown(messageText)
355
+ ) : (
356
+ <AliaMarkdown content={messageText} />
357
+ )}
358
+ </View>
359
+ {/* Action buttons */}
360
+ {!isStreamingThis && hasContent && (
361
+ <View className="flex-row gap-1">
362
+ {onReadAloud && (
363
+ <Pressable
364
+ className="p-1.5 rounded-lg active:bg-muted"
365
+ onPress={() => onReadAloud(message.id, messageText)}
366
+ >
367
+ {isThisPlaying || isThisPaused ? (
368
+ <Square size={14} className={isThisPlaying ? 'text-primary' : 'text-muted-foreground'} />
369
+ ) : (
370
+ <Volume2 size={14} className={isThisLoading ? 'text-primary opacity-50' : 'text-muted-foreground'} />
371
+ )}
372
+ </Pressable>
373
+ )}
374
+ <Pressable
375
+ className="p-1.5 rounded-lg active:bg-muted"
376
+ onPress={onCopy}
377
+ >
378
+ {copiedMessageId === message.id ? (
379
+ <Check size={14} className="text-green-500" />
380
+ ) : (
381
+ <Copy size={14} className="text-muted-foreground" />
382
+ )}
383
+ </Pressable>
384
+ {onThumbsUp && (
385
+ <Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsUp(message.id)}>
386
+ <ThumbsUp size={14} className="text-muted-foreground" />
387
+ </Pressable>
388
+ )}
389
+ {onThumbsDown && (
390
+ <Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsDown(message.id)}>
391
+ <ThumbsDown size={14} className="text-muted-foreground" />
392
+ </Pressable>
393
+ )}
394
+ </View>
395
+ )}
396
+ </View>
145
397
  )}
146
- </Animated.View>
398
+
399
+ {thinkingIndicator}
400
+ </View>
147
401
  );
148
402
  }
149
403
 
@@ -153,10 +407,30 @@ export function AliaChatMessageList({
153
407
  messages,
154
408
  isStreaming,
155
409
  scrollOffsetY,
410
+ onReadAloud,
411
+ ttsActiveMessageId,
412
+ ttsPlaybackState,
413
+ onEditMessage,
414
+ onThumbsUp,
415
+ onThumbsDown,
416
+ onApprovePlan,
417
+ onRejectPlan,
418
+ onToolResultPress,
419
+ welcomeComponent,
420
+ conversationLoading,
421
+ renderMarkdown,
156
422
  }: AliaChatMessageListProps) {
157
423
  const scrollRef = useRef<Animated.ScrollView>(null);
424
+ const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
425
+ const [editedContent, setEditedContent] = useState('');
426
+ const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
427
+ const prevMessageCountRef = useRef(messages.length);
158
428
 
159
- // Auto-scroll to bottom on new messages
429
+ useEffect(() => {
430
+ prevMessageCountRef.current = messages.length;
431
+ }, [messages.length]);
432
+
433
+ // Auto-scroll
160
434
  useEffect(() => {
161
435
  if (messages.length > 0) {
162
436
  const timer = setTimeout(() => {
@@ -164,7 +438,7 @@ export function AliaChatMessageList({
164
438
  }, 100);
165
439
  return () => clearTimeout(timer);
166
440
  }
167
- }, [messages]);
441
+ }, [messages.length, isStreaming]);
168
442
 
169
443
  const scrollHandler = useAnimatedScrollHandler({
170
444
  onScroll: (event) => {
@@ -172,93 +446,110 @@ export function AliaChatMessageList({
172
446
  },
173
447
  });
174
448
 
449
+ const handleCopy = useCallback(async (content: string, messageId: string) => {
450
+ try {
451
+ if (Platform.OS === 'web') {
452
+ await navigator.clipboard.writeText(content);
453
+ } else {
454
+ const Clipboard = await import('expo-clipboard');
455
+ await Clipboard.setStringAsync(content);
456
+ }
457
+ setCopiedMessageId(messageId);
458
+ setTimeout(() => setCopiedMessageId(null), 2000);
459
+ } catch {}
460
+ }, []);
461
+
462
+ const handleStartEdit = useCallback((messageId: string, content: string) => {
463
+ setEditingMessageId(messageId);
464
+ setEditedContent(content);
465
+ }, []);
466
+
467
+ const editedContentRef = useRef(editedContent);
468
+ editedContentRef.current = editedContent;
469
+
470
+ const handleSaveEdit = useCallback((messageId: string) => {
471
+ if (onEditMessage && editedContentRef.current.trim()) {
472
+ onEditMessage(messageId, editedContentRef.current);
473
+ }
474
+ setEditingMessageId(null);
475
+ setEditedContent('');
476
+ }, [onEditMessage]);
477
+
478
+ const handleCancelEdit = useCallback(() => {
479
+ setEditingMessageId(null);
480
+ setEditedContent('');
481
+ }, []);
482
+
483
+ const filteredMessages = useMemo(() => messages.filter(m => m.role !== 'system'), [messages]);
484
+
175
485
  return (
176
486
  <Animated.ScrollView
177
487
  ref={scrollRef}
178
- style={styles.scrollView}
179
- contentContainerStyle={styles.content}
488
+ className="flex-1 bg-background px-4 py-4"
489
+ contentContainerStyle={{ flexGrow: 1, paddingTop: 4, paddingBottom: 16 }}
180
490
  showsVerticalScrollIndicator={false}
181
491
  keyboardShouldPersistTaps="handled"
182
492
  onScroll={scrollHandler}
183
493
  scrollEventThrottle={16}
184
494
  >
185
- {messages
186
- .filter((m) => m.role !== 'system')
187
- .map((msg, i, arr) => {
188
- if (msg.role === 'user') {
189
- return <UserBubble key={msg.id} content={msg.content} />;
190
- }
191
-
192
- const isStreamingThis = isStreaming && i === arr.length - 1;
193
-
194
- return (
195
- <AssistantMessage
196
- key={msg.id}
197
- message={msg}
198
- isStreamingThis={isStreamingThis}
199
- />
200
- );
201
- })}
495
+ <View className="max-w-3xl mx-auto w-full" style={!filteredMessages.length ? { flex: 1, justifyContent: 'center' } : undefined}>
496
+ {/* Welcome or loading */}
497
+ {!filteredMessages.length && (
498
+ welcomeComponent || null
499
+ )}
500
+
501
+ {/* Messages */}
502
+ <View className="gap-2">
503
+ {filteredMessages.map((msg, i) => {
504
+ const isNewMessage = i >= prevMessageCountRef.current;
505
+ const isLast = i === filteredMessages.length - 1;
506
+ const isStreamingThis = isStreaming && isLast;
507
+ const messageText = getTextFromContent(msg.content);
508
+
509
+ return (
510
+ <Animated.View
511
+ key={msg.id}
512
+ entering={isNewMessage ? FadeInUp.springify() : undefined}
513
+ >
514
+ {msg.role === 'user' ? (
515
+ <UserBubble
516
+ message={msg}
517
+ text={messageText}
518
+ isEditing={editingMessageId === msg.id}
519
+ editedContent={editedContent}
520
+ onEditedContentChange={setEditedContent}
521
+ onSaveEdit={() => handleSaveEdit(msg.id)}
522
+ onCancelEdit={handleCancelEdit}
523
+ onStartEdit={() => handleStartEdit(msg.id, messageText)}
524
+ onCopy={() => handleCopy(messageText, msg.id)}
525
+ copiedMessageId={copiedMessageId}
526
+ showEditButton={!!onEditMessage}
527
+ />
528
+ ) : (
529
+ <AssistantMessage
530
+ message={msg}
531
+ messageText={messageText}
532
+ isStreamingThis={isStreamingThis}
533
+ isLastMessage={isLast}
534
+ isLoading={isStreaming}
535
+ onReadAloud={onReadAloud}
536
+ ttsActiveMessageId={ttsActiveMessageId}
537
+ ttsPlaybackState={ttsPlaybackState}
538
+ onCopy={() => handleCopy(messageText, msg.id)}
539
+ copiedMessageId={copiedMessageId}
540
+ onThumbsUp={onThumbsUp}
541
+ onThumbsDown={onThumbsDown}
542
+ onApprovePlan={onApprovePlan}
543
+ onRejectPlan={onRejectPlan}
544
+ onToolResultPress={onToolResultPress}
545
+ renderMarkdown={renderMarkdown}
546
+ />
547
+ )}
548
+ </Animated.View>
549
+ );
550
+ })}
551
+ </View>
552
+ </View>
202
553
  </Animated.ScrollView>
203
554
  );
204
555
  }
205
-
206
- const styles = StyleSheet.create({
207
- scrollView: {
208
- flex: 1,
209
- },
210
- content: {
211
- padding: 16,
212
- paddingTop: 4,
213
- flexGrow: 1,
214
- },
215
-
216
- // User bubble — frosted glass
217
- userMessage: {
218
- alignItems: 'flex-end',
219
- marginBottom: 12,
220
- },
221
- userBubble: {
222
- maxWidth: '85%',
223
- paddingHorizontal: 16,
224
- paddingVertical: 10,
225
- borderRadius: 24,
226
- borderWidth: StyleSheet.hairlineWidth,
227
- overflow: 'hidden',
228
- },
229
- userText: {
230
- fontSize: 15,
231
- lineHeight: 22,
232
- },
233
-
234
- // Assistant message — no bubble
235
- assistantMessage: {
236
- alignItems: 'flex-start',
237
- marginBottom: 12,
238
- },
239
- cursor: {
240
- fontSize: 15,
241
- marginTop: -4,
242
- },
243
-
244
- // Tool status
245
- toolStatus: {
246
- flexDirection: 'row',
247
- alignItems: 'center',
248
- gap: 6,
249
- marginBottom: 6,
250
- paddingVertical: 2,
251
- },
252
- toolDot: {
253
- width: 8,
254
- height: 8,
255
- borderRadius: 4,
256
- },
257
- toolCheckmark: {
258
- fontSize: 14,
259
- fontWeight: '600',
260
- },
261
- toolLabel: {
262
- fontSize: 13,
263
- },
264
- });