@alia.onl/sdk 15.8.0 → 19.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 (142) hide show
  1. package/CHANGELOG.md +262 -13
  2. package/README.md +223 -151
  3. package/package.json +16 -6
  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 +4 -4
  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 +98 -35
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +34 -15
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +141 -0
  91. package/src/runtime/app-assertion.ts +53 -0
  92. package/src/runtime/attachment-utils.ts +96 -0
  93. package/src/runtime/composer-draft-store.ts +270 -0
  94. package/src/runtime/config.tsx +186 -0
  95. package/src/runtime/conversation-store.ts +49 -0
  96. package/src/runtime/conversations.ts +557 -0
  97. package/src/runtime/error-utils.ts +104 -0
  98. package/src/runtime/host-tools.ts +140 -0
  99. package/src/runtime/index.ts +113 -0
  100. package/src/runtime/message-history.ts +124 -0
  101. package/src/runtime/model.ts +39 -0
  102. package/src/runtime/oxy-files.ts +37 -0
  103. package/src/runtime/power-levels.ts +66 -0
  104. package/src/runtime/query-keys.ts +34 -0
  105. package/src/runtime/random-uuid.ts +25 -0
  106. package/src/runtime/routes.ts +51 -0
  107. package/src/runtime/sharing.ts +165 -0
  108. package/src/runtime/sse-frame-reader.ts +88 -0
  109. package/src/runtime/stream-events.ts +146 -0
  110. package/src/runtime/stream-outcome.ts +21 -0
  111. package/src/runtime/thought-store.ts +103 -0
  112. package/src/runtime/thread/agent-activity.ts +261 -0
  113. package/src/runtime/thread/automation-labels.ts +79 -0
  114. package/src/runtime/thread/conversation-export.ts +135 -0
  115. package/src/runtime/thread/message-days.ts +120 -0
  116. package/src/runtime/thread/message-processor.ts +93 -0
  117. package/src/runtime/thread/task-utils.ts +11 -0
  118. package/src/runtime/thread/thought-utils.ts +752 -0
  119. package/src/runtime/thread/thread-history.ts +139 -0
  120. package/src/runtime/thread/timeline.ts +202 -0
  121. package/src/runtime/thread/title-tags.ts +8 -0
  122. package/src/runtime/thread/tool-cards.ts +383 -0
  123. package/src/runtime/turn-failure.ts +108 -0
  124. package/src/runtime/types.ts +148 -0
  125. package/src/runtime/usage-limit-error.ts +44 -0
  126. package/src/runtime/use-agent-activity.ts +80 -0
  127. package/src/runtime/use-conversation-stream.ts +1433 -0
  128. package/src/runtime/use-conversation-thread.ts +556 -0
  129. package/src/runtime/use-start-conversation.ts +107 -0
  130. package/src/runtime/use-thread-history.ts +64 -0
  131. package/src/runtime/use-thread-search.ts +95 -0
  132. package/src/runtime/use-turn-edit.ts +99 -0
  133. package/src/tools.ts +2 -2
  134. package/src/types.ts +18 -47
  135. package/src/components/AliaChatMessageList.tsx +0 -532
  136. package/src/components/ThinkingBlock.tsx +0 -43
  137. package/src/hooks/useAliaChat.ts +0 -573
  138. package/src/lib/catalogue.ts +0 -236
  139. package/src/lib/chat-stream.ts +0 -655
  140. package/src/lib/chat-transport.ts +0 -98
  141. package/src/lib/delegation.ts +0 -34
  142. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,458 @@
