@alia.onl/sdk 10.1.0 → 11.0.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 (64) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +24 -0
  3. package/package.json +5 -7
  4. package/src/components/AliaAvatar.tsx +66 -0
  5. package/src/components/AliaChatContent.tsx +235 -112
  6. package/src/components/AliaChatHeader.tsx +118 -0
  7. package/src/components/AliaChatMessageList.tsx +171 -260
  8. package/src/components/AliaChatScreen.tsx +23 -24
  9. package/src/components/AliaChatSheet.tsx +70 -316
  10. package/src/components/AliaWelcomeMessage.tsx +47 -50
  11. package/src/components/AmbientField.tsx +577 -0
  12. package/src/components/IdentityMark.tsx +10 -2
  13. package/src/components/Markdown.tsx +116 -40
  14. package/src/components/PlanPreviewCard.tsx +71 -55
  15. package/src/components/ThinkingBlock.tsx +47 -0
  16. package/src/components/VoiceModeIcon.tsx +10 -0
  17. package/src/components/voice/VoiceControls.tsx +22 -73
  18. package/src/components/voice/VoiceSession.tsx +2 -1
  19. package/src/hooks/useAliaChat.ts +22 -15
  20. package/src/hooks/useAmbientWave.ts +35 -5
  21. package/src/hooks/useSoundEffects.ts +14 -11
  22. package/src/hooks/useSpeechToText.ts +9 -2
  23. package/src/hooks/useTTS.ts +163 -156
  24. package/src/hooks/useVoiceRoom.ts +228 -170
  25. package/src/index.ts +18 -6
  26. package/src/lib/catalogue.ts +17 -4
  27. package/src/lib/chat-stream.ts +25 -7
  28. package/src/lib/chat-transport.ts +2 -1
  29. package/src/lib/citations.ts +63 -0
  30. package/src/lib/content-utils.ts +3 -2
  31. package/src/lib/image-data-url.ts +15 -0
  32. package/src/lib/keyboard.tsx +1 -1
  33. package/src/lib/labels.tsx +81 -0
  34. package/src/lib/speech-recognition.native.ts +15 -4
  35. package/src/lib/speech-recognition.ts +27 -7
  36. package/src/lib/task-labels.ts +67 -0
  37. package/src/lib/tool-call-status.ts +28 -0
  38. package/src/lib/tool-registry.ts +14 -14
  39. package/src/lib/voice-text.ts +12 -6
  40. package/src/lib/voice-turn.ts +4 -1
  41. package/src/lib/work-log-copy.ts +61 -0
  42. package/src/lib/work-log.ts +378 -0
  43. package/src/tools.ts +11 -0
  44. package/src/types.ts +5 -2
  45. package/src/voice.ts +12 -4
  46. package/src/components/Reasoning.tsx +0 -152
  47. package/src/components/ResearchProgressCard.tsx +0 -159
  48. package/src/components/ThinkingIndicator.tsx +0 -98
  49. package/src/components/ui/button.tsx +0 -104
  50. package/src/components/ui/chat-text-input.tsx +0 -185
  51. package/src/components/ui/dropdown-menu.tsx +0 -2
  52. package/src/components/ui/dropdown-menu.web.tsx +0 -383
  53. package/src/components/ui/prompt-input/actions.tsx +0 -28
  54. package/src/components/ui/prompt-input/add-menu.tsx +0 -83
  55. package/src/components/ui/prompt-input/attachments.tsx +0 -181
  56. package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
  57. package/src/components/ui/prompt-input/context.tsx +0 -71
  58. package/src/components/ui/prompt-input/mic-button.tsx +0 -53
  59. package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
  60. package/src/components/ui/prompt-input/submit-button.tsx +0 -50
  61. package/src/components/ui/prompt-input/textarea.tsx +0 -80
  62. package/src/components/ui/text.tsx +0 -29
  63. package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
  64. package/src/components/voice/VoiceOverlay.tsx +0 -75
@@ -1,33 +1,42 @@
1
1
  import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
- import { View, Pressable, TextInput, StyleSheet, Platform } from 'react-native';
3
- import { BlurView } from 'expo-blur';
2
+ import { View, Pressable, Platform } from 'react-native';
4
3
  import Animated, {
5
- useAnimatedScrollHandler,
6
4
  useSharedValue,
7
5
  useAnimatedStyle,
8
6
  withRepeat,
9
7
  withTiming,
10
8
  withSequence,
11
9
  cancelAnimation,
12
- type SharedValue,
13
10
  } from 'react-native-reanimated';
