@alia.onl/sdk 15.8.0 → 19.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 (141) hide show
  1. package/CHANGELOG.md +242 -13
  2. package/README.md +223 -151
  3. package/package.json +15 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +0 -3
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +76 -34
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +32 -14
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +123 -0
  91. package/src/runtime/attachment-utils.ts +96 -0
  92. package/src/runtime/composer-draft-store.ts +270 -0
  93. package/src/runtime/config.tsx +186 -0
  94. package/src/runtime/conversation-store.ts +49 -0
  95. package/src/runtime/conversations.ts +557 -0
  96. package/src/runtime/error-utils.ts +104 -0
  97. package/src/runtime/host-tools.ts +140 -0
  98. package/src/runtime/index.ts +113 -0
  99. package/src/runtime/message-history.ts +124 -0
  100. package/src/runtime/model.ts +39 -0
  101. package/src/runtime/oxy-files.ts +37 -0
  102. package/src/runtime/power-levels.ts +66 -0
  103. package/src/runtime/query-keys.ts +34 -0
  104. package/src/runtime/random-uuid.ts +25 -0
  105. package/src/runtime/routes.ts +51 -0
  106. package/src/runtime/sharing.ts +165 -0
  107. package/src/runtime/sse-frame-reader.ts +88 -0
  108. package/src/runtime/stream-events.ts +146 -0
  109. package/src/runtime/stream-outcome.ts +21 -0
  110. package/src/runtime/thought-store.ts +103 -0
  111. package/src/runtime/thread/agent-activity.ts +261 -0
  112. package/src/runtime/thread/automation-labels.ts +79 -0
  113. package/src/runtime/thread/conversation-export.ts +135 -0
  114. package/src/runtime/thread/message-days.ts +120 -0
  115. package/src/runtime/thread/message-processor.ts +93 -0
  116. package/src/runtime/thread/task-utils.ts +11 -0
  117. package/src/runtime/thread/thought-utils.ts +752 -0
  118. package/src/runtime/thread/thread-history.ts +139 -0
  119. package/src/runtime/thread/timeline.ts +202 -0
  120. package/src/runtime/thread/title-tags.ts +8 -0
  121. package/src/runtime/thread/tool-cards.ts +383 -0
  122. package/src/runtime/turn-failure.ts +108 -0
  123. package/src/runtime/types.ts +148 -0
  124. package/src/runtime/usage-limit-error.ts +44 -0
  125. package/src/runtime/use-agent-activity.ts +80 -0
  126. package/src/runtime/use-conversation-stream.ts +1427 -0
  127. package/src/runtime/use-conversation-thread.ts +556 -0
  128. package/src/runtime/use-start-conversation.ts +107 -0
  129. package/src/runtime/use-thread-history.ts +64 -0
  130. package/src/runtime/use-thread-search.ts +95 -0
  131. package/src/runtime/use-turn-edit.ts +99 -0
  132. package/src/tools.ts +2 -2
  133. package/src/types.ts +18 -47
  134. package/src/components/AliaChatMessageList.tsx +0 -532
  135. package/src/components/ThinkingBlock.tsx +0 -43
  136. package/src/hooks/useAliaChat.ts +0 -573
  137. package/src/lib/catalogue.ts +0 -236
  138. package/src/lib/chat-stream.ts +0 -655
  139. package/src/lib/chat-transport.ts +0 -98
  140. package/src/lib/delegation.ts +0 -34
  141. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,127 @@
