@alia.onl/sdk 12.0.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.
@@ -1,106 +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 { Text } from '@oxy.so/bloom/typography';
5
- import { memo, type ReactNode, useState } from 'react';
6
- import { Pressable, 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';
7
13
  import { useLabels } from '../lib/labels';
8
- import { AliaAvatar, type AliaAvatarState } from './AliaAvatar';
14
+ import { ALIA_ANCHORS, useAliaFlights } from '../lib/flights';
15
+ import { ALIA_AVATAR } from './AliaAvatar';
16
+ import type { AliaChatAgent } from './AliaChatContent';
9
17
 
10
18
  export interface AliaChatHeaderProps {
11
- /** What Alia is doing; the avatar shows it. */
12
- markState: AliaAvatarState;
13
- /** The chat has messages to clear. */
19
+ /** The chat has messages: there is something to share and to clear. */
14
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. */
15
24
  onClear: () => void;
16
- /** Draws the close island; omit where the chat is not dismissible. */
17
- onClose?: () => void;
18
- /** 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. */
19
32
  leading?: ReactNode;
20
- /** The host's own actions, kept before clear and close. */
33
+ /** The host's own actions, before share and the menu. */
21
34
  trailing?: ReactNode;
22
35
  /** Pressing Alia's avatar or name. Omitted, the avatar waves. */
23
36
  onProfilePress?: () => void;
24
37
  }
25
38
 
26
39
  /**
27
- * Alia's avatar above a name capsule, the conversation header of Bloom's `MultiAgentChat`.
28
- *
29
- * TODO(bloom): `ChatAgentProfile` draws exactly this (a 56px avatar over a 28px capsule
30
- * of radius 14, `backgroundSecondary` with a hairline border) but is not exported, so
31
- * 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.
32
42
  */
33
- function AliaProfile({ markState, onPress }: { markState: AliaAvatarState; onPress?: () => void }) {
34
- const [reactionKey, setReactionKey] = useState(0);
35
-
36
- // Without a host action the profile is still a button: the avatar waves back.
37
- const handlePress = () => {
38
- if (onPress) onPress();
39
- else setReactionKey((key) => key + 1);
40
- };
41
-
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
+ );
42
52
  return (
43
- <Pressable
44
- accessibilityRole="button"
45
- accessibilityLabel="Alia"
46
- onPress={handlePress}
47
- className="group/profile items-center"
48
- >
49
- <View pointerEvents="none" className="z-10 -mb-0.5">
50
- <AliaAvatar size={56} state={markState} reactionKey={reactionKey} />
51
- </View>
52
- <View
53
- pointerEvents="none"
54
- className="items-center justify-center rounded-[14px] border border-border-secondary bg-background-secondary-default px-2.5 py-1 group-hover/profile:bg-background-tertiary-default"
55
- >
56
- <Text numberOfLines={1} className="text-[16px] font-medium leading-5 text-text-primary">
57
- Alia
58
- </Text>
59
- </View>
60
- </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
+ />
61
59
  );
62
60
  }
63
61
 
62
+ const noop = () => undefined;
63
+
64
64
  /**
65
65
  * The chat's top bar: Bloom's `PageHeader`, set up as the shell sets it up for the
66
- * 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.
67
69
  */
