@alia.onl/sdk 3.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.
@@ -1,11 +1,5 @@
1
- import React, { useRef, useEffect, useState, useCallback } from 'react';
2
- import {
3
- View,
4
- Text,
5
- Pressable,
6
- StyleSheet,
7
- Platform,
8
- } from 'react-native';
1
+ import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
+ import { View, Pressable, TextInput, StyleSheet, Platform } from 'react-native';
9
3
  import { BlurView } from 'expo-blur';
10
4
  import Animated, {
11
5
  useAnimatedScrollHandler,
@@ -15,17 +9,32 @@ import Animated, {
15
9
  withTiming,
16
10
  withSequence,
17
11
  cancelAnimation,
18
- Easing,
19
12
  FadeInUp,
20
13
  type SharedValue,
21
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';
22
24
  import { useAliaColors } from '../theme';
25
+ import { Text } from './ui/text';
23
26
  import { AliaMarkdown } from './Markdown';
24
27
  import { ThinkingIndicator } from './ThinkingIndicator';
25
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';
26
33
  import type { ChatMessage, ToolInvocation } from '../types';
27
34
 
28
- 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 {}
29
38
 
30
39
  type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
31
40
 
@@ -36,223 +45,359 @@ export interface AliaChatMessageListProps {
36
45
  onReadAloud?: (messageId: string, text: string) => void;
37
46
  ttsActiveMessageId?: string | null;
38
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;
39
60
  }
40
61
 
41
62
  // ── Tool Bullet ──
42
63
 
43
- function ToolBullet({ invocation }: { invocation: ToolInvocation }) {
44
- const colors = useAliaColors();
45
-
46
- const displayName = invocation.toolName
47
- .replace(/^oxy_\w+__/, '')
48
- .replace(/([A-Z])/g, ' $1')
49
- .replace(/^./, (s) => s.toUpperCase())
50
- .trim();
51
-
52
- const isActive = invocation.state === 'call' || invocation.state === 'partial-call';
53
- const label = isActive ? `${displayName}...` : displayName;
54
-
55
- return (
56
- <View style={styles.toolStatus}>
57
- {isActive ? (
58
- <PulsingDot color={colors.tint} />
59
- ) : (
60
- <Text style={[styles.toolCheckmark, { color: colors.tint }]}>{'\u2713'}</Text>
61
- )}
62
- <Text style={[styles.toolLabel, { color: colors.secondaryText }]}>{label}</Text>
63
- </View>
64
- );
65
- }
66
-
67
- function PulsingDot({ color }: { color: string }) {
64
+ function ToolBullet({ isRunning }: { isRunning: boolean }) {
68
65
  const opacity = useSharedValue(1);
69
-
70
66
  useEffect(() => {
71
- opacity.value = withRepeat(
72
- withSequence(
73
- withTiming(0.3, { duration: 600, easing: Easing.inOut(Easing.sin) }),
74
- withTiming(1, { duration: 600, easing: Easing.inOut(Easing.sin) }),
75
- ),
76
- -1,
77
- );
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
+ }
78
78
  return () => cancelAnimation(opacity);
79
- }, []);
80
-
81
- const animStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
82
-
79
+ }, [isRunning]);
80
+ const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
83
81
  return (
84
- <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>
85
87
  );
86
88
  }
87
89
 
88
90
  // ── User Bubble ──
89
91
 