14
- import Copy from 'lucide-react-native/icons/copy';
15
- import Check from 'lucide-react-native/icons/check';
16
- import Volume2 from 'lucide-react-native/icons/volume-2';
17
- import Square from 'lucide-react-native/icons/square';
18
- import ThumbsUp from 'lucide-react-native/icons/thumbs-up';
19
- import ThumbsDown from 'lucide-react-native/icons/thumbs-down';
20
- import Pencil from 'lucide-react-native/icons/pencil';
21
- import { Text } from './ui/text';
11
+ import { Textarea } from '@oxy.so/bloom/textarea';
12
+ import { TaskList } from '@oxy.so/bloom/task-list';
13
+ import { Text } from '@oxy.so/bloom/typography';
14
+ import { WebSearch } from '@oxy.so/bloom/web-search';
22
15
  import { AliaMarkdown } from './Markdown';
23
- import { ThinkingIndicator } from './ThinkingIndicator';
24
- import { Reasoning, ReasoningTrigger, ReasoningContent } from './Reasoning';
25
- import { ResearchProgressCard } from './ResearchProgressCard';
16
+ import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
17
+ import {
18
+ AiChatFeedbackRow,
19
+ AiChatMessageLine,
20
+ AiChatThread,
21
+ AiChatUserMessage,
22
+ } from '@oxy.so/bloom/ai-chat';
23
+ import { Avatar } from '@oxy.so/bloom/avatar';
24
+ import { Button } from '@oxy.so/bloom/button';
25
+ import { useLabels } from '../lib/labels';
26
+ import { isWebInvocation, taskListLog, webSearchLog } from '../lib/work-log';
27
+ import {
28
+ RiCheckLine,
29
+ RiFileCopyLine,
30
+ RiPencilLine,
31
+ RiStopFill,
32
+ RiVolumeUpLine,
33
+ } from '@oxy.so/bloom/icons';
34
+ import { ThinkingBlock } from './ThinkingBlock';
26
35
  import { PlanPreviewCard } from './PlanPreviewCard';
27
36
  import { chatMessageEnter } from '../animations';
28
37
  import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
29
38
  import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
30
- import type { ChatMessage, ToolInvocation } from '../types';
39
+ import type { ChatMessage } from '../types';
31
40
 
32
41
  // Resolve expo-image once at module load (optional peer dep)
