@alia.onl/sdk 11.1.0 → 13.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 (36) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/package.json +5 -4
  3. package/src/components/AliaAvatar.tsx +16 -20
  4. package/src/components/AliaChatContent.tsx +234 -96
  5. package/src/components/AliaChatHeader.tsx +102 -76
  6. package/src/components/AliaChatMessageList.tsx +174 -134
  7. package/src/components/AliaChatScreen.tsx +45 -23
  8. package/src/components/AliaChatSheet.tsx +151 -50
  9. package/src/components/AliaWelcomeMessage.tsx +26 -53
  10. package/src/components/AmbientField.tsx +19 -16
  11. package/src/components/ComposerSuggestions.tsx +45 -0
  12. package/src/components/IdentityMark.tsx +3 -1
  13. package/src/components/Markdown.tsx +18 -24
  14. package/src/components/QuestionCard.tsx +35 -33
  15. package/src/components/ThinkingBlock.tsx +9 -13
  16. package/src/components/VoiceModeIcon.tsx +15 -7
  17. package/src/components/voice/VoiceControls.tsx +17 -9
  18. package/src/hooks/useAliaChat.ts +124 -5
  19. package/src/hooks/useComposerSuggestions.ts +84 -0
  20. package/src/index.ts +10 -10
  21. package/src/lib/agent-character.ts +28 -0
  22. package/src/lib/chat-stream.ts +25 -3
  23. package/src/lib/citations.ts +62 -10
  24. package/src/lib/delegation.ts +34 -0
  25. package/src/lib/domain.ts +12 -0
  26. package/src/lib/flights.ts +24 -0
  27. package/src/lib/greeting.ts +15 -0
  28. package/src/lib/image-data-url.ts +26 -5
  29. package/src/lib/labels.tsx +10 -2
  30. package/src/lib/questions.ts +15 -1
  31. package/src/lib/suggestions.ts +53 -0
  32. package/src/lib/task-labels.ts +7 -39
  33. package/src/lib/use-after-delay.ts +19 -0
  34. package/src/lib/work-log.ts +3 -9
  35. package/src/tools.ts +8 -1
  36. package/src/types.ts +6 -5
@@ -1,113 +1,139 @@
1
1
  import { ButtonGroup, ButtonGroupItem } from '@oxy.so/bloom/button-group';
2
- import { RiCloseLine, RiDeleteBin6Line } from '@oxy.so/bloom/icons';
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ } from '@oxy.so/bloom/dropdown-menu';
8
+ import { RiArrowLeftLine, RiDeleteBin6Line, RiMoreFill, RiShare2Line } from '@oxy.so/bloom/icons';
3
9
  import { PageHeader } from '@oxy.so/bloom/page-header';
4
- import { useTheme } from '@oxy.so/bloom/theme';
5
- import { Text } from '@oxy.so/bloom/typography';
6
- import { type ReactNode, useState } from 'react';
7
- import { Pressable, StyleSheet, View } from 'react-native';
10
+ import { AgentProfile } from '@oxy.so/bloom/agent-reply';
11
+ import { memo, type ReactNode, useMemo } from 'react';
12
+ import { readAgentCharacter } from '../lib/agent-character';
8
13
  import { useLabels } from '../lib/labels';
9
- import { AliaChatMark } from './AliaChatContent';
14
+ import { ALIA_ANCHORS, useAliaFlights } from '../lib/flights';
15
+ import { ALIA_AVATAR } from './AliaAvatar';
16
+ import type { AliaChatAgent } from './AliaChatContent';
10
17
 
11
18
  export interface AliaChatHeaderProps {
12
- /** The chat has messages to clear. */
19
+ /** The chat has messages: there is something to share and to clear. */
13
20
  hasMessages: boolean;
21
+ /** Delegated agents that have answered: they join Alia in the profile, up to three in all. */
22
+ agents?: AliaChatAgent[];
23
+ /** Clears the conversation, from the menu. */
14
24
  onClear: () => void;
15
- /** Draws the close island; omit where the chat is not dismissible. */
16
- onClose?: () => void;
17
- /** After the avatar, before the title — a host's back button. */
25
+ /** Draws the back button at the start of the bar; omit where there is nowhere to go back to. */
26
+ onBack?: () => void;
27
+ /** Draws the share button, once there is a conversation; omit where the host cannot share. */
28
+ onShare?: () => void;
29
+ /** More rows for the menu (`DropdownMenuItem`s), before Clear. */
30
+ menu?: ReactNode;
31
+ /** The host's own start content instead of the back button, e.g. a navigation drawer's menu. */
18
32
  leading?: ReactNode;
19
- /** The host's own actions, kept before clear and close. */
33
+ /** The host's own actions, before share and the menu. */
20
34
  trailing?: ReactNode;
21
35
  /** Pressing Alia's avatar or name. Omitted, the avatar waves. */
22
36
  onProfilePress?: () => void;
23
37
  }