68
70
  export const AliaChatHeader = memo(function AliaChatHeader({
69
- markState,
70
71
  hasMessages,
72
+ agents = [],
71
73
  onClear,
72
- onClose,
74
+ onBack,
75
+ onShare,
76
+ menu,
73
77
  leading,
74
78
  trailing,
75
79
  onProfilePress,
76
80
  }: AliaChatHeaderProps) {
77
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;
78
86
  return (
79
87
  <PageHeader
80
- leading={leading}
81
- title={<AliaProfile markState={markState} 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}
82
102
  titleAlign="center"
83
103
  actions={
84
104
  <>
85
105
  {trailing}
86
- <ButtonGroup accessibilityLabel={labels.chatActions}>
87
- {hasMessages ? (
88
- <ButtonGroupItem
89
- iconOnly
90
- leadingIcon={RiDeleteBin6Line}
91
- accessibilityLabel={labels.clear}
92
- onPress={onClear}
93
- />
94
- ) : null}
95
- {onClose ? (
96
- <ButtonGroupItem
97
- iconOnly
98
- leadingIcon={RiCloseLine}
99
- accessibilityLabel={labels.close}
100
- onPress={onClose}
101
- />
102
- ) : null}
103
- </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}
104
137
  </>
105
138
  }
106
139
  scrim="none"
@@ -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)
@@ -172,14 +175,69 @@ const UserBubble = React.memo(function UserBubble({
172
175
  );
173
176
  });
174
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
+
175
181
  // ── Assistant Message ──
176
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
+
177
234
  const AssistantMessage = React.memo(function AssistantMessage({
178
235
  message,
179
236
  messageText,
180
237
  isStreamingThis,
181
238
  isLastMessage,
182
239
  isLoading,
240
+ agentsWorking,
183
241
  onReadAloud,
184
242
  ttsActiveMessageId,
185
243
  ttsPlaybackState,
@@ -188,7 +246,6 @@ const AssistantMessage = React.memo(function AssistantMessage({
188
246
  onThumbsDown,
189
247
  onApprovePlan,
190
248
  onRejectPlan,
191
- onAnswerQuestions,
192
249
  renderMarkdown,
193
250
  }: {
194
251
  message: ChatMessage;
@@ -196,6 +253,8 @@ const AssistantMessage = React.memo(function AssistantMessage({
196
253
  isStreamingThis: boolean;
197
254
  isLastMessage: boolean;
198
255
  isLoading: boolean;
256
+ /** An agent Alia sent for has not answered yet: it is the one thinking, not Alia. */
257
+ agentsWorking: boolean;
199
258
  onReadAloud?: (messageId: string, text: string) => void;
200
259
  ttsActiveMessageId?: string | null;
201
260
  ttsPlaybackState?: PlaybackState;
@@ -204,11 +263,19 @@ const AssistantMessage = React.memo(function AssistantMessage({
204
263
  onThumbsDown?: (messageId: string) => void;
205
264
  onApprovePlan?: (planId: string) => void;
206
265
  onRejectPlan?: (planId: string) => void;
207
- onAnswerQuestions?: (message: string) => void;
208
266
  renderMarkdown?: (content: string) => React.ReactNode;
209
267
  }) {
210
268
  const labels = useLabels();
211
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
+ );
212
279
  const hasThinking = !!message.thinking;
213
280
 
214
281
  // Both logs count units and are driven by `revealed`, from the calls that have really
@@ -219,8 +286,8 @@ const AssistantMessage = React.memo(function AssistantMessage({
219
286
  () => webSearchLog(toolInvocations, researchProgress, { t: labels.workLog }),
220
287
  [toolInvocations, researchProgress, labels.workLog],
221
288
  );
222
- // The questions Alia asked, drawn as a card. Answerable only on the last message once the
223
- // 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.
224
291
  const questions = useMemo(
225
292
  () => (toolInvocations ?? []).flatMap((inv) => askedQuestions(inv) ?? []),
226
293
  [toolInvocations],
@@ -237,30 +304,37 @@ const AssistantMessage = React.memo(function AssistantMessage({
237
304
  const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
238
305
  const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
239
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;
240
326
 
241
- // AgentThinking — context-aware status
242
- let thinkingIndicator: React.ReactNode = null;
243
- if (isLoading && isLastMessage && !hasContent) {
244
- const activeTool = message.toolInvocations?.find(
245
- (t) => t.state === 'call' || t.state === 'partial-call',
246
- );
247
- const rp = message.researchProgress;
248
- let activeStatus: string | undefined;
249
- if (activeTool) {
250
- activeStatus = getToolActiveLabel(activeTool.toolName);
251
- } else if (rp?.phase && rp.phase !== 'complete') {
252
- activeStatus = getResearchActiveLabel(rp.phase);
253
- }
254
- // While it reasons, the reasoning block draws its own indicator: two at once is noise.
255
- if (activeStatus !== undefined || !hasThinking) {
256
- thinkingIndicator = (
257
- <AgentThinking
258
- variant={(message.toolInvocations?.length ?? 0) > 0 ? 'spin' : 'wave'}
259
- label={activeStatus?.replace(/\.{3}$/, '')}
260
- />
261
- );
262
- }
263
- }
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;
264
338
 
265
339
  return (
266
340
  <View className="w-full">
@@ -300,65 +374,30 @@ const AssistantMessage = React.memo(function AssistantMessage({
300
374
  )}
301
375
 
302
376
  {/* Message Content */}
303
- {(hasContent || message.isStreaming) && (
304
- <View className="flex-col items-start gap-0.5">
305
- {/* Agent identity */}
306
- {message.agentInfo && (
307
- <View className="mb-0.5 flex-row items-center gap-2">
308
- <Avatar source={message.agentInfo.avatar} size="xs" />
309
- <Text className="text-xs font-semibold text-text-primary">
310
- {message.agentInfo.name}
311
- </Text>
312
- </View>
313
- )}
314
- <View className="w-full">
315
- {message.source === 'voice' ? (
316
- <Text className="text-base text-text-primary leading-7">
317
- {messageText}
318
- {message.isStreaming ? '\u258C' : ''}
319
- </Text>
320
- ) : renderMarkdown ? (
321
- renderMarkdown(messageText)
322
- ) : (
323
- <AliaMarkdown content={messageText} />
324
- )}
325
- </View>
326
- {!isStreamingThis && hasContent && (
327
- <AiChatFeedbackRow
328
- onLike={onThumbsUp ? () => onThumbsUp(message.id) : undefined}
329
- onDislike={onThumbsDown ? () => onThumbsDown(message.id) : undefined}
330
- onCopy={() => onCopy(messageText, message.id)}
331
- actions={
332
- onReadAloud
333
- ? [
334
- {
335
- key: 'read-aloud',
336
- label:
337
- isThisPlaying || isThisPaused ? labels.stopReading : labels.readAloud,
338
- icon: isThisPlaying || isThisPaused ? RiStopFill : RiVolumeUpLine,
339
- active: isThisPlaying || isThisPaused,
340
- disabled: isThisLoading,
341
- onPress: () => onReadAloud(message.id, messageText),
342
- },
343
- ]
344
- : []
345
- }
346
- />
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} />
347
395
  )}
348
- </View>
349
- )}
396
+ </ReplyBody>
397
+ </Collapsible>
350
398
 
351
- {questions.length === 0 ? null : (
352
- <QuestionCard
353
- questions={questions}
354
- onSubmit={
355
- isLastMessage && !isLoading && onAnswerQuestions !== undefined
356
- ? onAnswerQuestions
357
- : undefined
358
- }
359
- />
360
- )}
361
- {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} />}
362
401
  </View>
363
402
  );
364
403
  });
@@ -376,7 +415,6 @@ export function AliaChatMessageList({
376
415
  onThumbsDown,
377
416
  onApprovePlan,
378
417
  onRejectPlan,
379
- onAnswerQuestions,
380
418
  welcomeComponent,
381
419
  renderMarkdown,
382
420
  }: AliaChatMessageListProps) {
@@ -429,6 +467,9 @@ export function AliaChatMessageList({
429
467
  }, []);
430
468
 
431
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));
432
473
 
433
474
  return (
434
475
  // Bloom's thread: follows the newest turn, and reports its scroll to a floating-chrome
@@ -451,39 +492,42 @@ export function AliaChatMessageList({
451
492
 
452
493
  return (
453
494
  <Animated.View key={msg.id} entering={isNewMessage ? chatMessageEnter : undefined}>
454
- {msg.role === 'user' ? (
455
- <UserBubble
456
- message={msg}
457
- text={messageText}
458
- isEditing={editingMessageId === msg.id}
459
- editedContent={editingMessageId === msg.id ? editedContent : ''}
460
- onEditedContentChange={setEditedContent}
461
- onSaveEdit={handleSaveEdit}
462
- onCancelEdit={handleCancelEdit}
463
- onStartEdit={handleStartEdit}
464
- onCopy={handleCopy}
465
- copiedMessageId={copiedMessageId}
466
- showEditButton={!!onEditMessage}
467
- />
468
- ) : (
469
- <AssistantMessage
470
- message={msg}
471
- messageText={messageText}
472
- isStreamingThis={isStreamingThis}
473
- isLastMessage={isLast}
474
- isLoading={isStreaming}
475
- onReadAloud={onReadAloud}
476
- ttsActiveMessageId={ttsActiveMessageId}
477
- ttsPlaybackState={ttsPlaybackState}
478
- onCopy={handleCopy}
479
- onThumbsUp={onThumbsUp}
480
- onThumbsDown={onThumbsDown}
481
- onApprovePlan={onApprovePlan}
482
- onRejectPlan={onRejectPlan}
483
- onAnswerQuestions={onAnswerQuestions}
484
- renderMarkdown={renderMarkdown}
485
- />
486
- )}
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>
487
531
  </Animated.View>
488
532
  );
489
533
  })}