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