@alia.onl/sdk 3.0.0 → 3.1.1

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