1
+ import { AgentProgress } from '@oxy.so/bloom/agent-progress';
2
+ import { AgentReplyIdentity } from '@oxy.so/bloom/agent-reply';
3
+ import {
4
+ AiChatAssistantMessage,
5
+ AiChatUserMessage,
6
+ type AiChatTurnAction,
7
+ } from '@oxy.so/bloom/ai-chat';
8
+ import { RiEditLine } from '@oxy.so/bloom/icons/RiEditLine';
9
+ import { RiFileCopyLine } from '@oxy.so/bloom/icons/RiFileCopyLine';
10
+ import { RiRefreshLine } from '@oxy.so/bloom/icons/RiRefreshLine';
11
+ import { RiStopFill } from '@oxy.so/bloom/icons/RiStopFill';
12
+ import { RiThumbDownLine } from '@oxy.so/bloom/icons/RiThumbDownLine';
13
+ import { RiThumbUpLine } from '@oxy.so/bloom/icons/RiThumbUpLine';
14
+ import { RiVolumeUpLine } from '@oxy.so/bloom/icons/RiVolumeUpLine';
15
+ import { TaskList } from '@oxy.so/bloom/task-list';
16
+ import { Text } from '@oxy.so/bloom/typography';
17
+ import { WebSearch } from '@oxy.so/bloom/web-search';
18
+ import { memo } from 'react';
19
+ import { type LayoutChangeEvent, View } from 'react-native';
20
+ import Animated from 'react-native-reanimated';
21
+ import type { ReadAloudState } from '../../hooks/useReadAloud';
22
+ import type { Vote } from '../../hooks/useMessageVote';
23
+ import { readAgentCharacter } from '../../lib/agent-character';
24
+ import { getImagesFromContent, messageText } from '../../lib/content-utils';
25
+ import { ANCHORS, useAliaFlights } from '../../lib/flights';
26
+ import { useLabels } from '../../lib/labels';
27
+ import { askedQuestions, isQuestionInvocation } from '../../lib/questions';
28
+ import { parseCatalogueTool, useToolRenderers } from '../../lib/tool-renderers';
29
+ import { isWebInvocation, taskListLog, webSearchLog } from '../../lib/work-log';
30
+ import { useAliaRuntime } from '../../runtime/config';
31
+ import { type Message, cardOf, formatElapsed, getToolPillLabel } from '../../runtime/model';
32
+ import type { OpenThought } from '../../hooks/useOpenThought';
33
+ import { Image } from '../Image';
34
+ import { PlanPreviewCard } from '../PlanPreviewCard';
35
+ import { AskedQuestions } from '../QuestionCard';
36
+ import { ReplyMarkdown } from '../ReplyMarkdown';
37
+ import { MessageBlockBoundary } from './MessageBlockBoundary';
38
+ import { ToolResultCard } from './ToolResultCard';
39
+ import { TurnMenu } from './TurnMenu';
40
+ import { TurnStatusLine } from './TurnStatusLine';
41
+
42
+ /**
43
+ * What a row can do, built once by the thread so every memoised row sees the same object: the
44
+ * thread re-renders per streamed token, and a function made per row would re-render them all.
45
+ */
46
+ export interface ThreadActions {
47
+ /** Copy a turn's words; resolves whether they really reached the clipboard. */
48
+ copy: (text: string) => Promise<boolean>;
49
+ vote: (messageId: string, vote: Vote, conversationId: string | undefined) => void;
50
+ readAloud: (messageId: string, text: string, conversationId: string | undefined) => void;
51
+ /** Absent where the host has no thought panel: the "worked for" line is then plain text. */
52
+ openThought?: OpenThought;
53
+ /** A thread read by somebody it was shared with: words to copy, nothing to vote on or play. */
54
+ readOnly?: boolean;
55
+ }
56
+
57
+ export interface ThreadRowProps {
58
+ message: Message;
59
+ /** The row only for a message that arrived while the conversation was open; history never animates. */
60
+ isNew: boolean;
61
+ /** The last of Alia's own replies: the one that may be streaming. */
62
+ isLastReply: boolean;
63
+ /** A turn is in flight. Given only to the last reply, so a turn starting re-renders one row. */
64
+ isLoading: boolean;
65
+ /** The conversation that holds this message, or `undefined` while none does (`Message.unsaved` also withholds it). */
66
+ conversationId: string | undefined;
67
+ /** Where this row ended up, for the one row a jump is aimed at. */
68
+ onLayout?: (event: LayoutChangeEvent) => void;
69
+ actions: ThreadActions;
70
+ /** This reply's read-aloud, as a primitive so only its own row re-renders. */
71
+ readAloudState: ReadAloudState;
72
+ /** A call or a dictation holds the audio. */
73
+ readAloudBlocked: boolean;
74
+ /** Given only to the one reply that may be regenerated. */
75
+ onRegenerate?: (assistantMessageId: string) => void;
76
+ /** Given only to a question that may be edited. */
77
+ onStartEdit?: (userMessageId: string) => void;
78
+ /** The turn's timing for its work summary, as epoch ms, or `null` for a turn the server has not stamped. */
79
+ workStartedAt: number | null;
80
+ workEndedAt: number | null;
81
+ }
82
+
83
+ const isPresent = <T,>(value: T | null): value is T => value !== null;
84
+
85
+ /**
86
+ * One turn of the thread: a question in Bloom's bubble, or a reply with its work summary, cards
87
+ * and Markdown.
88
+ *
89
+ * The turn's own actions are each offered only where they can be done — read aloud and copy need
90
+ * words, regenerate a finished reply in a thread not busy with another turn — and the row under
91
+ * the turn and the long-press menu are the same list.
92
+ */
93
+ export const ThreadRow = memo(function ThreadRow({
94
+ message,
95
+ isNew,
96
+ isLastReply,
97
+ isLoading,
98
+ conversationId,
99
+ onLayout,
100
+ actions,
101
+ readAloudState,
102
+ readAloudBlocked,
103
+ onRegenerate,
104
+ onStartEdit,
105
+ workStartedAt,
106
+ workEndedAt,
107
+ }: ThreadRowProps) {
108
+ const { t } = useLabels();
109
+ const text = messageText(message);
110
+ const images = message.content ? getImagesFromContent(message.content) : [];
111
+ const isReply = message.role === 'assistant';
112
+ const storedIn = message.unsaved === true ? undefined : conversationId;
113
+ const hasText = text.length > 0;
114
+
115
+ const copyAction: AiChatTurnAction | null = hasText
116
+ ? {
117
+ key: 'copy',
118
+ label: t('chat.copy'),
119
+ icon: RiFileCopyLine,
120
+ onPress: () => void actions.copy(text),
121
+ }
122
+ : null;
123
+
124
+ if (!isReply) {
125
+ const editAction: AiChatTurnAction | null =
126
+ onStartEdit === undefined
127
+ ? null
128
+ : {
129
+ key: 'edit',
130
+ label: t('chat.edit'),
131
+ icon: RiEditLine,
132
+ onPress: () => onStartEdit(message.id),
133
+ };
134
+ const questionActions = [copyAction, editAction].filter(isPresent);
135
+ return (
136
+ <Animated.View onLayout={onLayout}>
137
+ {text.length === 0 && images.length === 0 ? null : (
138
+ // Bloom's user turn, with the breathing room above it the thread has always had.
139
+ <View className="mt-2 w-full flex-col items-end">
140
+ <TurnMenu actions={questionActions} label={t('chat.turnActions')}>
141
+ <AiChatUserMessage animate={isNew} actions={questionActions}>
142
+ {images.length > 0 && (
143
+ <View className="flex-row flex-wrap gap-2">
144
+ {images.map((url) => (
145
+ <View key={url} className="size-[120px] overflow-hidden rounded-xl">
146
+ <Image source={{ uri: url }} className="h-full w-full" contentFit="cover" />
147
+ </View>
148
+ ))}
149
+ </View>
150
+ )}
151
+ {text}
152
+ </AiChatUserMessage>
153
+ </TurnMenu>
154
+ </View>
155
+ )}
156
+ </Animated.View>
157
+ );
158
+ }
159
+
160
+ return (
161
+ <Animated.View onLayout={onLayout}>
162
+ <ReplyTurn
163
+ message={message}
164
+ text={text}
165
+ isNew={isNew}
166
+ isLastReply={isLastReply}
167
+ isLoading={isLoading}
168
+ storedIn={storedIn}
169
+ actions={actions}
170
+ copyAction={copyAction}
171
+ readAloudState={readAloudState}
172
+ readAloudBlocked={readAloudBlocked}
173
+ onRegenerate={onRegenerate}
174
+ workStartedAt={workStartedAt}
175
+ workEndedAt={workEndedAt}
176
+ />
177
+ </Animated.View>
178
+ );
179
+ });
180
+
181
+ interface ReplyTurnProps {
182
+ message: Message;
183
+ text: string;
184
+ isNew: boolean;
185
+ isLastReply: boolean;
186
+ isLoading: boolean;
187
+ storedIn: string | undefined;
188
+ actions: ThreadActions;
189
+ copyAction: AiChatTurnAction | null;
190
+ readAloudState: ReadAloudState;
191
+ readAloudBlocked: boolean;
192
+ onRegenerate?: (assistantMessageId: string) => void;
193
+ workStartedAt: number | null;
194
+ workEndedAt: number | null;
195
+ }
196
+
197
+ function ReplyTurn({
198
+ message,
199
+ text,
200
+ isNew,
201
+ isLastReply,
202
+ isLoading,
203
+ storedIn,
204
+ actions,
205
+ copyAction,
206
+ readAloudState,
207
+ readAloudBlocked,
208
+ onRegenerate,
209
+ workStartedAt,
210
+ workEndedAt,
211
+ }: ReplyTurnProps) {
212
+ const { t } = useLabels();
213
+ const { faviconUrl } = useAliaRuntime().host;
214
+ const renderers = useToolRenderers();
215
+ const flights = useAliaFlights();
216
+ const toolInvocations = message.toolInvocations ?? [];
217
+ const plan = message.plan;
218
+ const agent = message.agentInfo;
219
+ const { readOnly = false } = actions;
220
+
221
+ // A host's own drawing of a catalogue tool's result, before any generic card: keyed by the
222
+ // tool's canonical name (`oxy_<app>__<tool>`), never by anything the model wrote.
223
+ const rendered = toolInvocations.flatMap((call, index) => {
224
+ if (call.state !== 'result') return [];
225
+ const parsed = parseCatalogueTool(call.toolName);
226
+ const render = parsed ? renderers[`${parsed.app}:${parsed.tool}`] : undefined;
227
+ if (!parsed || render === undefined) return [];
228
+ return [
229
+ {
230
+ key: call.toolCallId || `rendered-${message.id}-${index}`,
231
+ call,
232
+ node: render({
233
+ app: parsed.app,
234
+ tool: parsed.tool,
235
+ toolCallId: call.toolCallId,
236
+ input: call.args,
237
+ output: call.result,
238
+ ...(agent ? { agentId: agent.id } : {}),
239
+ }),
240
+ },
241
+ ];
242
+ });
243
+ const renderedCalls = new Set(rendered.map(({ call }) => call));
244
+
245
+ // The calls the work summary lists: every one that did not draw its own card. Read on each
246
+ // render — the list changes per streamed tool event and the row is memoised on `message`.
247
+ const workInvocations = toolInvocations.filter(
248
+ (call) => cardOf(call) === null && !isQuestionInvocation(call) && !renderedCalls.has(call),
249
+ );
250
+ const questions = toolInvocations.flatMap((call) => askedQuestions(call) ?? []);
251
+ const toolCards = toolInvocations.flatMap((call, index) => {
252
+ if (renderedCalls.has(call)) return [];
253
+ const card = cardOf(call);
254
+ return card === null ? [] : [{ key: call.toolCallId || `tool-${message.id}-${index}`, card }];
255
+ });
256
+ /** The template swaps the steps for the reply once the turn is done. */
257
+ const turnWorking = isLoading && isLastReply && message.isStreaming === true;
258
+
259
+ // Where the turn went — searches, page visits, research — as a `WebSearch` trail with its
260
+ // sources, and everything else it did as a `TaskList`. Both are driven by `revealed` from the
261
+ // calls that have really arrived, never by their own demo ticker.
262
+ const webLog = webSearchLog(workInvocations, message.researchProgress, { t, faviconUrl });
263
+ const taskInvocations = workInvocations.filter((call) => !isWebInvocation(call));
264
+ const taskLog =
265
+ taskInvocations.length === 0 || turnWorking ? null : taskListLog(taskInvocations, false, { t });
266
+ const hasWorkLog = webLog.steps.length > 0 || (workInvocations.length > 0 && !turnWorking);
267
+
268
+ const reading =
269
+ readAloudState === 'loading' || readAloudState === 'playing' || readAloudState === 'paused';
270
+ const readAloudAction: AiChatTurnAction | null =
271
+ !text || readOnly
272
+ ? null
273
+ : {
274
+ key: 'read-aloud',
275
+ label: reading ? t('chat.stopReading') : t('chat.readAloud'),
276
+ icon: reading ? RiStopFill : RiVolumeUpLine,
277
+ active: reading,
278
+ disabled: readAloudBlocked && !reading,
279
+ onPress: () => actions.readAloud(message.id, text, storedIn),
280
+ };
281
+ const regenerateAction: AiChatTurnAction | null =
282
+ onRegenerate === undefined || message.isStreaming || isLoading || readOnly
283
+ ? null
284
+ : {
285
+ key: 'regenerate',
286
+ label: t('chat.regenerate'),
287
+ icon: RiRefreshLine,
288
+ onPress: () => onRegenerate(message.id),
289
+ };
290
+ /** Under a reply, after Bloom's like / dislike / copy. */
291
+ const replyActions = [readAloudAction, regenerateAction].filter(isPresent);
292
+ const hasFeedback = !message.isStreaming && text.length > 0 && !readOnly;
293
+ /** The long-press menu: everything the rows offer, likes included. */
294
+ const menuActions: AiChatTurnAction[] = hasFeedback
295
+ ? [
296
+ readAloudAction,
297
+ copyAction,
298
+ regenerateAction,
299
+ {
300
+ key: 'like',
301
+ label: t('chat.like'),
302
+ icon: RiThumbUpLine,
303
+ onPress: () => actions.vote(message.id, 'up', storedIn),
304
+ },
305
+ {
306
+ key: 'dislike',
307
+ label: t('chat.dislike'),
308
+ icon: RiThumbDownLine,
309
+ onPress: () => actions.vote(message.id, 'down', storedIn),
310
+ },
311
+ ].filter(isPresent)
312
+ : [];
313
+
314
+ const { openThought } = actions;
315
+ const viewDetails = openThought && (() => openThought(message.id, 'steps'));
316
+ /** A consulted agent's reply is drawn from its first frame: its identity is where it joins from. */
317
+ const showsContent =
318
+ agent !== undefined ||
319
+ text.length > 0 ||
320
+ hasWorkLog ||
321
+ toolCards.length > 0 ||
322
+ rendered.length > 0 ||
323
+ (message.isStreaming === true && message.source === 'voice');
324
+ /** Its avatar is the source of the flight into the header while it is answering. */
325
+ const agentLive = agent !== undefined && message.isStreaming === true;
326
+ const agentAnchor = agent ? ANCHORS.reply(agent.id) : undefined;
327
+
328
+ return (
329
+ <>
330
+ {/* Plan Preview — shown before tool execution */}
331
+ {plan && (
332
+ <MessageBlockBoundary>
333
+ <PlanPreviewCard steps={plan.steps} />
334
+ </MessageBlockBoundary>
335
+ )}
336
+
337
+ {/* The template's steps: Bloom's AgentProgress, driven by the tool calls as the runtime
338
+ reports them. Web calls are left to the WebSearch trail inside the turn, which streams
339
+ them itself. */}
340
+ {taskInvocations.length === 0 || !turnWorking ? null : (
341
+ <AgentProgress
342
+ steps={taskInvocations.map((call) => getToolPillLabel(call.toolName, t))}
343
+ completedCount={taskInvocations.filter((call) => call.state === 'result').length}
344
+ />
345
+ )}
346
+
347
+ {/* `isStreaming` opens the block for VOICE only: a voice row shows its cursor before it has
348
+ words, while a text turn's placeholder — stamped `isStreaming` too — is the thinking
349
+ indicator's until its first token arrives. */}
350
+ {showsContent && (
351
+ <View className="w-full flex-col">
352
+ {agent && agentAnchor ? (
353
+ <View className="mb-0.5">
354
+ <AgentReplyIdentity
355
+ character={readAgentCharacter(agent.character)}
356
+ name={agent.name}
357
+ thinking={agentLive && text.length === 0}
358
+ workingKey={agent.id}
359
+ avatarRef={agentLive && flights ? flights.anchor(agentAnchor) : undefined}
360
+ avatarHidden={agentLive && flights ? flights.isLeaving(agentAnchor) : false}
361
+ />
362
+ </View>
363
+ ) : null}
364
+ {/* Bloom's reply: its reveal and its own feedback row (like / dislike / copy), then read
365
+ aloud and regenerate. Copy confirms only what the clipboard did. */}
366
+ <TurnMenu actions={menuActions} label={t('chat.turnActions')}>
367
+ <AiChatAssistantMessage
368
+ animate={isNew}
369
+ feedback={hasFeedback}
370
+ feedbackProps={{
371
+ onLike: () => actions.vote(message.id, 'up', storedIn),
372
+ onDislike: () => actions.vote(message.id, 'down', storedIn),
373
+ onCopy: () => actions.copy(text),
374
+ actions: replyActions,
375
+ labels: {
376
+ like: t('chat.like'),
377
+ dislike: t('chat.dislike'),
378
+ copy: t('chat.copy'),
379
+ copied: t('chat.copied'),
380
+ },
381
+ }}
382
+ >
383
+ {workInvocations.length === 0 || turnWorking ? null : (
384
+ <TurnStatusLine
385
+ label={
386
+ workStartedAt !== null && workEndedAt !== null
387
+ ? t('thought.workedFor', {
388
+ elapsed: formatElapsed(workEndedAt - workStartedAt),
389
+ })
390
+ : t('thought.worked')
391
+ }
392
+ hint={t('thought.viewDetails')}
393
+ onPress={viewDetails}
394
+ />
395
+ )}
396
+ {/* A turn that reasoned without tools: its reasoning, a press away. */}
397
+ {workInvocations.length > 0 || !message.thinking || turnWorking ? null : (
398
+ <TurnStatusLine
399
+ label={t('thought.reasoning')}
400
+ hint={t('thought.viewDetails')}
401
+ onPress={viewDetails}
402
+ />
403
+ )}
404
+ {taskLog === null ? null : (
405
+ <TaskList
406
+ tasks={taskLog.tasks}
407
+ revealed={taskLog.revealed}
408
+ collapseOnComplete="all"
409
+ working={false}
410
+ />
411
+ )}
412
+ {webLog.steps.length === 0 ? null : (
413
+ <WebSearch
414
+ steps={webLog.steps}
415
+ revealed={webLog.revealed}
416
+ working={turnWorking ? t('chat.working') : false}
417
+ labels={{ sources: t('chat.sources') }}
418
+ />
419
+ )}
420
+ {rendered.map(({ key, node }) => (
421
+ <MessageBlockBoundary key={key}>{node}</MessageBlockBoundary>
422
+ ))}
423
+ {/* Each card in its own boundary: `cardOf` checks the card's NAME, and its data is
424
+ cast unchecked. */}
425
+ {toolCards.map(({ key, card }) => (
426
+ <MessageBlockBoundary key={key}>
427
+ <ToolResultCard card={card} />
428
+ </MessageBlockBoundary>
429
+ ))}
430
+ {questions.length === 0 ? null : (
431
+ <MessageBlockBoundary>
432
+ <AskedQuestions questions={questions} />
433
+ </MessageBlockBoundary>
434
+ )}
435
+ {agent && message.turnOutcome === 'failed' && text.length === 0 ? (
436
+ <Text className="text-sm text-muted-foreground">
437
+ {t('chat.consultFailed', { name: agent.name })}
438
+ </Text>
439
+ ) : null}
440
+ {message.source === 'voice' ? (
441
+ <Text className="text-base leading-7 text-foreground">
442
+ {text}
443
+ {message.isStreaming ? '▌' : ''}
444
+ </Text>
445
+ ) : (
446
+ <ReplyMarkdown
447
+ content={text}
448
+ toolInvocations={message.toolInvocations}
449
+ researchSources={message.researchProgress?.sources}
450
+ />
451
+ )}
452
+ </AiChatAssistantMessage>
453
+ </TurnMenu>
454
+ </View>
455
+ )}
456
+ </>
457
+ );
458
+ }
@@ -0,0 +1,129 @@
1
+ import type { ThreadSearchHit } from '../../runtime/thread/thread-history';
2
+ import { useThreadSearch } from '../../runtime/use-thread-search';
3
+ import { useLabels } from '../../lib/labels';
4
+ import { Button } from '@oxy.so/bloom/button';
5
+ import { Card, CardBody } from '@oxy.so/bloom/card';
6
+ import { RiCloseLine } from '@oxy.so/bloom/icons/RiCloseLine';
7
+ import { Item } from '@oxy.so/bloom/item';
8
+ import { Search } from '@oxy.so/bloom/search';
9
+ import { Muted } from '@oxy.so/bloom/typography';
10
+ import { useState } from 'react';
11
+ import { ScrollView, View } from 'react-native';
12
+
13
+ /**
14
+ * Searching what was said in this thread, across every conversation in it.
15
+ *
16
+ * Not the app-wide palette on ⌘K, and the difference is the point: that one
17
+ * finds a chat, this one finds a SENTENCE, and it looks past the stretch on
18
+ * screen into every one before it. `GET /conversations/:id/…` could not serve
19
+ * this — it would stop at the current conversation, which is the one thing the
20
+ * reader can already see.
21
+ *
22
+ * Each result carries the cursor that opens the thread around it. A result you
23
+ * can read and not reach is a wall, so `onJump` is not an extra: it is what the
24
+ * hit is for.
25
+ *
26
+ * Bloom's `Search` for the field and an `Item` per hit, on a `Card` floating
27
+ * over the top of the thread in its 768 column: the card is the surface, so
28
+ * nothing here paints one of its own.
29
+ */
30
+
31
+ interface ThreadSearchProps {
32
+ /** The handle whose thread is searched. */
33
+ handle: string;
34
+ /** Open the thread around this message. */
35
+ onJump: (hit: ThreadSearchHit) => void;
36
+ onClose: () => void;
37
+ }
38
+
39
+ /** The day a hit was written, in the reader's own locale and timezone. */
40
+ function hitDay(createdAt: string, locale: string): string {
41
+ return new Date(createdAt).toLocaleDateString(locale, {
42
+ day: 'numeric',
43
+ month: 'short',
44
+ year: 'numeric',
45
+ });
46
+ }
47
+
48
+ export const ThreadSearch = ({ handle, onJump, onClose }: ThreadSearchProps) => {
49
+ const { t, locale } = useLabels();
50
+ const [query, setQuery] = useState('');
51
+ const { data: hits, isFetching, isError } = useThreadSearch(handle, query);
52
+
53
+ /**
54
+ * Told apart deliberately: nothing typed is not a search that found nothing.
55
+ * One is an invitation and the other is an answer, and saying "no results"
56
+ * over an empty field answers a question nobody asked.
57
+ */
58
+ const asked = query.trim().length > 0;
59
+ const found = hits ?? [];
60
+
61
+ return (
62
+ <View className="absolute inset-x-0 top-0 z-20 px-4 pt-4" pointerEvents="box-none">
63
+ <View className="w-full max-w-[768px] self-center">
64
+ <Card elevation="m">
65
+ <CardBody>
66
+ <View className="flex-row items-center gap-2">
67
+ <View className="flex-1">
68
+ <Search
69
+ label={t('chat.searchThreadPlaceholder')}
70
+ value={query}
71
+ onChangeText={setQuery}
72
+ onClearText={() => setQuery('')}
73
+ autoFocus
74
+ returnKeyType="search"
75
+ />
76
+ </View>
77
+ <Button
78
+ size="md"
79
+ appearance="plain"
80
+ tone="neutral"
81
+ iconOnly
82
+ leadingIcon={RiCloseLine}
83
+ accessibilityLabel={t('common.close')}
84
+ onPress={onClose}
85
+ />
86
+ </View>
87
+
88
+ {!asked ? (
89
+ <View className="px-1 py-4">
90
+ <Muted>{t('chat.searchThreadHint')}</Muted>
91
+ </View>
92
+ ) : isError ? (
93
+ <View className="px-1 py-4">
94
+ <Muted>{t('chat.searchThreadFailed')}</Muted>
95
+ </View>
96
+ ) : found.length === 0 ? (
97
+ <View className="px-1 py-4">
98
+ <Muted>
99
+ {isFetching ? t('chat.searchThreadSearching') : t('chat.searchThreadEmpty')}
100
+ </Muted>
101
+ </View>
102
+ ) : (
103
+ <ScrollView
104
+ // A height in px, not a percentage of a parent that has none:
105
+ // measured in Chromium, the list otherwise sized itself to its
106
+ // content and spilled the results over the thread behind it.
107
+ className="mt-2 max-h-96"
108
+ keyboardShouldPersistTaps="handled"
109
+ >
110
+ {found.map((hit) => (
111
+ <Item
112
+ // The cursor, never `messageId`: a message the server wrote
113
+ // has no client id, and `null` is not a key.
114
+ key={hit.cursor}
115
+ title={hit.snippet}
116
+ subtitle={`${t(
117
+ hit.role === 'user' ? 'chat.searchThreadYou' : 'chat.searchThreadAgent',
118
+ )} · ${hitDay(hit.createdAt, locale)}`}
119
+ onPress={() => onJump(hit)}
120
+ />
121
+ ))}
122
+ </ScrollView>
123
+ )}
124
+ </CardBody>
125
+ </Card>
126
+ </View>
127
+ </View>
128
+ );
129
+ };