90
- function UserBubble({ content }: { content: string }) {
91
- const colors = useAliaColors();
92
- const isDark = colors.isDark;
93
-
94
- return (
95
- <View style={styles.userMessage}>
96
- <View style={[styles.userBubble, { borderColor: colors.border }]}>
97
- <BlurView
98
- intensity={60}
99
- tint={isDark ? 'dark' : 'light'}
100
- style={StyleSheet.absoluteFill}
101
- />
102
- <Text style={[styles.userText, { color: colors.text }]}>{content}</Text>
103
- </View>
104
- </View>
105
- );
106
- }
107
-
108
- // ── Message Actions ──
109
-
110
- function MessageActions({
92
+ function UserBubble({
111
93
  message,
112
- onReadAloud,
113
- ttsActiveMessageId,
114
- ttsPlaybackState,
94
+ text,
95
+ isEditing,
96
+ editedContent,
97
+ onEditedContentChange,
98
+ onSaveEdit,
99
+ onCancelEdit,
100
+ onStartEdit,
101
+ onCopy,
102
+ copiedMessageId,
103
+ showEditButton,
115
104
  }: {
116
105
  message: ChatMessage;
117
- onReadAloud?: (messageId: string, text: string) => void;
118
- ttsActiveMessageId?: string | null;
119
- ttsPlaybackState?: PlaybackState;
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;
120
116
  }) {
121
- const colors = useAliaColors();
122
- const [copied, setCopied] = useState(false);
123
-
124
- const handleCopy = useCallback(async () => {
125
- try {
126
- if (Platform.OS === 'web') {
127
- await navigator.clipboard.writeText(message.content);
128
- } else {
129
- const Clipboard = await import('expo-clipboard');
130
- await Clipboard.setStringAsync(message.content);
131
- }
132
- setCopied(true);
133
- setTimeout(() => setCopied(false), 2000);
134
- } catch {}
135
- }, [message.content]);
136
-
137
- const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
138
- const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
139
-
140
- if (!message.content) return null;
117
+ const images = getImagesFromContent(message.content);
141
118
 
142
119
  return (
143
- <View style={styles.actionsRow}>
144
- {/* Copy */}
145
- <Pressable onPress={handleCopy} style={styles.actionBtn} hitSlop={8}>
146
- {copied ? (
147
- <Text style={[styles.actionIcon, { color: colors.tint }]}>{'\u2713'}</Text>
148
- ) : (
149
- <CopyIcon color={colors.secondaryText} />
150
- )}
151
- </Pressable>
152
-
153
- {/* Read aloud */}
154
- {onReadAloud && (
155
- <Pressable
156
- onPress={() => onReadAloud(message.id, message.content)}
157
- style={styles.actionBtn}
158
- hitSlop={8}
159
- >
160
- {isThisLoading ? (
161
- <Text style={[styles.actionIcon, { color: colors.secondaryText }]}>{'\u2026'}</Text>
162
- ) : (
163
- <VolumeIcon color={isThisPlaying ? colors.tint : colors.secondaryText} />
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>
164
193
  )}
165
- </Pressable>
194
+ </View>
166
195
  )}
167
196
  </View>
168
197
  );
169
198
  }
170
199
 
171
- function CopyIcon({ color }: { color: string }) {
172
- if (Platform.OS === 'web') {
173
- return (
174
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
175
- <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
176
- <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
177
- </svg>
178
- );
179
- }
180
- return <Text style={[styles.actionIcon, { color }]}>{'\u2398'}</Text>;
181
- }
182
-
183
- function VolumeIcon({ color }: { color: string }) {
184
- if (Platform.OS === 'web') {
185
- return (
186
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
187
- <path d="M11 5L6 9H2v6h4l5 4V5z" />
188
- <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
189
- </svg>
190
- );
191
- }
192
- return <Text style={[styles.actionIcon, { color }]}>{'\u{1F50A}'}</Text>;
193
- }
194
-
195
200
  // ── Assistant Message ──
196
201
 
197
202
  function AssistantMessage({
198
203
  message,
204
+ messageText,
199
205
  isStreamingThis,
206
+ isLastMessage,
207
+ isLoading,
200
208
  onReadAloud,
201
209
  ttsActiveMessageId,
202
210
  ttsPlaybackState,
211
+ onCopy,
212
+ copiedMessageId,
213
+ onThumbsUp,
214
+ onThumbsDown,
215
+ onApprovePlan,
216
+ onRejectPlan,
217
+ onToolResultPress,
218
+ renderMarkdown,
203
219
  }: {
204
220
  message: ChatMessage;
221
+ messageText: string;
205
222
  isStreamingThis: boolean;
223
+ isLastMessage: boolean;
224
+ isLoading: boolean;
206
225
  onReadAloud?: (messageId: string, text: string) => void;
207
226
  ttsActiveMessageId?: string | null;
208
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;
209
236
  }) {
210
- const colors = useAliaColors();
211
- const hasContent = !!message.content;
237
+ const hasContent = messageText.length > 0;
212
238
  const hasThinking = !!message.thinking;
213
- const showThinkingIndicator = isStreamingThis && !hasContent && !message.toolInvocations?.length && !hasThinking;
214
239
 
215
- return (
216
- <Animated.View
217
- entering={ENTER_ANIMATION}
218
- style={styles.assistantMessage}
219
- >
220
- {/* Extended thinking / reasoning */}
221
- {hasThinking && (
222
- <Reasoning isStreaming={isStreamingThis && !hasContent}>
223
- <ReasoningTrigger />
224
- <ReasoningContent>{message.thinking!}</ReasoningContent>
225
- </Reasoning>
226
- )}
240
+ const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
241
+ const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
242
+ const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
227
243
 
228
- {/* Tool invocations */}
229
- {message.toolInvocations?.map((tool, i) => (
230
- <ToolBullet key={`${tool.toolCallId || tool.toolName}-${i}`} invocation={tool} />
231
- ))}
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
+ }
232
266
 
233
- {/* Thinking indicator (no content yet) */}
234
- {showThinkingIndicator && <ThinkingIndicator />}
267
+ return (
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
+ />
278
+ )}
235
279
 
236
- {/* Text content — rendered as markdown */}
237
- {hasContent && (
238
- <AliaMarkdown content={message.content} />
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} />
239
317
  )}
240
318
 
241
- {/* Streaming cursor */}
242
- {isStreamingThis && hasContent && (
243
- <Text style={[styles.cursor, { color: colors.secondaryText }]}>{'\u2758'}</Text>
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>
244
327
  )}
245
328
 
246
- {/* Message actions (copy, read aloud) — shown when not streaming */}
247
- {!isStreamingThis && hasContent && (
248
- <MessageActions
249
- message={message}
250
- onReadAloud={onReadAloud}
251
- ttsActiveMessageId={ttsActiveMessageId}
252
- ttsPlaybackState={ttsPlaybackState}
253
- />
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>
254
397
  )}
255
- </Animated.View>
398
+
399
+ {thinkingIndicator}
400
+ </View>
256
401
  );