24
38
 
25
39
  /**
26
- * Alia's avatar above a name capsule, the conversation header of Bloom's `MultiAgentChat`.
27
- *
28
- * TODO(bloom): `ChatAgentProfile` draws exactly this (a 56px avatar over a 28px capsule
29
- * of radius 14, `backgroundSecondary` with a hairline border) but is not exported, so
30
- * this repeats its geometry. Use Bloom's component when it is.
40
+ * Alia's profile, Bloom's `AgentProfile`: her avatar above a name capsule, joined by the agents
41
+ * that answered ("Alia + Lumi + Atlas"). Pressing it makes every avatar wave.
31
42
  */
32
- function AliaProfile({ onPress }: { onPress?: () => void }) {
33
- const { colors } = useTheme();
34
- const [hovered, setHovered] = useState(false);
35
- const [reactionKey, setReactionKey] = useState(0);
36
-
37
- // Without a host action the profile is still a button: the avatar waves back.
38
- const handlePress = () => {
39
- if (onPress) onPress();
40
- else setReactionKey((key) => key + 1);
41
- };
42
-
43
+ function AliaProfile({ agents, onPress }: { agents: AliaChatAgent[]; onPress?: () => void }) {
44
+ const flights = useAliaFlights();
45
+ const team = useMemo(
46
+ () => [
47
+ { id: 'alia', name: 'Alia', avatar: ALIA_AVATAR },
48
+ ...agents.map((agent) => ({ ...agent, avatar: readAgentCharacter(agent.character) })),
49
+ ],
50
+ [agents],
51
+ );
43
52
  return (
44
- <Pressable
45
- accessibilityRole="button"
46
- accessibilityLabel="Alia"
47
- onPress={handlePress}
48
- onHoverIn={() => setHovered(true)}
49
- onHoverOut={() => setHovered(false)}
50
- className="items-center"
51
- >
52
- <View pointerEvents="none" style={{ marginBottom: -2, zIndex: 1 }}>
53
- <AliaChatMark size={56} reactionKey={reactionKey} />
54
- </View>
55
- <View
56
- pointerEvents="none"
57
- className="items-center justify-center rounded-[14px] px-2.5 py-1"
58
- style={{
59
- backgroundColor: hovered ? colors.backgroundTertiary : colors.backgroundSecondary,
60
- borderWidth: StyleSheet.hairlineWidth,
61
- borderColor: colors.border,
62
- }}
63
- >
64
- <Text numberOfLines={1} className="text-[16px] font-medium leading-5 text-text-primary">
65
- Alia
66
- </Text>
67
- </View>
68
- </Pressable>
53
+ <AgentProfile
54
+ agents={team}
55
+ onPress={onPress ?? noop}
56
+ avatarRef={flights?.anchor(ALIA_ANCHORS.profile)}
57
+ avatarHidden={flights?.isLanding(ALIA_ANCHORS.profile)}
58
+ />
69
59
  );
70
60
  }
71
61
 
62
+ const noop = () => undefined;
63
+
72
64
  /**
73
65
  * The chat's top bar: Bloom's `PageHeader`, set up as the shell sets it up for the
74
- * agents' screens (inline, no scrim, no safe area), with Alia's profile as its title.
66
+ * agents' screens (inline, no scrim, no safe area). Alia's profile is its title only once
67
+ * there is a conversation: the empty chat already shows the avatar beside its greeting,
68
+ * so the bar carries just its buttons until then.
75
69
  */
76
- export function AliaChatHeader({
70
+ export const AliaChatHeader = memo(function AliaChatHeader({
77
71
  hasMessages,
72
+ agents = [],
78
73
  onClear,
79
- onClose,
74
+ onBack,
75
+ onShare,
76
+ menu,
80
77
  leading,
81
78
  trailing,
82
79
  onProfilePress,
83
80
  }: AliaChatHeaderProps) {
84
81
  const labels = useLabels();
82
+ // Share and the menu act on a conversation. With none there is nothing for them to do, and a
83
+ // bar of dimmed buttons over the empty chat is chrome with no job: no bar at all, unless the
84
+ // host brought content of its own (a back button to somewhere, a drawer's menu).
85
+ if (!hasMessages && !onBack && leading === undefined && trailing === undefined) return null;
85
86
  return (
86
87
  <PageHeader
87
- leading={leading}
88
- title={<AliaProfile onPress={onProfilePress} />}
88
+ leading={
89
+ leading ??
90
+ (onBack ? (
91
+ <ButtonGroup accessibilityLabel={labels.chatActions}>
92
+ <ButtonGroupItem
93
+ iconOnly
94
+ leadingIcon={RiArrowLeftLine}
95
+ accessibilityLabel={labels.back}
96
+ onPress={onBack}
97
+ />
98
+ </ButtonGroup>
99
+ ) : undefined)
100
+ }
101
+ title={hasMessages ? <AliaProfile agents={agents} onPress={onProfilePress} /> : undefined}
89
102
  titleAlign="center"
90
103
  actions={
91
104
  <>
92
105
  {trailing}
93
- <ButtonGroup accessibilityLabel={labels.chatActions}>
94
- {hasMessages ? (
95
- <ButtonGroupItem
96
- iconOnly
97
- leadingIcon={RiDeleteBin6Line}
98
- accessibilityLabel={labels.clear}
99
- onPress={onClear}
100
- />
101
- ) : null}
102
- {onClose ? (
103
- <ButtonGroupItem
104
- iconOnly
105
- leadingIcon={RiCloseLine}
106
- accessibilityLabel={labels.close}
107
- onPress={onClose}
108
- />
109
- ) : null}
110
- </ButtonGroup>
106
+ {hasMessages ? (
107
+ <ButtonGroup accessibilityLabel={labels.chatActions}>
108
+ {onShare ? (
109
+ <ButtonGroupItem
110
+ iconOnly
111
+ leadingIcon={RiShare2Line}
112
+ accessibilityLabel={labels.share}
113
+ onPress={onShare}
114
+ />
115
+ ) : null}
116
+ <DropdownMenu>
117
+ <DropdownMenuTrigger asChild label={labels.more}>
118
+ <ButtonGroupItem
119
+ iconOnly
120
+ leadingIcon={RiMoreFill}
121
+ accessibilityLabel={labels.more}
122
+ />
123
+ </DropdownMenuTrigger>
124
+ <DropdownMenuContent align="end">
125
+ {menu}
126
+ <DropdownMenuItem
127
+ tone="danger"
128
+ leading={<RiDeleteBin6Line width={16} height={16} />}
129
+ onPress={onClear}
130
+ >
131
+ {labels.clear}
132
+ </DropdownMenuItem>
133
+ </DropdownMenuContent>
134
+ </DropdownMenu>
135
+ </ButtonGroup>
136
+ ) : null}
111
137
  </>
112
138
  }
113
139
  scrim="none"
@@ -115,4 +141,4 @@ export function AliaChatHeader({
115
141
  safeArea={false}
116
142
  />
117
143
  );
118
- }
144
+ });
@@ -12,15 +12,19 @@ import { Textarea } from '@oxy.so/bloom/textarea';
12
12
  import { TaskList } from '@oxy.so/bloom/task-list';
13
13
  import { Text } from '@oxy.so/bloom/typography';
14
14
  import { WebSearch } from '@oxy.so/bloom/web-search';
15
- import { AliaMarkdown } from './Markdown';
16
- import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
15
+ import { AliaMarkdown, MARKDOWN_BODY } from './Markdown';
16
+ import { AiChatMessageLine, AiChatThread, AiChatUserMessage } from '@oxy.so/bloom/ai-chat';
17
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';
18
+ AgentReply,
19
+ AgentReplyAction,
20
+ AgentReplyActions,
21
+ AgentReplyBubble,
22
+ AgentReplyIdentity,
23
+ } from '@oxy.so/bloom/agent-reply';
24
+ import { readAgentCharacter } from '../lib/agent-character';
25
+ import { useAfterDelay } from '../lib/use-after-delay';
26
+ import { Collapsible } from '@oxy.so/bloom/collapsible';
27
+ import { ALIA_AVATAR } from './AliaAvatar';
24
28
  import { Button } from '@oxy.so/bloom/button';
25
29
  import { useLabels } from '../lib/labels';
26
30
  import { isWebInvocation, taskListLog, webSearchLog } from '../lib/work-log';
@@ -33,8 +37,9 @@ import {
33
37
  } from '@oxy.so/bloom/icons';
34
38
  import { ThinkingBlock } from './ThinkingBlock';
35
39
  import { PlanPreviewCard } from './PlanPreviewCard';
36
- import { QuestionCard } from './QuestionCard';
40
+ import { AskedQuestions } from './QuestionCard';
37
41
  import { askedQuestions, isQuestionInvocation } from '../lib/questions';
42
+ import { extractCitationSources, linkifyCitations } from '../lib/citations';
38
43
  import { chatMessageEnter } from '../animations';
39
44
  import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
40
45
  import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
@@ -69,8 +74,6 @@ export interface AliaChatMessageListProps {
69
74
  onThumbsDown?: (messageId: string) => void;
70
75
  onApprovePlan?: (planId: string) => void;
71
76
  onRejectPlan?: (planId: string) => void;
72
- /** Sends the answers to Alia's questions as the person's next message. */
73
- onAnswerQuestions?: (message: string) => void;
74
77
  // Welcome
75
78
  welcomeComponent?: React.ReactNode;
76
79
  // Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
@@ -154,11 +157,7 @@ const UserBubble = React.memo(function UserBubble({
154
157
  <AiChatMessageLine block>
155
158
  <View className="flex-row flex-wrap gap-2">
156
159
  {images.map((imgUrl) => (
157
- <View
158
- key={imgUrl}
159
- className="rounded-xl overflow-hidden"
160
- style={{ width: 120, height: 120 }}
161
- >
160
+ <View key={imgUrl} className="size-[120px] overflow-hidden rounded-xl">
162
161
  <ExpoImage
163
162
  source={{ uri: imgUrl }}
164
163
  className="w-full h-full"
@@ -176,14 +175,69 @@ const UserBubble = React.memo(function UserBubble({
176
175
  );
177
176
  });
178
177
 
178
+ /** Alia is shown thinking only once she has for this long: a hand-over to an agent shows no flash of her. */
179
+ const ALIA_THINKING_DELAY_MS = 450;
180
+
179
181
  // ── Assistant Message ──
180
182
 
183
+ /**
184
+ * One reply, Alia's or a delegated agent's: the reply, and under it who answered. While the one
185
+ * who answers is working the identity shows its avatar large and thinking, and when the reply
186
+ * arrives it is the same avatar that settles under it, as in Bloom's multi-agent chat. Alia's own
187
+ * text runs the width of the thread; an agent answers in a bubble.
188
+ */
189
+ function ReplyBody({
190
+ agent,
191
+ thinking,
192
+ reasoning,
193
+ actions,
194
+ children,
195
+ }: {
196
+ agent: ChatMessage['agentInfo'];
197
+ thinking: boolean;
198
+ /** What the model has reasoned so far: the thinking avatar folds it under its row. */
199
+ reasoning: React.ReactNode;
200
+ actions: React.ReactNode;
201
+ children: React.ReactNode;
202
+ }) {
203
+ const identity = (
204
+ <AgentReplyIdentity
205
+ character={agent ? readAgentCharacter(agent.character) : ALIA_AVATAR}
206
+ name={agent?.name ?? 'Alia'}
207
+ thinking={thinking}
208
+ reasoning={reasoning}
209
+ />
210
+ );
211
+ if (agent === undefined)
212
+ return (
213
+ <AgentReply
214
+ identity={identity}
215
+ actions={actions}
216
+ // The thread pads its content and spaces its messages: the text starts at its edge, full width.
217
+ style={{ marginHorizontal: 0, marginTop: 0, maxWidth: '100%', width: '100%' }}
218
+ >
219
+ {children}
220
+ </AgentReply>
221
+ );
222
+ return (
223
+ <AgentReply
224
+ identity={identity}
225
+ actions={actions}
226
+ // The reply starts where Alia's own text does and sits one thread gap from the message above.
227
+ style={{ marginHorizontal: 0, marginTop: 0, maxWidth: '100%' }}
228
+ >
229
+ {children ? <AgentReplyBubble text={MARKDOWN_BODY}>{children}</AgentReplyBubble> : null}
230
+ </AgentReply>
231
+ );
232
+ }
233
+
181
234
  const AssistantMessage = React.memo(function AssistantMessage({
182
235
  message,
183
236
  messageText,
184
237
  isStreamingThis,
185
238
  isLastMessage,
186
239
  isLoading,
240
+ agentsWorking,
187
241
  onReadAloud,
188
242
  ttsActiveMessageId,
189
243
  ttsPlaybackState,
@@ -192,7 +246,6 @@ const AssistantMessage = React.memo(function AssistantMessage({
192
246
  onThumbsDown,
193
247
  onApprovePlan,
194
248
  onRejectPlan,
195
- onAnswerQuestions,
196
249
  renderMarkdown,
197
250
  }: {
198
251
  message: ChatMessage;
@@ -200,6 +253,8 @@ const AssistantMessage = React.memo(function AssistantMessage({
200
253
  isStreamingThis: boolean;
201
254
  isLastMessage: boolean;
202
255
  isLoading: boolean;
256
+ /** An agent Alia sent for has not answered yet: it is the one thinking, not Alia. */
257
+ agentsWorking: boolean;
203
258
  onReadAloud?: (messageId: string, text: string) => void;
204
259
  ttsActiveMessageId?: string | null;
205
260
  ttsPlaybackState?: PlaybackState;
@@ -208,11 +263,19 @@ const AssistantMessage = React.memo(function AssistantMessage({
208
263
  onThumbsDown?: (messageId: string) => void;
209
264
  onApprovePlan?: (planId: string) => void;
210
265
  onRejectPlan?: (planId: string) => void;
211
- onAnswerQuestions?: (message: string) => void;
212
266
  renderMarkdown?: (content: string) => React.ReactNode;
213
267
  }) {
214
268
  const labels = useLabels();
215
269
  const hasContent = messageText.length > 0;
270
+ // `[n]` markers that name a source become links to it.
271
+ const citedText = useMemo(
272
+ () =>
273
+ linkifyCitations(
274
+ messageText,
275
+ extractCitationSources(message.toolInvocations, message.researchProgress?.sources),
276
+ ),
277
+ [messageText, message.toolInvocations, message.researchProgress?.sources],
278
+ );
216
279
  const hasThinking = !!message.thinking;
217
280
 
218
281
  // Both logs count units and are driven by `revealed`, from the calls that have really
@@ -223,8 +286,8 @@ const AssistantMessage = React.memo(function AssistantMessage({
223
286
  () => webSearchLog(toolInvocations, researchProgress, { t: labels.workLog }),
224
287
  [toolInvocations, researchProgress, labels.workLog],
225
288
  );
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.
289
+ // The questions Alia asked, kept in the thread as a record. They are answered above the
290
+ // composer (`AliaChatContent`), where the person is about to type.
228
291
  const questions = useMemo(
229
292
  () => (toolInvocations ?? []).flatMap((inv) => askedQuestions(inv) ?? []),
230
293
  [toolInvocations],
@@ -241,30 +304,37 @@ const AssistantMessage = React.memo(function AssistantMessage({
241
304
  const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
242
305
  const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
243
306
  const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
307
+ const replyActions =
308
+ !isStreamingThis && hasContent ? (
309
+ <AgentReplyActions
310
+ onVoteChange={(vote) =>
311
+ vote === 'up' ? onThumbsUp?.(message.id) : onThumbsDown?.(message.id)
312
+ }
313
+ onCopy={() => onCopy(messageText, message.id)}
314
+ >
315
+ {onReadAloud ? (
316
+ <AgentReplyAction
317
+ icon={isThisPlaying || isThisPaused ? RiStopFill : RiVolumeUpLine}
318
+ label={isThisPlaying || isThisPaused ? labels.stopReading : labels.readAloud}
319
+ selected={isThisPlaying || isThisPaused}
320
+ disabled={isThisLoading}
321
+ onPress={() => onReadAloud(message.id, messageText)}
322
+ />
323
+ ) : null}
324
+ </AgentReplyActions>
325
+ ) : undefined;
244
326
 
245
- // AgentThinking — context-aware status
246
- let thinkingIndicator: React.ReactNode = null;
247
- if (isLoading && isLastMessage && !hasContent) {
248
- const activeTool = message.toolInvocations?.find(
249
- (t) => t.state === 'call' || t.state === 'partial-call',
250
- );
251
- const rp = message.researchProgress;
252
- let activeStatus: string | undefined;
253
- if (activeTool) {
254
- activeStatus = getToolActiveLabel(activeTool.toolName);
255
- } else if (rp?.phase && rp.phase !== 'complete') {
256
- activeStatus = getResearchActiveLabel(rp.phase);
257
- }
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
- }
267
- }
327
+ // The one who answers is thinking until its reply starts: an agent from the call that sent for it,
328
+ // Alia when no agent is working for her. The avatar is the indicator.
329
+ // Alia thinks once she has for a moment: when she hands the turn over at once, only the agent is
330
+ // shown thinking, not Alia for an instant first.
331
+ const aliaThinking = useAfterDelay(
332
+ isLoading && !hasContent && !message.agentInfo && isLastMessage && !agentsWorking,
333
+ ALIA_THINKING_DELAY_MS,
334
+ );
335
+ const thinking = message.agentInfo ? isLoading && !hasContent : aliaThinking;
336
+ // The reply, its avatar and its reasoning come and go with their height eased, never popped.
337
+ const showsReply = hasContent || message.isStreaming || thinking;
268
338
 
269
339
  return (
270
340
  <View className="w-full">
@@ -304,65 +374,30 @@ const AssistantMessage = React.memo(function AssistantMessage({
304
374
  )}
305
375
 
306
376
  {/* Message Content */}
307
- {(hasContent || message.isStreaming) && (
308
- <View className="flex-col items-start gap-0.5">
309
- {/* Agent identity */}
310
- {message.agentInfo && (
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">
314
- {message.agentInfo.name}
315
- </Text>
316
- </View>
317
- )}
318
- <View className="w-full">
319
- {message.source === 'voice' ? (
320
- <Text className="text-base text-text-primary leading-7">
321
- {messageText}
322
- {message.isStreaming ? '\u258C' : ''}
323
- </Text>
324
- ) : renderMarkdown ? (
325
- renderMarkdown(messageText)
326
- ) : (
327
- <AliaMarkdown content={messageText} />
328
- )}
329
- </View>
330
- {!isStreamingThis && hasContent && (
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
- />
377
+ <Collapsible open={showsReply}>
378
+ <ReplyBody
379
+ agent={message.agentInfo}
380
+ thinking={thinking}
381
+ reasoning={
382
+ thinking && hasThinking ? <AliaMarkdown content={message.thinking!} /> : undefined
383
+ }
384
+ actions={replyActions}
385
+ >
386
+ {!hasContent && !message.isStreaming ? null : message.source === 'voice' ? (
387
+ <Text className="text-base text-text-primary leading-7">
388
+ {messageText}
389
+ {message.isStreaming ? '\u258C' : ''}
390
+ </Text>
391
+ ) : renderMarkdown ? (
392
+ renderMarkdown(citedText)
393
+ ) : (
394
+ <AliaMarkdown content={citedText} />
351
395
  )}
352
- </View>
353
- )}
396
+ </ReplyBody>
397
+ </Collapsible>
354
398
 
355
- {questions.length === 0 ? null : (
356
- <QuestionCard
357
- questions={questions}
358
- onSubmit={
359
- isLastMessage && !isLoading && onAnswerQuestions !== undefined
360
- ? onAnswerQuestions
361
- : undefined
362
- }
363
- />
364
- )}
365
- {thinkingIndicator}
399
+ {/* What Alia asked stays in the thread as a record; answering happens above the composer. */}
400
+ {questions.length === 0 ? null : <AskedQuestions questions={questions} />}
366
401
  </View>
367
402
  );
368
403
  });
@@ -380,7 +415,6 @@ export function AliaChatMessageList({
380
415
  onThumbsDown,
381
416
  onApprovePlan,
382
417
  onRejectPlan,
383
- onAnswerQuestions,
384
418
  welcomeComponent,
385
419
  renderMarkdown,
386
420
  }: AliaChatMessageListProps) {
@@ -433,6 +467,9 @@ export function AliaChatMessageList({
433
467
  }, []);
434
468
 
435
469
  const filteredMessages = useMemo(() => messages.filter((m) => m.role !== 'system'), [messages]);
470
+ // An agent Alia sent for that has not answered: it is the one that is thinking.
471
+ const agentsWorking =
472
+ isStreaming && filteredMessages.some((m) => m.agentInfo && !getTextFromContent(m.content));
436
473
 
437
474
  return (
438
475
  // Bloom's thread: follows the newest turn, and reports its scroll to a floating-chrome
@@ -455,39 +492,42 @@ export function AliaChatMessageList({
455
492
 
456
493
  return (
457
494
  <Animated.View key={msg.id} entering={isNewMessage ? chatMessageEnter : undefined}>
458
- {msg.role === 'user' ? (
459
- <UserBubble
460
- message={msg}
461
- text={messageText}
462
- isEditing={editingMessageId === msg.id}
463
- editedContent={editingMessageId === msg.id ? editedContent : ''}
464
- onEditedContentChange={setEditedContent}
465
- onSaveEdit={handleSaveEdit}
466
- onCancelEdit={handleCancelEdit}
467
- onStartEdit={handleStartEdit}
468
- onCopy={handleCopy}
469
- copiedMessageId={copiedMessageId}
470
- showEditButton={!!onEditMessage}
471
- />
472
- ) : (
473
- <AssistantMessage
474
- message={msg}
475
- messageText={messageText}
476
- isStreamingThis={isStreamingThis}
477
- isLastMessage={isLast}
478
- isLoading={isStreaming}
479
- onReadAloud={onReadAloud}
480
- ttsActiveMessageId={ttsActiveMessageId}
481
- ttsPlaybackState={ttsPlaybackState}
482
- onCopy={handleCopy}
483
- onThumbsUp={onThumbsUp}
484
- onThumbsDown={onThumbsDown}
485
- onApprovePlan={onApprovePlan}
486
- onRejectPlan={onRejectPlan}
487
- onAnswerQuestions={onAnswerQuestions}
488
- renderMarkdown={renderMarkdown}
489
- />
490
- )}
495
+ {/* A new row opens up as it arrives, so what is below it moves with it. */}
496
+ <Collapsible open appear={isNewMessage}>
497
+ {msg.role === 'user' ? (
498
+ <UserBubble
499
+ message={msg}
500
+ text={messageText}
501
+ isEditing={editingMessageId === msg.id}
502
+ editedContent={editingMessageId === msg.id ? editedContent : ''}
503
+ onEditedContentChange={setEditedContent}
504
+ onSaveEdit={handleSaveEdit}
505
+ onCancelEdit={handleCancelEdit}
506
+ onStartEdit={handleStartEdit}
507
+ onCopy={handleCopy}
508
+ copiedMessageId={copiedMessageId}
509
+ showEditButton={!!onEditMessage}
510
+ />
511
+ ) : (
512
+ <AssistantMessage
513
+ message={msg}
514
+ messageText={messageText}
515
+ isStreamingThis={isStreamingThis}
516
+ isLastMessage={isLast}
517
+ isLoading={isStreaming}
518
+ agentsWorking={agentsWorking}
519
+ onReadAloud={onReadAloud}
520
+ ttsActiveMessageId={ttsActiveMessageId}
521
+ ttsPlaybackState={ttsPlaybackState}
522
+ onCopy={handleCopy}
523
+ onThumbsUp={onThumbsUp}
524
+ onThumbsDown={onThumbsDown}
525
+ onApprovePlan={onApprovePlan}
526
+ onRejectPlan={onRejectPlan}
527
+ renderMarkdown={renderMarkdown}
528
+ />
529
+ )}
530
+ </Collapsible>
491
531
  </Animated.View>
492
532
  );
493
533
  })}