1
+ import type { AgentActivityState } from '../../runtime/thread/agent-activity';
2
+ import { useLabels } from '../../lib/labels';
3
+ import { getToolPillLabel } from '../../runtime/model';
4
+ import { Admonition } from '@oxy.so/bloom/admonition';
5
+ import { AgentProgress } from '@oxy.so/bloom/agent-progress';
6
+ import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
7
+ import { Badge } from '@oxy.so/bloom/badge';
8
+ import { Card, CardBody, CardHeader } from '@oxy.so/bloom/card';
9
+ import { Muted } from '@oxy.so/bloom/typography';
10
+ import React, { useEffect, useState } from 'react';
11
+ import { View } from 'react-native';
12
+
13
+ /**
14
+ * AgentTaskCard — an agent run in progress, inline in the chat.
15
+ *
16
+ * Bloom all the way down: a `Card` whose header carries the run's status as a
17
+ * `Badge` with the elapsed time, the plan as `AgentProgress` (its rings are the
18
+ * checklist), what the agent is doing right now as `AgentThinking`, and an
19
+ * error as an `Admonition`. The tool is named by its product label, never by
20
+ * its internal name.
21
+ */
22
+
23
+ interface AgentTaskCardProps {
24
+ activity: AgentActivityState;
25
+ }
26
+
27
+ /** `42s`, `3m 5s` — how long the run has been going. */
28
+ export function formatRunElapsed(startedAt: number | null, now: number): string {
29
+ if (!startedAt) return '';
30
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
31
+ if (seconds < 60) return `${seconds}s`;
32
+ return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
33
+ }
34
+
35
+ /**
36
+ * The plan's lines as `AgentProgress` steps. The steps are also its React keys,
37
+ * and a plan can repeat a line ("Run the tests" twice), so a repeat is told
38
+ * apart by its position rather than dropped.
39
+ */
40
+ export function uniqueStepLabels(texts: readonly string[]): string[] {
41
+ const seen = new Map<string, number>();
42
+ return texts.map((text) => {
43
+ const count = (seen.get(text) ?? 0) + 1;
44
+ seen.set(text, count);
45
+ return count === 1 ? text : `${text} (${count})`;
46
+ });
47
+ }
48
+
49
+ export const AgentTaskCard = React.memo(function AgentTaskCard({ activity }: AgentTaskCardProps) {
50
+ const { t } = useLabels();
51
+ const {
52
+ plan,
53
+ currentAction,
54
+ isComplete,
55
+ hasError,
56
+ lastError,
57
+ eventCount,
58
+ startedAt,
59
+ latestResponse,
60
+ } = activity;
61
+ const [now, setNow] = useState(() => Date.now());
62
+
63
+ // The clock runs while the agent does; a finished run keeps its last reading.
64
+ useEffect(() => {
65
+ if (!startedAt || isComplete) return;
66
+ const interval = setInterval(() => setNow(Date.now()), 1000);
67
+ return () => clearInterval(interval);
68
+ }, [startedAt, isComplete]);
69
+
70
+ // Nothing to show yet
71
+ if (!plan && !currentAction && eventCount === 0) return null;
72
+
73
+ const elapsed = formatRunElapsed(startedAt, now);
74
+ const status = isComplete
75
+ ? { color: 'success' as const, label: t('chat.agentRun.complete') }
76
+ : hasError
77
+ ? { color: 'error' as const, label: t('chat.agentRun.error') }
78
+ : { color: 'info' as const, label: t('chat.agentRun.working') };
79
+
80
+ return (
81
+ <Card>
82
+ <CardHeader>
83
+ <View className="flex-row items-center justify-between gap-2">
84
+ <Badge variant="subtle" color={status.color} content={status.label} />
85
+ <Muted>
86
+ {[elapsed, plan && plan.total > 0 ? `${plan.completed}/${plan.total}` : '']
87
+ .filter((part) => part !== '')
88
+ .join(' · ')}
89
+ </Muted>
90
+ </View>
91
+ </CardHeader>
92
+ <CardBody>
93
+ <View className="gap-3">
94
+ {plan && plan.items.length > 0 ? (
95
+ <AgentProgress
96
+ steps={uniqueStepLabels(plan.items.map((item) => item.text))}
97
+ completedCount={plan.completed}
98
+ />
99
+ ) : null}
100
+
101
+ {currentAction && !isComplete ? (
102
+ <AgentThinking
103
+ variant="wave"
104
+ label={
105
+ currentAction.content
106
+ ? `${getToolPillLabel(currentAction.toolName, t)} · ${
107
+ currentAction.content.length > 80
108
+ ? `${currentAction.content.slice(0, 80)}…`
109
+ : currentAction.content
110
+ }`
111
+ : getToolPillLabel(currentAction.toolName, t)
112
+ }
113
+ />
114
+ ) : null}
115
+
116
+ {latestResponse && !isComplete ? (
117
+ <Muted numberOfLines={4}>
118
+ {latestResponse.length > 200 ? `${latestResponse.slice(0, 200)}…` : latestResponse}
119
+ </Muted>
120
+ ) : null}
121
+
122
+ {hasError && lastError ? <Admonition type="error">{lastError}</Admonition> : null}
123
+ </View>
124
+ </CardBody>
125
+ </Card>
126
+ );
127
+ });
@@ -0,0 +1,401 @@
1
+ import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
2
+ import {
3
+ AiChatThread,
4
+ type AiChatThreadHandle,
5
+ useAiChatChromeInsets,
6
+ } from '@oxy.so/bloom/ai-chat';
7
+ import { ChatDateHeader } from '@oxy.so/bloom/chat-screen';
8
+ import { Divider } from '@oxy.so/bloom/divider';
9
+ import { Loading } from '@oxy.so/bloom/loading';
10
+ import * as Skeleton from '@oxy.so/bloom/skeleton';
11
+ import { Fragment, memo, type ReactNode, type RefObject, useCallback, useMemo } from 'react';
12
+ import {
13
+ type LayoutChangeEvent,
14
+ type NativeScrollEvent,
15
+ type NativeSyntheticEvent,
16
+ View,
17
+ } from 'react-native';
18
+ import { AT_BOTTOM_THRESHOLD } from '../../hooks/useAtBottom';
19
+ import { useMessageVote } from '../../hooks/useMessageVote';
20
+ import { useOpenThought } from '../../hooks/useOpenThought';
21
+ import { useReadAloud } from '../../hooks/useReadAloud';
22
+ import { useThoughtScope } from '../../hooks/useThoughtScope';
23
+ import { useThreadRows } from '../../hooks/useThreadRows';
24
+ import { useTimelineWindow } from '../../hooks/useTimelineWindow';
25
+ import { useFailedTurnToast } from '../../hooks/useFailedTurnToast';
26
+ import { copyText } from '../../lib/clipboard';
27
+ import { messageText } from '../../lib/content-utils';
28
+ import { useLabels } from '../../lib/labels';
29
+ import type { AgentActivityState } from '../../runtime/thread/agent-activity';
30
+ import type { FailedTurn } from '../../runtime/turn-failure';
31
+ import type { Message } from '../../runtime/types';
32
+ import type { ThreadHistory } from '../../runtime/thread/thread-history';
33
+ import type { AgentState } from '../../types';
34
+ import { AgentResultCard } from './AgentResultCard';
35
+ import { AgentTaskCard } from './AgentTaskCard';
36
+ import { NewConversationOffer } from './NewConversationOffer';
37
+ import { type ThreadActions, ThreadRow } from './ThreadRow';
38
+
39
+ /**
40
+ * How near the top asks for the page above, px.
41
+ *
42
+ * A screenful of warning rather than the top itself: asking at zero leaves the reader at a dead
43
+ * end while the request flies, and a thread is read upwards at speed.
44
+ */
45
+ const NEAR_TOP = 300;
46
+
47
+ /** The agent's offer to start the next stretch of the thread fresh. */
48
+ export interface NewConversationOfferProps {
49
+ /** The agent's reason for offering. */
50
+ reason: string;
51
+ onAccept: () => void;
52
+ onDismiss: () => void;
53
+ }
54
+
55
+ export interface AliaThreadProps {
56
+ /** The conversation being streamed into. */
57
+ messages: Message[];
58
+ /** The conversation on screen: what a vote, a saved scroll position and the thought panel are addressed to. */
59
+ conversationId?: string;
60
+ /** A turn is in flight. */
61
+ isLoading: boolean;
62
+ /** The conversation's messages are still on their way. */
63
+ conversationLoading?: boolean;
64
+ history?: ThreadHistory;
65
+ /**
66
+ * The message a jump was aimed at, by cursor, or `null` at the present. A window is half before
67
+ * the hit and half after, so landing at its end — which is what following the newest does with
68
+ * any list — leaves the reader twenty messages past the thing they searched for.
69
+ */
70
+ focusCursor?: string | null;
71
+ /**
72
+ * The turn that got no answer. It is told with a toast and a Retry, and the person's message
73
+ * stays in the thread.
74
+ */
75
+ failedTurn?: FailedTurn | null;
76
+ onRetryTurn?: () => void;
77
+ /**
78
+ * Ask for Alia's last reply again. Offered on that reply only, and only once it has finished: an
79
+ * earlier one would throw away every turn after it, and a thread has no branches to keep them in.
80
+ */
81
+ onRegenerate?: (assistantMessageId: string) => void;
82
+ /** Load one of this conversation's questions into the composer to rewrite it. */
83
+ onStartEdit?: (userMessageId: string) => void;
84
+ newConversationOffer?: NewConversationOfferProps;
85
+ /** The run an agent is on for this conversation: in progress, or the card it leaves. */
86
+ agentActivity?: AgentActivityState | null;
87
+ /**
88
+ * A voice call is on. Its composer is the call's controls, so there is nowhere for an edit or a
89
+ * regenerate to go, and reading a reply aloud would talk over it.
90
+ */
91
+ call?: { agentState: AgentState | undefined };
92
+ /** Shown, centred above the composer, while there is nothing to read and nothing on its way. */
93
+ empty?: ReactNode;
94
+ /**
95
+ * Bloom's thread handle, for a jump to a cursor and for a restore: `scrollToEnd`,
96
+ * `scrollToOffset`, and `getScrollView()` for the one thing neither covers.
97
+ */
98
+ threadRef: RefObject<AiChatThreadHandle | null>;
99
+ onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
100
+ /** A conversation read by somebody it was shared with: no votes, no read-aloud, no regenerate. */
101
+ readOnly?: boolean;
102
+ }
103
+
104
+ /** True for a reply with no word and no tool call yet: the thinking indicator's moment. */
105
+ function isSilent(message: Message | undefined): boolean {
106
+ if (message === undefined || message.role === 'user') return true;
107
+ return (
108
+ message.role === 'assistant' &&
109
+ messageText(message).length === 0 &&
110
+ (message.toolInvocations?.length ?? 0) === 0
111
+ );
112
+ }
113
+
114
+ function ThreadSkeleton() {
115
+ return (
116
+ <View className="gap-5 py-4">
117
+ <View className="items-end">
118
+ <Skeleton.Box width="65%" height={48} borderRadius={24} />
119
+ </View>
120
+ <View className="items-start gap-2.5">
121
+ <Skeleton.Box width="80%" height={14} borderRadius={8} />
122
+ <Skeleton.Box width="70%" height={14} borderRadius={8} />
123
+ <Skeleton.Box width="45%" height={14} borderRadius={8} />
124
+ </View>
125
+ <View className="items-end">
126
+ <Skeleton.Box width="50%" height={40} borderRadius={24} />
127
+ </View>
128
+ <View className="items-start gap-2.5">
129
+ <Skeleton.Box width="85%" height={14} borderRadius={8} />
130
+ <Skeleton.Box width="60%" height={14} borderRadius={8} />
131
+ </View>
132
+ </View>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * The conversation's thread: its turns in Bloom's AI Chat thread, the same on the sheet, the
138
+ * screen and the Alia app.
139
+ *
140
+ * It draws a list it is handed and owns what belongs to the list: the window over a long history
141
+ * and the scroll that goes with it (`useTimelineWindow`), the day lines and the seams between an
142
+ * agent's conversations, each turn's actions, the thought panel's selection, and the toast for a
143
+ * turn that failed. What the conversation IS — its messages, the history behind it, the turn in
144
+ * flight — comes in as props from `useConversationThread` and the thread hooks.
145
+ */
146
+ export const AliaThread = memo(function AliaThread({
147
+ messages,
148
+ conversationId,
149
+ isLoading,
150
+ conversationLoading = false,
151
+ history: historyProps,
152
+ focusCursor,
153
+ failedTurn = null,
154
+ onRetryTurn,
155
+ onRegenerate,
156
+ onStartEdit,
157
+ newConversationOffer,
158
+ agentActivity,
159
+ call,
160
+ empty,
161
+ threadRef,
162
+ onScroll,
163
+ readOnly = false,
164
+ }: AliaThreadProps) {
165
+ const { t } = useLabels();
166
+ const callActive = call !== undefined;
167
+ const voiceThinking = call?.agentState === 'thinking';
168
+ const { messages: pastMessages, loading: loadingHistory = false, loadMore } = historyProps ?? {};
169
+
170
+ useFailedTurnToast(failedTurn, onRetryTurn);
171
+
172
+ const { rows, live, history, ids, arrived, dayLabels, seamIds, lastReplyIndex, timingOf } =
173
+ useThreadRows({
174
+ messages,
175
+ history: pastMessages,
176
+ conversationId,
177
+ conversationLoading,
178
+ });
179
+
180
+ // ── The thought panel: this conversation as it reads it, and opening it on a turn ──
181
+ const thoughtScope = useThoughtScope({
182
+ conversationId,
183
+ messages: live,
184
+ conversationLoading,
185
+ isLoading,
186
+ failedTurn,
187
+ });
188
+ const lastLive = isLoading ? live[live.length - 1] : undefined;
189
+ const runningTurnId =
190
+ lastLive?.role === 'assistant' && (lastLive.toolInvocations?.length ?? 0) > 0
191
+ ? lastLive.id
192
+ : null;
193
+ const openThought = useOpenThought(thoughtScope, history, runningTurnId);
194
+
195
+ // ── What a row can do ──
196
+ const vote = useMessageVote();
197
+ const readAloud = useReadAloud(callActive);
198
+ const copy = useCallback((text: string) => copyText(text, t), [t]);
199
+ const actions = useMemo<ThreadActions>(
200
+ () => ({
201
+ copy,
202
+ vote,
203
+ readAloud: readAloud.toggle,
204
+ openThought,
205
+ readOnly,
206
+ }),
207
+ [copy, vote, readAloud.toggle, openThought, readOnly],
208
+ );
209
+
210
+ /**
211
+ * Whether the reply at `index` can be asked for again: Alia's last reply, the last thing in the
212
+ * thread (a question after it has its own retry), not the one a failure hangs under, and with a
213
+ * question of this conversation before it to replay.
214
+ */
215
+ const canRegenerate = (message: Message, index: number): boolean => {
216
+ if (onRegenerate === undefined || callActive) return false;
217
+ if (index !== lastReplyIndex || index !== rows.length - 1) return false;
218
+ if (index < history.length || failedTurn?.anchorMessageId === message.id) return false;
219
+ for (let i = index - 1; i >= history.length; i--) {
220
+ if (rows[i].role === 'user') return true;
221
+ }
222
+ return false;
223
+ };
224
+
225
+ /**
226
+ * The conversation that holds the row at `index`, as the id its own actions address: a vote on
227
+ * an old message belongs to the stretch that ended, not to the one on screen.
228
+ */
229
+ const conversationOf = (index: number): string | undefined =>
230
+ index < history.length ? history[index].conversationId : conversationId;
231
+
232
+ // ── Which rows are mounted, and the scroll that goes with them ──
233
+ /** The row a cursor jump was aimed at, as an index into what is shown, or -1. */
234
+ const focusIndex = useMemo(
235
+ () =>
236
+ focusCursor === undefined || focusCursor === null
237
+ ? -1
238
+ : history.findIndex((m) => m.cursor === focusCursor),
239
+ [history, focusCursor],
240
+ );
241
+
242
+ /**
243
+ * Put the message a jump was aimed at under the reader's eyes, re-applied on every layout of
244
+ * that row: the rows above it settle in installments, so the first position it reports is not
245
+ * the one it keeps. Each call supersedes the last and the final one is right.
246
+ */
247
+ const handleFocusLayout = useCallback(
248
+ (event: LayoutChangeEvent) => {
249
+ threadRef.current?.scrollToOffset({ offset: Math.max(0, event.nativeEvent.layout.y) });
250
+ },
251
+ [threadRef],
252
+ );
253
+
254
+ const timeline = useTimelineWindow({
255
+ ids,
256
+ scrollKey: focusCursor == null ? (conversationId ?? null) : null,
257
+ ready: !conversationLoading,
258
+ focusIndex,
259
+ turnInFlight: isLoading,
260
+ atBottomThreshold: AT_BOTTOM_THRESHOLD,
261
+ threadRef,
262
+ onLoadHistory: loadMore,
263
+ onScroll,
264
+ });
265
+
266
+ const chromeInsets = useAiChatChromeInsets();
267
+ const nothingToShow =
268
+ rows.length === 0 && !conversationLoading && !isLoading && !loadingHistory && !voiceThinking;
269
+ if (nothingToShow) {
270
+ return (
271
+ // Above the floating composer: the container measures it for us.
272
+ <View
273
+ className="flex-1 justify-center px-4"
274
+ style={{ paddingBottom: chromeInsets?.bottom ?? 0 }}
275
+ >
276
+ <View className="w-full max-w-[768px] self-center">{empty}</View>
277
+ </View>
278
+ );
279
+ }
280
+
281
+ const renderRow = (message: Message, index: number) => {
282
+ const fromHistory = index < history.length;
283
+ const timing = timingOf(message.id);
284
+ const dayLabel = dayLabels.get(message.id);
285
+ const isLastReply = index === lastReplyIndex;
286
+ return (
287
+ <Fragment key={message.id || `row-${index}`}>
288
+ {seamIds.has(message.id) ? <Divider spacing={16}>{t('chat.newStretch')}</Divider> : null}
289
+ {dayLabel === undefined ? null : <ChatDateHeader label={dayLabel} placement="inline" />}
290
+ <ThreadRow
291
+ message={message}
292
+ // Only a message that arrived while this conversation was open animates in; none of the
293
+ // history ever does.
294
+ isNew={!fromHistory && arrived.has(message.id)}
295
+ isLastReply={isLastReply}
296
+ // Only the last reply reads it: handing it to every row would re-render the whole thread
297
+ // when a turn starts and when it ends.
298
+ isLoading={isLastReply ? isLoading : false}
299
+ conversationId={conversationOf(index)}
300
+ onLayout={index === focusIndex ? handleFocusLayout : undefined}
301
+ actions={actions}
302
+ readAloudState={readAloud.stateOf(message.id)}
303
+ readAloudBlocked={readAloud.blocked}
304
+ onRegenerate={canRegenerate(message, index) ? onRegenerate : undefined}
305
+ // A question of an earlier conversation is not in the list an edit cuts, so the "edit"
306
+ // would silently become a new turn.
307
+ onStartEdit={
308
+ message.role === 'user' && !fromHistory && !callActive ? onStartEdit : undefined
309
+ }
310
+ workStartedAt={timing.startedAt}
311
+ workEndedAt={timing.endedAt}
312
+ />
313
+ </Fragment>
314
+ );
315
+ };
316
+
317
+ /*
318
+ * Bloom's thread, with the four behaviours Alia's own scroll hook had.
319
+ *
320
+ * `followAnimated={false}` is the one that is not a default: an ANIMATED `scrollToEnd` emits a
321
+ * run of intermediate positions from above the end, and anything reading those as the reader's
322
+ * own scrolling stops following at the first token — the classic autoscroll that switches
323
+ * itself off and looks random. Unanimated there are no intermediate positions, so the trap is
324
+ * unreachable rather than guarded against.
325
+ *
326
+ * `onStartReached` rather than an end-reached: a chat pages UPWARD. `maintainStartPosition`
327
+ * adds the page's growth to the offset so the turn being read does not slide away under it.
328
+ *
329
+ * The keyboard is the container's to handle: Bloom's AI Chat family imports no keyboard
330
+ * controller at all.
331
+ */
332
+ return (
333
+ <View className="flex-1">
334
+ <AiChatThread
335
+ ref={threadRef}
336
+ followAnimated={false}
337
+ followThreshold={AT_BOTTOM_THRESHOLD}
338
+ onStartReached={timeline.onStartReached}
339
+ onStartReachedThreshold={NEAR_TOP}
340
+ maintainStartPosition={timeline.onStartReached !== undefined}
341
+ onScroll={timeline.onScroll}
342
+ >
343
+ {/* The transcript column, centred: a little wider than the composer (896 against its
344
+ 768), so the answer has room to breathe. */}
345
+ <View className="w-full max-w-4xl self-center">
346
+ {rows.length === 0 && conversationLoading ? <ThreadSkeleton /> : null}
347
+
348
+ <View className="relative" onLayout={timeline.onListLayout}>
349
+ {/* The slot the history loader shows in, held open for as long as there is history to
350
+ load. The thread anchors ONE growth per request — the first after
351
+ `onStartReached` — so a loader that appeared on request would spend that anchor on
352
+ itself and leave the page that follows it to push the reader down. */}
353
+ {loadMore === undefined && !loadingHistory ? null : (
354
+ <View className="h-14 items-center justify-center">
355
+ {loadingHistory ? (
356
+ <Loading variant="inline" text={t('chat.loadingHistory')} />
357
+ ) : null}
358
+ </View>
359
+ )}
360
+ {rows
361
+ .slice(timeline.start)
362
+ .map((message, offset) => renderRow(message, timeline.start + offset))}
363
+ </View>
364
+
365
+ {/* Agent execution — in-progress card or completed result card */}
366
+ {agentActivity && agentActivity.eventCount > 0 ? (
367
+ agentActivity.isComplete ? (
368
+ <AgentResultCard activity={agentActivity} />
369
+ ) : (
370
+ <AgentTaskCard activity={agentActivity} />
371
+ )
372
+ ) : null}
373
+
374
+ {/* The agent's offer to start the next stretch fresh. Last in the list on purpose: it
375
+ must not cover what is being read, and ignoring it has to leave the thread exactly as
376
+ it was. */}
377
+ {newConversationOffer === undefined ? null : (
378
+ <NewConversationOffer
379
+ reason={newConversationOffer.reason}
380
+ onAccept={newConversationOffer.onAccept}
381
+ onDismiss={newConversationOffer.onDismiss}
382
+ />
383
+ )}
384
+
385
+ {/* Waiting indicator for a call: the agent is thinking but there is no reply to hold it
386
+ yet (right after the person spoke). */}
387
+ {voiceThinking && !isLoading && messages[messages.length - 1]?.role !== 'assistant' ? (
388
+ <AgentThinking variant="wave" label={t('chat.thinking')} showTimer={false} />
389
+ ) : null}
390
+
391
+ {/* Bloom's `AgentChat`: while a turn is busy and its reply has no word yet, AgentThinking
392
+ sits under the transcript, in its column. A turn already running tools shows their
393
+ own working line. */}
394
+ {isLoading && isSilent(messages[messages.length - 1]) ? (
395
+ <AgentThinking variant="wave" label={t('chat.thinking')} />
396
+ ) : null}
397
+ </View>
398
+ </AiChatThread>
399
+ </View>
400
+ );
401
+ });
@@ -0,0 +1,78 @@
1
+ import { useLabels } from '../../lib/labels';
2
+ import { EmptyState } from '@oxy.so/bloom/empty-state';
3
+ import { ErrorBoundary } from '@oxy.so/bloom/error-boundary';
4
+ import { RiErrorWarningLine } from '@oxy.so/bloom/icons/RiErrorWarningLine';
5
+ /**
6
+ * One block of an answer, contained.
7
+ *
8
+ * ## What used to happen
9
+ *
10
+ * The blocks in a turn are built from what a tool returned, and a tool result
11
+ * is data the app did not write. `cardOf` checks that `card.type` is one of
12
+ * four known names and that `card.data` is truthy, and then hands `data`
13
+ * straight to the card with an unchecked cast:
14
+ *
15
+ * <WeatherCard data={card.data as WeatherCardData} />
16
+ *
17
+ * `WeatherCard` then reads `data.daily[selectedDay]`, `data.hourly.filter(...)`
18
+ * and `data.current.temperature`, none of them guarded. A result shaped
19
+ * `{ type: 'weather', data: { place: 'Madrid' } }` passes every check `cardOf`
20
+ * makes and throws a `TypeError` on the first of those reads.
21
+ *
22
+ * A throw during render unwinds to the nearest boundary, and between a message
23
+ * row and `AppErrorBoundary` in `app/(app)/_layout.tsx` there was nothing. So a
24
+ * single malformed block did not degrade a card — it replaced the entire app
25
+ * scene with the crash screen, taking the thread, the composer and the sidebar
26
+ * with it. Reloading reopened the same conversation and hit the same block
27
+ * again, which makes the conversation unreadable rather than merely broken.
28
+ *
29
+ * #608 §7 asks for the opposite: "presentación segura y error explícito, no
30
+ * caída completa del chat".
31
+ *
32
+ * ## Why a boundary rather than validating the data
33
+ *
34
+ * Both, eventually — but they answer different questions. Validating each
35
+ * card's payload says what a well-formed weather result looks like, and is
36
+ * worth having. A boundary says that NO block, including the ones nobody has
37
+ * thought about yet and the ones a future tool adds, can take the conversation
38
+ * down with it. Only the second survives a card that is added next month.
39
+ *
40
+ * `retry` is deliberately not offered. The same data will be re-read and the
41
+ * same block will throw again, and a button that cannot work is the thing #608
42
+ * §1.6 rules out. What is offered is the truth: this part could not be shown,
43
+ * and the rest of the answer below it still can.
44
+ */
45
+ export function MessageBlockBoundary({
46
+ children,
47
+ onError,
48
+ }: {
49
+ children: React.ReactNode;
50
+ /**
51
+ * Reported rather than swallowed. A block that throws is a defect in the
52
+ * tool's output or in the card that reads it, and neither is visible from
53
+ * here — the console line is what makes it findable.
54
+ */
55
+ onError?: (error: Error) => void;
56
+ }) {
57
+ const { t } = useLabels();
58
+
59
+ return (
60
+ <ErrorBoundary
61
+ onError={(error) => {
62
+ console.error('A message block failed to render:', error);
63
+ onError?.(error);
64
+ }}
65
+ // Bloom's compact empty state — the panel rung, a glyph and one line —
66
+ // rather than the boundary's default, whose Retry is ruled out above.
67
+ fallback={
68
+ <EmptyState
69
+ variant="compact"
70
+ icon={RiErrorWarningLine}
71
+ description={t('chat.blockFailed')}
72
+ />
73
+ }
74
+ >
75
+ {children}
76
+ </ErrorBoundary>
77
+ );
78
+ }
@@ -0,0 +1,54 @@
1
+ import { useLabels } from '../../lib/labels';
2
+ import { Notification } from '@oxy.so/bloom/notification';
3
+ import React from 'react';
4
+ interface NewConversationOfferProps {
5
+ /**
6
+ * The model's own sentence for why it is offering, in the model's own words,
7
+ * or empty when it sent none.
8
+ *
9
+ * Never translated and never rewritten. A substitute we invented would read
10
+ * as the agent's reasoning while being ours, and a plausible one is worse
11
+ * than none — so an empty reason simply drops the line.
12
+ */
13
+ reason: string;
14
+ onAccept: () => void;
15
+ onDismiss: () => void;
16
+ }
17
+
18
+ /**
19
+ * The agent offering to start the next stretch of the thread fresh.
20
+ *
21
+ * ## It is an offer, and the shape has to say so
22
+ *
23
+ * Nothing has been written when this appears: the tool behind it creates
24
+ * nothing, so ignoring it leaves the thread exactly as it was. That is why this
25
+ * is a card at the end of the list rather than a dialog — it does not cover
26
+ * what somebody is reading, does not take focus from the composer, and carries
27
+ * on being ignorable for as long as they ignore it.
28
+ *
29
+ * Accepting is the person's act: it starts a conversation with the same agent.
30
+ * The agent cannot do that itself, by construction rather than by policy.
31
+ *
32
+ * Bloom's `Notification` with the two answers as its actions: "Not now" the
33
+ * quiet one, "Start new conversation" the solid one.
34
+ */
35
+ export const NewConversationOffer = React.memo(function NewConversationOffer({
36
+ reason,
37
+ onAccept,
38
+ onDismiss,
39
+ }: NewConversationOfferProps) {
40
+ const { t } = useLabels();
41
+
42
+ return (
43
+ <Notification
44
+ status="information"
45
+ title={t('chat.newConversationOffer')}
46
+ description={reason === '' ? undefined : reason}
47
+ actions={[
48
+ { label: t('chat.newConversationDismiss'), onPress: onDismiss },
49
+ { label: t('chat.newConversationAccept'), onPress: onAccept },
50
+ ]}
51
+ dismissible={false}
52
+ />
53
+ );
54
+ });