257
402
  }
258
403
 
@@ -265,10 +410,27 @@ export function AliaChatMessageList({
265
410
  onReadAloud,
266
411
  ttsActiveMessageId,
267
412
  ttsPlaybackState,
413
+ onEditMessage,
414
+ onThumbsUp,
415
+ onThumbsDown,
416
+ onApprovePlan,
417
+ onRejectPlan,
418
+ onToolResultPress,
419
+ welcomeComponent,
420
+ conversationLoading,
421
+ renderMarkdown,
268
422
  }: AliaChatMessageListProps) {
269
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);
428
+
429
+ useEffect(() => {
430
+ prevMessageCountRef.current = messages.length;
431
+ }, [messages.length]);
270
432
 
271
- // Auto-scroll to bottom on new messages
433
+ // Auto-scroll
272
434
  useEffect(() => {
273
435
  if (messages.length > 0) {
274
436
  const timer = setTimeout(() => {
@@ -276,7 +438,7 @@ export function AliaChatMessageList({
276
438
  }, 100);
277
439
  return () => clearTimeout(timer);
278
440
  }
279
- }, [messages]);
441
+ }, [messages.length, isStreaming]);
280
442
 
281
443
  const scrollHandler = useAnimatedScrollHandler({
282
444
  onScroll: (event) => {
@@ -284,114 +446,110 @@ export function AliaChatMessageList({
284
446
  },
285
447
  });
286
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
+
287
485
  return (
288
486
  <Animated.ScrollView
289
487
  ref={scrollRef}
290
- style={styles.scrollView}
291
- contentContainerStyle={styles.content}
488
+ className="flex-1 bg-background px-4 py-4"
489
+ contentContainerStyle={{ flexGrow: 1, paddingTop: 4, paddingBottom: 16 }}
292
490
  showsVerticalScrollIndicator={false}
293
491
  keyboardShouldPersistTaps="handled"
294
492
  onScroll={scrollHandler}
295
493
  scrollEventThrottle={16}
296
494
  >
297
- {messages
298
- .filter((m) => m.role !== 'system')
299
- .map((msg, i, arr) => {
300
- if (msg.role === 'user') {
301
- return <UserBubble key={msg.id} content={msg.content} />;
302
- }
303
-
304
- const isStreamingThis = isStreaming && i === arr.length - 1;
305
-
306
- return (
307
- <AssistantMessage
308
- key={msg.id}
309
- message={msg}
310
- isStreamingThis={isStreamingThis}
311
- onReadAloud={onReadAloud}
312
- ttsActiveMessageId={ttsActiveMessageId}
313
- ttsPlaybackState={ttsPlaybackState}
314
- />
315
- );
316
- })}
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>
317
553
  </Animated.ScrollView>
318
554
  );
319
555
  }
320
-
321
- const styles = StyleSheet.create({
322
- scrollView: {
323
- flex: 1,
324
- },
325
- content: {
326
- padding: 16,
327
- paddingTop: 4,
328
- flexGrow: 1,
329
- },
330
-
331
- // User bubble — frosted glass
332
- userMessage: {
333
- alignItems: 'flex-end',
334
- marginBottom: 12,
335
- },
336
- userBubble: {
337
- maxWidth: '85%',
338
- paddingHorizontal: 16,
339
- paddingVertical: 10,
340
- borderRadius: 24,
341
- borderWidth: StyleSheet.hairlineWidth,
342
- overflow: 'hidden',
343
- },
344
- userText: {
345
- fontSize: 15,
346
- lineHeight: 22,
347
- },
348
-
349
- // Assistant message — no bubble
350
- assistantMessage: {
351
- alignItems: 'flex-start',
352
- marginBottom: 12,
353
- },
354
- cursor: {
355
- fontSize: 15,
356
- marginTop: -4,
357
- },
358
-
359
- // Tool status
360
- toolStatus: {
361
- flexDirection: 'row',
362
- alignItems: 'center',
363
- gap: 6,
364
- marginBottom: 6,
365
- paddingVertical: 2,
366
- },
367
- toolDot: {
368
- width: 8,
369
- height: 8,
370
- borderRadius: 4,
371
- },
372
- toolCheckmark: {
373
- fontSize: 14,
374
- fontWeight: '600',
375
- },
376
- toolLabel: {
377
- fontSize: 13,
378
- },
379
-
380
- // Message actions
381
- actionsRow: {
382
- flexDirection: 'row',
383
- alignItems: 'center',
384
- gap: 12,
385
- marginTop: 6,
386
- opacity: 0.7,
387
- },
388
- actionBtn: {
389
- width: 28,
390
- height: 28,
391
- alignItems: 'center',
392
- justifyContent: 'center',
393
- },
394
- actionIcon: {
395
- fontSize: 14,
396
- },
397
- });