33
42
  type ExpoImageProps = {
@@ -49,7 +58,6 @@ type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
49
58
  export interface AliaChatMessageListProps {
50
59
  messages: ChatMessage[];
51
60
  isStreaming: boolean;
52
- scrollOffsetY: SharedValue<number>;
53
61
  onReadAloud?: (messageId: string, text: string) => void;
54
62
  ttsActiveMessageId?: string | null;
55
63
  ttsPlaybackState?: PlaybackState;
@@ -59,42 +67,12 @@ export interface AliaChatMessageListProps {
59
67
  onThumbsDown?: (messageId: string) => void;
60
68
  onApprovePlan?: (planId: string) => void;
61
69
  onRejectPlan?: (planId: string) => void;
62
- onToolResultPress?: (messageId: string) => void;
63
70
  // Welcome
64
71
  welcomeComponent?: React.ReactNode;
65
- conversationLoading?: boolean;
66
72
  // Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
67
73
  renderMarkdown?: (content: string) => React.ReactNode;
68
74
  }
69
75
 
70
- // ── Tool Bullet ──
71
-
72
- function ToolBullet({ isRunning }: { isRunning: boolean }) {
73
- const opacity = useSharedValue(1);
74
- useEffect(() => {
75
- if (isRunning) {
76
- opacity.value = withRepeat(
77
- withSequence(
78
- withTiming(0.3, { duration: 500 }),
79
- withTiming(1, { duration: 500 }),
80
- ),
81
- -1,
82
- );
83
- } else {
84
- opacity.value = 1;
85
- }
86
- return () => cancelAnimation(opacity);
87
- }, [isRunning]);
88
- const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
89
- return (
90
- <Animated.View style={style}>
91
- <Text style={{ color: isRunning ? '#eab308' : '#22c55e', fontSize: 10 }}>
92
- {'\u25CF'}
93
- </Text>
94
- </Animated.View>
95
- );
96
- }
97
-
98
76
  // ── User Bubble ──
99
77
 
100
78
  const UserBubble = React.memo(function UserBubble({
@@ -122,84 +100,73 @@ const UserBubble = React.memo(function UserBubble({
122
100
  copiedMessageId: string | null;
123
101
  showEditButton: boolean;
124
102
  }) {
103
+ const labels = useLabels();
125
104
  const images = getImagesFromContent(message.content);
126
105
 
127
106
  return (
128
107
  <View className="flex-col items-end gap-0.5">
129
108
  {isEditing ? (
130
- <View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
131
- <BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
132
- <View className="px-4 py-2">
133
- <TextInput
134
- value={editedContent}
135
- onChangeText={onEditedContentChange}
136
- multiline
137
- className="text-base text-foreground leading-7"
138
- autoFocus
139
- />
140
- <View className="flex-row gap-2 mt-2">
141
- <Pressable
142
- className="px-3 py-1.5 rounded-lg bg-primary"
143
- onPress={() => onSaveEdit(message.id)}
144
- >
145
- <Text className="text-xs text-primary-foreground">Save</Text>
146
- </Pressable>
147
- <Pressable
148
- className="px-3 py-1.5 rounded-lg bg-muted-foreground"
149
- onPress={onCancelEdit}
150
- >
151
- <Text className="text-xs text-background">Cancel</Text>
152
- </Pressable>
153
- </View>
109
+ <View className="w-full max-w-[85%] gap-2 sm:max-w-[75%]">
110
+ <Textarea
111
+ value={editedContent}
112
+ onValueChange={onEditedContentChange}
113
+ rows={1}
114
+ autoResize
115
+ autoFocus
116
+ accessibilityLabel={labels.edit}
117
+ />
118
+ <View className="flex-row justify-end gap-2">
119
+ <Button tone="neutral" appearance="subtle" size="xs" onPress={onCancelEdit}>
120
+ {labels.cancel}
121
+ </Button>
122
+ <Button tone="action" size="xs" onPress={() => onSaveEdit(message.id)}>
123
+ {labels.save}
124
+ </Button>
154
125
  </View>
155
126
  </View>
156
127
  ) : (
157
- <View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
158
- <BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
159
- <View className="px-4 py-2">
160
- {/* Inline images from multi-part content */}
161
- {images.length > 0 && (
162
- <View className="flex-row flex-wrap gap-2 mb-2">
163
- {images.map((imgUrl, imgIdx) => {
164
- if (!ExpoImage) return null;
165
- return (
166
- <View key={`img-${imgIdx}`} className="rounded-xl overflow-hidden" style={{ width: 120, height: 120 }}>
167
- <ExpoImage
168
- source={{ uri: imgUrl }}
169
- className="w-full h-full"
170
- contentFit="cover"
171
- />
172
- </View>
173
- );
174
- })}
128
+ <AiChatUserMessage
129
+ animate={false}
130
+ actions={[
131
+ {
132
+ key: 'copy',
133
+ label: copiedMessageId === message.id ? labels.copied : labels.copy,
134
+ icon: copiedMessageId === message.id ? RiCheckLine : RiFileCopyLine,
135
+ onPress: () => onCopy(text, message.id),
136
+ },
137
+ ...(showEditButton
138
+ ? [
139
+ {
140
+ key: 'edit',
141
+ label: labels.edit,
142
+ icon: RiPencilLine,
143
+ onPress: () => onStartEdit(message.id, text),
144
+ },
145
+ ]
146
+ : []),
147
+ ]}
148
+ >
149
+ {images.length > 0 && ExpoImage ? (
150
+ <AiChatMessageLine block>
151
+ <View className="flex-row flex-wrap gap-2">
152
+ {images.map((imgUrl) => (
153
+ <View
154
+ key={imgUrl}
155
+ className="rounded-xl overflow-hidden"
156
+ style={{ width: 120, height: 120 }}
157
+ >
158
+ <ExpoImage
159
+ source={{ uri: imgUrl }}
160
+ className="w-full h-full"
161
+ contentFit="cover"
162
+ />
163
+ </View>
164
+ ))}
175
165
  </View>
176
- )}
177
- <Text className="text-base text-foreground leading-7">{text}</Text>
178
- </View>
179
- </View>
180
- )}
181
- {/* User message actions */}
182
- {!isEditing && (
183
- <View className="flex-row gap-1">
184
- <Pressable
185
- className="p-1.5 rounded-lg active:bg-muted"
186
- onPress={() => onCopy(text, message.id)}
187
- >
188
- {copiedMessageId === message.id ? (
189
- <Check size={14} className="text-green-500" />
190
- ) : (
191
- <Copy size={14} className="text-muted-foreground" />
192
- )}
193
- </Pressable>
194
- {showEditButton && (
195
- <Pressable
196
- className="p-1.5 rounded-lg active:bg-muted"
197
- onPress={() => onStartEdit(message.id, text)}
198
- >
199
- <Pencil size={14} className="text-muted-foreground" />
200
- </Pressable>
201
- )}
202
- </View>
166
+ </AiChatMessageLine>
167
+ ) : null}
168
+ <AiChatMessageLine>{text}</AiChatMessageLine>
169
+ </AiChatUserMessage>
203
170
  )}
204
171
  </View>
205
172
  );
@@ -217,12 +184,10 @@ const AssistantMessage = React.memo(function AssistantMessage({
217
184
  ttsActiveMessageId,
218
185
  ttsPlaybackState,
219
186
  onCopy,
220
- copiedMessageId,
221
187
  onThumbsUp,
222
188
  onThumbsDown,
223
189
  onApprovePlan,
224
190
  onRejectPlan,
225
- onToolResultPress,
226
191
  renderMarkdown,
227
192
  }: {
228
193
  message: ChatMessage;
@@ -234,26 +199,40 @@ const AssistantMessage = React.memo(function AssistantMessage({
234
199
  ttsActiveMessageId?: string | null;
235
200
  ttsPlaybackState?: PlaybackState;
236
201
  onCopy: (text: string, messageId: string) => void;
237
- copiedMessageId: string | null;
238
202
  onThumbsUp?: (messageId: string) => void;
239
203
  onThumbsDown?: (messageId: string) => void;
240
204
  onApprovePlan?: (planId: string) => void;
241
205
  onRejectPlan?: (planId: string) => void;
242
- onToolResultPress?: (messageId: string) => void;
243
206
  renderMarkdown?: (content: string) => React.ReactNode;
244
207
  }) {
208
+ const labels = useLabels();
245
209
  const hasContent = messageText.length > 0;
246
210
  const hasThinking = !!message.thinking;
247
211
 
212
+ // Both logs count units and are driven by `revealed`, from the calls that have really
213
+ // arrived — never by their own demo ticker. The task list waits for the turn to end.
214
+ const toolInvocations = message.toolInvocations;
215
+ const researchProgress = message.researchProgress;
216
+ const webLog = useMemo(
217
+ () => webSearchLog(toolInvocations, researchProgress, { t: labels.workLog }),
218
+ [toolInvocations, researchProgress, labels.workLog],
219
+ );
220
+ const taskLog = useMemo(() => {
221
+ const tasks = (toolInvocations ?? []).filter((inv) => !isWebInvocation(inv));
222
+ return tasks.length === 0 || isStreamingThis
223
+ ? null
224
+ : taskListLog(tasks, false, { t: labels.workLog });
225
+ }, [toolInvocations, isStreamingThis, labels.workLog]);
226
+
248
227
  const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
249
228
  const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
250
229
  const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
251
230
 
252
- // ThinkingIndicator — context-aware status
231
+ // AgentThinking — context-aware status
253
232
  let thinkingIndicator: React.ReactNode = null;
254
233
  if (isLoading && isLastMessage && !hasContent) {
255
234
  const activeTool = message.toolInvocations?.find(
256
- t => t.state === 'call' || t.state === 'partial-call',
235
+ (t) => t.state === 'call' || t.state === 'partial-call',
257
236
  );
258
237
  const rp = message.researchProgress;
259
238
  let activeStatus: string | undefined;
@@ -261,15 +240,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
261
240
  activeStatus = getToolActiveLabel(activeTool.toolName);
262
241
  } else if (rp?.phase && rp.phase !== 'complete') {
263
242
  activeStatus = getResearchActiveLabel(rp.phase);
264
- } else if (message.thinking) {
265
- activeStatus = 'Reasoning...';
266
243
  }
267
- thinkingIndicator = (
268
- <ThinkingIndicator
269
- isWorking={(message.toolInvocations?.length ?? 0) > 0}
270
- statusText={activeStatus}
271
- />
272
- );
244
+ // While it reasons, the reasoning block draws its own indicator: two at once is noise.
245
+ if (activeStatus !== undefined || !hasThinking) {
246
+ thinkingIndicator = (
247
+ <AgentThinking
248
+ variant={(message.toolInvocations?.length ?? 0) > 0 ? 'spin' : 'wave'}
249
+ label={activeStatus?.replace(/\.{3}$/, '')}
250
+ />
251
+ );
252
+ }
273
253
  }
274
254
 
275
255
  return (
@@ -285,52 +265,27 @@ const AssistantMessage = React.memo(function AssistantMessage({
285
265
  />
286
266
  )}
287
267
 
288
- {/* Tool Invocations */}
289
- {message.toolInvocations?.map((t, ti) => {
290
- const key = t.toolCallId || `tool-${message.id}-${ti}`;
291
- const toolLabel = getToolLabel(t.toolName);
292
- const isRunning = t.state === 'call' || t.state === 'partial-call';
293
- const isDone = t.state === 'result';
294
-
295
- let description = '';
296
- if (t.args?.url) {
297
- const url = String(t.args.url);
298
- description = url.length > 40 ? url.substring(0, 40) + '...' : url;
299
- } else if (t.args?.query) {
300
- const q = String(t.args.query);
301
- description = `"${q.length > 30 ? q.substring(0, 30) + '...' : q}"`;
302
- }
303
-
304
- return (
305
- <Pressable
306
- key={key}
307
- className="flex-row items-center gap-2 py-1 active:opacity-70"
308
- onPress={isDone && onToolResultPress ? () => onToolResultPress(message.id) : undefined}
309
- disabled={!isDone || !onToolResultPress}
310
- >
311
- <ToolBullet isRunning={isRunning} />
312
- <Text className="text-sm text-foreground flex-1 flex-shrink">
313
- <Text className="font-bold">{toolLabel}</Text>
314
- {description ? (
315
- <Text className="text-muted-foreground"> {description}</Text>
316
- ) : null}
317
- </Text>
318
- </Pressable>
319
- );
320
- })}
321
-
322
- {/* Research Progress */}
323
- {message.researchProgress && (
324
- <ResearchProgressCard progress={message.researchProgress} />
268
+ {/* What the turn did: where it went (searches, pages, research) and everything else. */}
269
+ {taskLog === null ? null : (
270
+ <TaskList
271
+ tasks={taskLog.tasks}
272
+ revealed={taskLog.revealed}
273
+ collapseOnComplete="all"
274
+ working={false}
275
+ />
276
+ )}
277
+ {webLog.steps.length === 0 ? null : (
278
+ <WebSearch
279
+ steps={webLog.steps}
280
+ revealed={webLog.revealed}
281
+ working={isStreamingThis ? undefined : false}
282
+ />
325
283
  )}
326
284
 
327
285
  {/* Extended Thinking */}
328
286
  {hasThinking && (
329
287
  <View className="mb-3 w-full">
330
- <Reasoning isStreaming={isStreamingThis && !hasContent}>
331
- <ReasoningTrigger />
332
- <ReasoningContent>{message.thinking!}</ReasoningContent>
333
- </Reasoning>
288
+ <ThinkingBlock text={message.thinking!} streaming={isStreamingThis && !hasContent} />
334
289
  </View>
335
290
  )}
336
291
 
@@ -339,18 +294,16 @@ const AssistantMessage = React.memo(function AssistantMessage({
339
294
  <View className="flex-col items-start gap-0.5">
340
295
  {/* Agent identity */}
341
296
  {message.agentInfo && (
342
- <View className="flex-row items-center gap-2 mb-0.5">
343
- <View className="w-5 h-5 rounded-full bg-orange-500/20 items-center justify-center">
344
- <Text style={{ fontSize: 8, color: '#f97316' }}>{'\u25CF'}</Text>
345
- </View>
346
- <Text className="text-xs font-semibold" style={{ color: '#f97316' }}>
297
+ <View className="mb-0.5 flex-row items-center gap-2">
298
+ <Avatar source={message.agentInfo.avatar} size="xs" />
299
+ <Text className="text-xs font-semibold text-text-primary">
347
300
  {message.agentInfo.name}
348
301
  </Text>
349
302
  </View>
350
303
  )}
351
304
  <View className="w-full">
352
305
  {message.source === 'voice' ? (
353
- <Text className="text-base text-foreground leading-7">
306
+ <Text className="text-base text-text-primary leading-7">
354
307
  {messageText}
355
308
  {message.isStreaming ? '\u258C' : ''}
356
309
  </Text>
@@ -360,42 +313,27 @@ const AssistantMessage = React.memo(function AssistantMessage({
360
313
  <AliaMarkdown content={messageText} />
361
314
  )}
362
315
  </View>
363
- {/* Action buttons */}
364
316
  {!isStreamingThis && hasContent && (
365
- <View className="flex-row gap-1">
366
- {onReadAloud && (
367
- <Pressable
368
- className="p-1.5 rounded-lg active:bg-muted"
369
- onPress={() => onReadAloud(message.id, messageText)}
370
- >
371
- {isThisPlaying || isThisPaused ? (
372
- <Square size={14} className={isThisPlaying ? 'text-primary' : 'text-muted-foreground'} />
373
- ) : (
374
- <Volume2 size={14} className={isThisLoading ? 'text-primary opacity-50' : 'text-muted-foreground'} />
375
- )}
376
- </Pressable>
377
- )}
378
- <Pressable
379
- className="p-1.5 rounded-lg active:bg-muted"
380
- onPress={() => onCopy(messageText, message.id)}
381
- >
382
- {copiedMessageId === message.id ? (
383
- <Check size={14} className="text-green-500" />
384
- ) : (
385
- <Copy size={14} className="text-muted-foreground" />
386
- )}
387
- </Pressable>
388
- {onThumbsUp && (
389
- <Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsUp(message.id)}>
390
- <ThumbsUp size={14} className="text-muted-foreground" />
391
- </Pressable>
392
- )}
393
- {onThumbsDown && (
394
- <Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsDown(message.id)}>
395
- <ThumbsDown size={14} className="text-muted-foreground" />
396
- </Pressable>
397
- )}
398
- </View>
317
+ <AiChatFeedbackRow
318
+ onLike={onThumbsUp ? () => onThumbsUp(message.id) : undefined}
319
+ onDislike={onThumbsDown ? () => onThumbsDown(message.id) : undefined}
320
+ onCopy={() => onCopy(messageText, message.id)}
321
+ actions={
322
+ onReadAloud
323
+ ? [
324
+ {
325
+ key: 'read-aloud',
326
+ label:
327
+ isThisPlaying || isThisPaused ? labels.stopReading : labels.readAloud,
328
+ icon: isThisPlaying || isThisPaused ? RiStopFill : RiVolumeUpLine,
329
+ active: isThisPlaying || isThisPaused,
330
+ disabled: isThisLoading,
331
+ onPress: () => onReadAloud(message.id, messageText),
332
+ },
333
+ ]
334
+ : []
335
+ }
336
+ />
399
337
  )}
400
338
  </View>
401
339
  )}
@@ -410,7 +348,6 @@ const AssistantMessage = React.memo(function AssistantMessage({
410
348
  export function AliaChatMessageList({
411
349
  messages,
412
350
  isStreaming,
413
- scrollOffsetY,
414
351
  onReadAloud,
415
352
  ttsActiveMessageId,
416
353
  ttsPlaybackState,
@@ -419,12 +356,9 @@ export function AliaChatMessageList({
419
356
  onThumbsDown,
420
357
  onApprovePlan,
421
358
  onRejectPlan,
422
- onToolResultPress,
423
359
  welcomeComponent,
424
- conversationLoading,
425
360
  renderMarkdown,
426
361
  }: AliaChatMessageListProps) {
427
- const scrollRef = useRef<Animated.ScrollView>(null);
428
362
  const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
429
363
  const [editedContent, setEditedContent] = useState('');
430
364
  const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
@@ -434,22 +368,6 @@ export function AliaChatMessageList({
434
368
  prevMessageCountRef.current = messages.length;
435
369
  }, [messages.length]);
436
370
 
437
- // Auto-scroll
438
- useEffect(() => {
439
- if (messages.length > 0) {
440
- const timer = setTimeout(() => {
441
- (scrollRef.current as { scrollToEnd?: (opts: { animated: boolean }) => void } | null)?.scrollToEnd?.({ animated: true });
442
- }, 100);
443
- return () => clearTimeout(timer);
444
- }
445
- }, [messages.length, isStreaming]);
446
-
447
- const scrollHandler = useAnimatedScrollHandler({
448
- onScroll: (event) => {
449
- scrollOffsetY.value = event.contentOffset.y;
450
- },
451
- });
452
-
453
371
  const handleCopy = useCallback(async (content: string, messageId: string) => {
454
372
  try {
455
373
  if (Platform.OS === 'web') {
@@ -473,36 +391,34 @@ export function AliaChatMessageList({
473
391
  const editedContentRef = useRef(editedContent);
474
392
  editedContentRef.current = editedContent;
475
393
 
476
- const handleSaveEdit = useCallback((messageId: string) => {
477
- if (onEditMessage && editedContentRef.current.trim()) {
478
- onEditMessage(messageId, editedContentRef.current);
479
- }
480
- setEditingMessageId(null);
481
- setEditedContent('');
482
- }, [onEditMessage]);
394
+ const handleSaveEdit = useCallback(
395
+ (messageId: string) => {
396
+ if (onEditMessage && editedContentRef.current.trim()) {
397
+ onEditMessage(messageId, editedContentRef.current);
398
+ }
399
+ setEditingMessageId(null);
400
+ setEditedContent('');
401
+ },
402
+ [onEditMessage],
403
+ );
483
404
 
484
405
  const handleCancelEdit = useCallback(() => {
485
406
  setEditingMessageId(null);
486
407
  setEditedContent('');
487
408
  }, []);
488
409
 
489
- const filteredMessages = useMemo(() => messages.filter(m => m.role !== 'system'), [messages]);
410
+ const filteredMessages = useMemo(() => messages.filter((m) => m.role !== 'system'), [messages]);
490
411
 
491
412
  return (
492
- <Animated.ScrollView
493
- ref={scrollRef}
494
- className="flex-1 bg-background px-4 py-4"
495
- contentContainerStyle={{ flexGrow: 1, paddingTop: 4, paddingBottom: 16 }}
496
- showsVerticalScrollIndicator={false}
497
- keyboardShouldPersistTaps="handled"
498
- onScroll={scrollHandler}
499
- scrollEventThrottle={16}
500
- >
501
- <View className="max-w-3xl mx-auto w-full" style={!filteredMessages.length ? { flex: 1, justifyContent: 'center' } : undefined}>
413
+ // Bloom's thread: follows the newest turn, and reports its scroll to a floating-chrome
414
+ // container so the header and composer fade in over it.
415
+ <AiChatThread>
416
+ <View
417
+ className="max-w-3xl mx-auto w-full"
418
+ style={!filteredMessages.length ? { flex: 1, justifyContent: 'center' } : undefined}
419
+ >
502
420
  {/* Welcome or loading */}
503
- {!filteredMessages.length && (
504
- welcomeComponent || null
505
- )}
421
+ {!filteredMessages.length && (welcomeComponent || null)}
506
422
 
507
423
  {/* Messages */}
508
424
  <View className="gap-2">
@@ -513,10 +429,7 @@ export function AliaChatMessageList({
513
429
  const messageText = getTextFromContent(msg.content);
514
430
 
515
431
  return (
516
- <Animated.View
517
- key={msg.id}
518
- entering={isNewMessage ? chatMessageEnter : undefined}
519
- >
432
+ <Animated.View key={msg.id} entering={isNewMessage ? chatMessageEnter : undefined}>
520
433
  {msg.role === 'user' ? (
521
434
  <UserBubble
522
435
  message={msg}
@@ -542,12 +455,10 @@ export function AliaChatMessageList({
542
455
  ttsActiveMessageId={ttsActiveMessageId}
543
456
  ttsPlaybackState={ttsPlaybackState}
544
457
  onCopy={handleCopy}
545
- copiedMessageId={copiedMessageId}
546
458
  onThumbsUp={onThumbsUp}
547
459
  onThumbsDown={onThumbsDown}
548
460
  onApprovePlan={onApprovePlan}
549
461
  onRejectPlan={onRejectPlan}
550
- onToolResultPress={onToolResultPress}
551
462
  renderMarkdown={renderMarkdown}
552
463
  />
553
464
  )}
@@ -556,6 +467,6 @@ export function AliaChatMessageList({
556
467
  })}
557
468
  </View>
558
469
  </View>
559
- </Animated.ScrollView>
470
+ </AiChatThread>
560
471
  );
561
472
  }