@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,417 @@
1
+ import {
2
+ type AuditEntry,
3
+ type Message,
4
+ type Source,
5
+ type ThoughtStep,
6
+ type TurnLifecycle,
7
+ auditText,
8
+ buildAuditTimeline,
9
+ buildSteps,
10
+ extractOutputs,
11
+ extractSources,
12
+ isLiveLifecycle,
13
+ mergeSources,
14
+ researchSourcesToSources,
15
+ toolCallStatus,
16
+ toolCallText,
17
+ toolLabel,
18
+ turnLifecycle,
19
+ } from '../../runtime/model';
20
+ import { restoreOpenerFocus } from '../../lib/focus-return';
21
+ import { useLabels } from '../../lib/labels';
22
+ import { getToolIcon } from '../../lib/tool-icons';
23
+ import { type ThoughtTab, useThoughtStore } from '../../runtime/thought-store';
24
+ import { FilesAndSources, type OutputActions } from './FilesAndSources';
25
+ import { ToolStep } from './ToolStep';
26
+ import {
27
+ AgentLogRow,
28
+ AgentLogShimmerText,
29
+ AgentLogWorkingRow,
30
+ useAgentLogMotion,
31
+ } from '@oxy.so/bloom/agent-log';
32
+ import { useAiChatShell } from '@oxy.so/bloom/ai-chat';
33
+ import { Button } from '@oxy.so/bloom/button';
34
+ import { EmptyState } from '@oxy.so/bloom/empty-state';
35
+ import { RiCheckboxCircleLine } from '@oxy.so/bloom/icons/RiCheckboxCircleLine';
36
+ import { RiCloseCircleLine } from '@oxy.so/bloom/icons/RiCloseCircleLine';
37
+ import { RiCloseLine } from '@oxy.so/bloom/icons/RiCloseLine';
38
+ import { RiForbidLine } from '@oxy.so/bloom/icons/RiForbidLine';
39
+ import { Loading } from '@oxy.so/bloom/loading';
40
+ import { Tabs, TabsTrigger } from '@oxy.so/bloom/tabs';
41
+ import { useTheme } from '@oxy.so/bloom/theme';
42
+ import { Muted, Text } from '@oxy.so/bloom/typography';
43
+ import * as WebBrowser from 'expo-web-browser';
44
+ import { useCallback, useMemo, useState } from 'react';
45
+ import { Platform, ScrollView, View } from 'react-native';
46
+
47
+ /** One array for "nothing selected", so the memos below hold across renders. */
48
+ const NO_MESSAGES: Message[] = [];
49
+
50
+ function TabToggle({ value, onChange }: { value: ThoughtTab; onChange: (t: ThoughtTab) => void }) {
51
+ const { t } = useLabels();
52
+ const tabs: { key: ThoughtTab; label: string }[] = [
53
+ { key: 'steps', label: t('thought.steps') },
54
+ { key: 'sources', label: t('thought.sources') },
55
+ { key: 'activity', label: t('thought.activity') },
56
+ ];
57
+
58
+ return (
59
+ <Tabs variant="pill" value={value} onValueChange={(next) => onChange(next as ThoughtTab)}>
60
+ {tabs.map((tab) => (
61
+ <TabsTrigger key={tab.key} value={tab.key} label={tab.label} />
62
+ ))}
63
+ </Tabs>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * The panel's own wording for every step that is not a tool. Tool steps keep
69
+ * the label the registry gave them; these are lifecycle words, and they are
70
+ * translated here rather than in `buildSteps` so the pure function stays free
71
+ * of the locale.
72
+ */
73
+ const STEP_LABEL_KEYS: Record<Exclude<ThoughtStep['type'], 'tool'>, string> = {
74
+ thinking: 'thought.thinking',
75
+ writing: 'thought.writing',
76
+ done: 'thought.done',
77
+ failed: 'thought.failed',
78
+ cancelled: 'thought.cancelled',
79
+ };
80
+
81
+ /** A step that is a phase or an ending rather than a tool call. */
82
+ type PhaseStep = Omit<ThoughtStep, 'type'> & {
83
+ type: Exclude<ThoughtStep['type'], 'tool'>;
84
+ };
85
+
86
+ /** The glyph of an ending; a phase that is working has none — the log's own shimmer says so. */
87
+ function PhaseIcon({ type }: { type: PhaseStep['type'] }) {
88
+ const { colors } = useTheme();
89
+ if (type === 'done') return <RiCheckboxCircleLine size="sm" fill={colors.success} />;
90
+ if (type === 'failed') return <RiCloseCircleLine size="sm" fill={colors.error} />;
91
+ if (type === 'cancelled') return <RiForbidLine size="sm" fill={colors.textSecondary} />;
92
+ return null;
93
+ }
94
+
95
+ /** A phase or ending as one line of the log. Not a control: there is nothing to expand. */
96
+ function PhaseRow({ step, isActive }: { step: PhaseStep; isActive: boolean }) {
97
+ const { t } = useLabels();
98
+ const label = t(STEP_LABEL_KEYS[step.type]);
99
+ const ending = step.type === 'done' || step.type === 'failed';
100
+ return (
101
+ <View className="flex-row items-center gap-1.5 py-1" accessible accessibilityLabel={label}>
102
+ <PhaseIcon type={step.type} />
103
+ <Text variant={ending ? 'body-medium' : 'body-regular'}>
104
+ {isActive ? <AgentLogShimmerText>{label}</AgentLogShimmerText> : label}
105
+ </Text>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * The turn's steps as Bloom's agent log: each tool call a `ToolStep` whose
112
+ * input and output open in place, the phases and the ending as plain lines,
113
+ * and — while the turn is thinking or writing — the log's working row at the
114
+ * tail. Rows land as the runtime reports them, so the log is driven by real
115
+ * events rather than a ticker.
116
+ *
117
+ * A tool step spins on ITS OWN state while the turn runs — not on being last
118
+ * — so a finished tool after it cannot hide that it is still going, and a
119
+ * call that never returned in a turn that is over sits still and says so.
120
+ */
121
+ function StepsTab({ steps, lifecycle }: { steps: ThoughtStep[]; lifecycle: TurnLifecycle }) {
122
+ const { t } = useLabels();
123
+ const { colors } = useTheme();
124
+ const reduce = useAgentLogMotion();
125
+ const live = isLiveLifecycle(lifecycle);
126
+ const [openRows, setOpenRows] = useState<Record<string, boolean>>({});
127
+
128
+ if (steps.length === 0) {
129
+ return <EmptyState variant="compact" description={t('thought.noSteps')} />;
130
+ }
131
+
132
+ const last = steps[steps.length - 1];
133
+ const tail = live && (last.type === 'thinking' || last.type === 'writing') ? last : null;
134
+ const rows = tail === null ? steps : steps.slice(0, -1);
135
+
136
+ return (
137
+ <View>
138
+ <View role="list">
139
+ {rows.map((step, index) => {
140
+ const first = index === 0;
141
+ const isLastRow = index === rows.length - 1;
142
+ if (step.type !== 'tool') {
143
+ return (
144
+ <AgentLogRow
145
+ key={`${step.type}-${index}`}
146
+ first={first}
147
+ last={isLastRow}
148
+ reduce={reduce}
149
+ >
150
+ <PhaseRow
151
+ step={{ ...step, type: step.type }}
152
+ isActive={live && isLastRow && tail === null}
153
+ />
154
+ </AgentLogRow>
155
+ );
156
+ }
157
+ const inv = step.invocation;
158
+ const key = inv?.toolCallId || `tool-${index}`;
159
+ const ToolIcon = getToolIcon(step.toolName || '');
160
+ const status =
161
+ inv === undefined ? (live ? 'running' : 'done') : toolCallStatus(inv, live);
162
+ return (
163
+ <AgentLogRow key={key} first={first} last={isLastRow} reduce={reduce}>
164
+ <ToolStep
165
+ title={step.toolName === undefined ? step.label : toolLabel(step.toolName, t)}
166
+ status={status}
167
+ icon={
168
+ <ToolIcon
169
+ width={14}
170
+ height={14}
171
+ fill={status === 'error' ? colors.error : colors.text}
172
+ />
173
+ }
174
+ input={inv === undefined ? '' : toolCallText(inv.args)}
175
+ output={inv === undefined ? '' : toolCallText(inv.result)}
176
+ sources={step.sources}
177
+ expanded={openRows[key] === true}
178
+ onToggle={() => setOpenRows((open) => ({ ...open, [key]: open[key] !== true }))}
179
+ />
180
+ </AgentLogRow>
181
+ );
182
+ })}
183
+ </View>
184
+ {tail === null ? null : (
185
+ <AgentLogWorkingRow
186
+ label={t(STEP_LABEL_KEYS[tail.type as PhaseStep['type']])}
187
+ reduce={reduce}
188
+ />
189
+ )}
190
+ </View>
191
+ );
192
+ }
193
+
194
+ /** The conversation's audit trail as one agent log, oldest first. */
195
+ function ActivityTab({ entries }: { entries: AuditEntry[] }) {
196
+ const { t } = useLabels();
197
+ const { colors } = useTheme();
198
+ const reduce = useAgentLogMotion();
199
+ if (entries.length === 0) {
200
+ return <EmptyState variant="compact" description={t('thought.noActivity')} />;
201
+ }
202
+
203
+ return (
204
+ <View role="list">
205
+ {entries.map((entry, index) => (
206
+ <AgentLogRow
207
+ key={entry.id}
208
+ first={index === 0}
209
+ last={index === entries.length - 1}
210
+ reduce={reduce}
211
+ >
212
+ <View className="py-1">
213
+ <View className="flex-row items-center gap-1.5">
214
+ {entry.status === 'interrupted' ? (
215
+ <RiForbidLine size="xs" fill={colors.textSecondary} />
216
+ ) : null}
217
+ <Text variant="body-regular" numberOfLines={1}>
218
+ {entry.status === 'in_progress' ? (
219
+ <AgentLogShimmerText>{auditText(entry.label, t)}</AgentLogShimmerText>
220
+ ) : (
221
+ auditText(entry.label, t)
222
+ )}
223
+ </Text>
224
+ </View>
225
+ {auditText(entry.description, t) ? (
226
+ <Muted numberOfLines={1}>{auditText(entry.description, t)}</Muted>
227
+ ) : null}
228
+ </View>
229
+ </AgentLogRow>
230
+ ))}
231
+ </View>
232
+ );
233
+ }
234
+
235
+ /**
236
+ * The panel has nothing to show for the selection, and the reason. Rendered
237
+ * in place of the tabs' content so the three tabs never each say "none" about
238
+ * a message whose data has simply not arrived.
239
+ */
240
+ function SelectionState({ status }: { status: 'loading' | 'failed' | 'gone' }) {
241
+ const { t } = useLabels();
242
+ const key =
243
+ status === 'loading'
244
+ ? 'thought.loading'
245
+ : status === 'failed'
246
+ ? 'thought.loadFailed'
247
+ : 'thought.messageGone';
248
+ return (
249
+ <EmptyState
250
+ variant="compact"
251
+ illustration={status === 'loading' ? <Loading size="sm" iconSize={24} /> : undefined}
252
+ description={t(key)}
253
+ />
254
+ );
255
+ }
256
+
257
+ /** Open a source where the platform reads the web. */
258
+ function openSource(source: Source): void {
259
+ if (Platform.OS === 'web') {
260
+ window.open(source.url, '_blank', 'noopener,noreferrer');
261
+ } else {
262
+ void WebBrowser.openBrowserAsync(source.url);
263
+ }
264
+ }
265
+
266
+ export interface ThoughtPanelProps {
267
+ /**
268
+ * The panel was closed (its button): the selection is already cleared and focus is back where
269
+ * it came from; the host hides the place it put the panel in.
270
+ */
271
+ onClose: () => void;
272
+ /** Where a generated file opens (the host's canvas). Omitted, outputs are listed and never pressable. */
273
+ outputActions?: OutputActions;
274
+ }
275
+
276
+ /**
277
+ * The execution panel: what the selected turn did, produced and read.
278
+ *
279
+ * The thread selects the turn (`useThoughtStore`) and the host decides where this panel goes
280
+ * (`AliaRuntimeHost.showThought`); closing hands focus back to whatever opened it.
281
+ *
282
+ * Everything shown comes from the selection's own scope and the lifecycle the
283
+ * runtime stamps (#542, #543): a message not yet loaded is "loading", a load
284
+ * that failed says so, a message cut out since is "gone", and none of them is
285
+ * ever "no steps".
286
+ */
287
+ export function ThoughtPanel({ onClose, outputActions }: ThoughtPanelProps) {
288
+ const { colors } = useTheme();
289
+ const shell = useAiChatShell();
290
+ const { t } = useLabels();
291
+ const selection = useThoughtStore((s) => s.selection);
292
+ const setActiveTab = useThoughtStore((s) => s.setTab);
293
+ const closeSelection = useThoughtStore((s) => s.close);
294
+ const activeTab = selection?.tab ?? 'steps';
295
+ const thoughtMessageId = selection?.messageId;
296
+ const scope = selection?.scope ?? null;
297
+
298
+ /**
299
+ * The scope's messages are the conversation the selection was made in and
300
+ * nothing else — a screen showing another conversation never gets to write
301
+ * here (see `syncThoughtScope`), so a miss below is about THIS conversation:
302
+ * still loading, failed to load, or a message that has since been cut out
303
+ * of it by an edit or a regenerate.
304
+ */
305
+ const messages = scope?.messages ?? NO_MESSAGES;
306
+ const message = useMemo(
307
+ () => messages.find((m) => m.id === thoughtMessageId),
308
+ [messages, thoughtMessageId],
309
+ );
310
+
311
+ const lifecycle = useMemo<TurnLifecycle>(() => {
312
+ if (!message) return 'completed';
313
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
314
+ return turnLifecycle(message, {
315
+ isLoading: scope?.isLoading ?? false,
316
+ isLastAssistant: lastAssistant?.id === message.id,
317
+ failedTurn: scope?.failedTurn ?? null,
318
+ });
319
+ }, [message, messages, scope?.isLoading, scope?.failedTurn]);
320
+
321
+ const steps = useMemo(
322
+ () => (message ? buildSteps(message, lifecycle) : []),
323
+ [message, lifecycle],
324
+ );
325
+
326
+ // A research answer's sources come from its persisted `deepResearch`
327
+ // invocation after a reload and from the live progress event before one;
328
+ // both are read so the tab is the same either way.
329
+ const sources = useMemo(
330
+ () =>
331
+ message
332
+ ? mergeSources(
333
+ extractSources(message.toolInvocations),
334
+ researchSourcesToSources(message.researchProgress?.sources),
335
+ )
336
+ : [],
337
+ [message],
338
+ );
339
+
340
+ const outputs = useMemo(
341
+ () => (message ? extractOutputs(message.toolInvocations) : []),
342
+ [message],
343
+ );
344
+
345
+ const auditEntries = useMemo(
346
+ () =>
347
+ buildAuditTimeline(messages, {
348
+ isLoading: scope?.isLoading ?? false,
349
+ failedTurn: scope?.failedTurn ?? null,
350
+ }),
351
+ [messages, scope?.isLoading, scope?.failedTurn],
352
+ );
353
+
354
+ const close = useCallback(() => {
355
+ closeSelection();
356
+ restoreOpenerFocus();
357
+ onClose();
358
+ }, [closeSelection, onClose]);
359
+
360
+ /**
361
+ * Which of the three things a missing message means. A message that IS
362
+ * here but whose conversation is still loading is shown as it is — the
363
+ * live turn is exactly that — so only a miss consults the status.
364
+ */
365
+ const emptyState: 'loading' | 'failed' | 'gone' | null =
366
+ message !== undefined
367
+ ? null
368
+ : scope === null || scope.status === 'loading'
369
+ ? 'loading'
370
+ : scope.status === 'failed'
371
+ ? 'failed'
372
+ : 'gone';
373
+
374
+ const content =
375
+ emptyState !== null ? (
376
+ <SelectionState status={emptyState} />
377
+ ) : activeTab === 'steps' ? (
378
+ <StepsTab steps={steps} lifecycle={lifecycle} />
379
+ ) : activeTab === 'sources' ? (
380
+ <FilesAndSources
381
+ outputs={outputs}
382
+ sources={sources}
383
+ outputActions={outputActions}
384
+ onOpenSource={openSource}
385
+ />
386
+ ) : (
387
+ <ActivityTab entries={auditEntries} />
388
+ );
389
+
390
+ return (
391
+ <View className="min-h-0 flex-1 gap-2.5 pt-2">
392
+ <View className="h-[30px] flex-row items-center justify-between">
393
+ <View className="min-w-0 shrink">
394
+ <TabToggle value={activeTab} onChange={setActiveTab} />
395
+ </View>
396
+ {!shell?.compact && (
397
+ <Button
398
+ appearance="plain"
399
+ tone="neutral"
400
+ size="xs"
401
+ accessibilityLabel={t('common.close')}
402
+ onPress={close}
403
+ icon={<RiCloseLine size="sm" fill={colors.textSecondary} />}
404
+ />
405
+ )}
406
+ </View>
407
+
408
+ <ScrollView
409
+ className="flex-1 px-4"
410
+ contentContainerClassName="pb-4"
411
+ showsVerticalScrollIndicator={false}
412
+ >
413
+ {content}
414
+ </ScrollView>
415
+ </View>
416
+ );
417
+ }
@@ -0,0 +1,141 @@
1
+ import { useLabels } from '../../lib/labels';
2
+ import type { Source, ToolCallStatus } from '../../runtime/model';
3
+ import { Chip } from '@oxy.so/bloom/chip';
4
+ import { CodeBlock } from '@oxy.so/bloom/code';
5
+ import { RiArrowDownSLine } from '@oxy.so/bloom/icons/RiArrowDownSLine';
6
+ import { RiArrowRightSLine } from '@oxy.so/bloom/icons/RiArrowRightSLine';
7
+ import { RiGlobalLine } from '@oxy.so/bloom/icons/RiGlobalLine';
8
+ import { Item } from '@oxy.so/bloom/item';
9
+ import { Loading } from '@oxy.so/bloom/loading';
10
+ import { useTheme } from '@oxy.so/bloom/theme';
11
+ import { Muted } from '@oxy.so/bloom/typography';
12
+ import * as Clipboard from 'expo-clipboard';
13
+ import type { ReactNode } from 'react';
14
+ import { ScrollView, View } from 'react-native';
15
+
16
+ /**
17
+ * One execution row: a tool call, its state, and — expanded — what went in
18
+ * and what came out.
19
+ *
20
+ * The row is Bloom's `Item` as a disclosure (`expanded`), the two blocks are
21
+ * Bloom's `CodeBlock`, and a search step's domains are `Chip`s. The expanded
22
+ * block is MOUNTED only while expanded, so nothing inside a collapsed row can
23
+ * hold focus.
24
+ *
25
+ * Status is read from the lifecycle, never from the row being last: a call
26
+ * that never returned in a turn that is over reads as interrupted, and a call
27
+ * whose result carries an error reads as an error, so an execution list never
28
+ * says "done" about work that did not finish.
29
+ */
30
+ export interface ToolStepProps {
31
+ title: string;
32
+ /** The call's argument summary, printed muted under the title. */
33
+ description?: string;
34
+ status: ToolCallStatus;
35
+ /** The tool's icon, drawn in the leading slot while the call is not running. */
36
+ icon: ReactNode;
37
+ /** The call's arguments as text; empty hides the block. */
38
+ input: string;
39
+ /** The call's result as text; empty hides the block. */
40
+ output: string;
41
+ /** The web sources a search step returned, drawn as domain chips under the block. */
42
+ sources?: Source[];
43
+ expanded: boolean;
44
+ onToggle: () => void;
45
+ }
46
+
47
+ /** How many domain chips a search step shows before it counts the rest. */
48
+ const CHIP_LIMIT = 3;
49
+
50
+ async function copy(code: string) {
51
+ await Clipboard.setStringAsync(code);
52
+ }
53
+
54
+ export function ToolStep({
55
+ title,
56
+ description,
57
+ status,
58
+ icon,
59
+ input,
60
+ output,
61
+ sources,
62
+ expanded,
63
+ onToggle,
64
+ }: ToolStepProps) {
65
+ const { t } = useLabels();
66
+ const { colors } = useTheme();
67
+ const chips = sources ? sources.slice(0, CHIP_LIMIT) : [];
68
+ const extra = sources ? Math.max(0, sources.length - CHIP_LIMIT) : 0;
69
+ const statusWord =
70
+ status === 'error'
71
+ ? t('thought.failed')
72
+ : status === 'interrupted'
73
+ ? t('thought.cancelled')
74
+ : null;
75
+ const Chevron = expanded ? RiArrowDownSLine : RiArrowRightSLine;
76
+ const labels = { copy: t('panels.code.copy'), copied: t('panels.code.copied') };
77
+
78
+ return (
79
+ <View>
80
+ <Item
81
+ density="compact"
82
+ leading={status === 'running' ? <Loading size="sm" iconSize={14} /> : icon}
83
+ title={title}
84
+ subtitle={description}
85
+ destructive={status === 'error'}
86
+ trailing={
87
+ <View className="flex-row items-center gap-1.5">
88
+ {statusWord === null ? null : <Muted>{statusWord}</Muted>}
89
+ <Chevron size="sm" fill={colors.textSecondary} />
90
+ </View>
91
+ }
92
+ expanded={expanded}
93
+ onPress={onToggle}
94
+ accessibilityLabel={statusWord === null ? title : `${title}, ${statusWord}`}
95
+ />
96
+
97
+ {expanded ? (
98
+ <View className="gap-2 pb-2">
99
+ {input.length > 0 || output.length > 0 ? (
100
+ <ScrollView
101
+ className="max-h-[200px]"
102
+ contentContainerClassName="gap-2"
103
+ nestedScrollEnabled
104
+ >
105
+ {input.length > 0 ? (
106
+ <CodeBlock
107
+ code={input}
108
+ filename={t('thought.input')}
109
+ lineNumbers={false}
110
+ wrap
111
+ onCopy={copy}
112
+ labels={labels}
113
+ />
114
+ ) : null}
115
+ {output.length > 0 ? (
116
+ <CodeBlock
117
+ code={output}
118
+ filename={t('thought.output')}
119
+ lineNumbers={false}
120
+ wrap
121
+ onCopy={copy}
122
+ labels={labels}
123
+ />
124
+ ) : null}
125
+ </ScrollView>
126
+ ) : null}
127
+ {chips.length > 0 ? (
128
+ <View className="flex-row flex-wrap gap-1.5">
129
+ {chips.map((source) => (
130
+ <Chip key={source.url} size="sm" leadingIcon={RiGlobalLine}>
131
+ {source.domain}
132
+ </Chip>
133
+ ))}
134
+ {extra > 0 ? <Chip size="sm">{`+ ${extra}`}</Chip> : null}
135
+ </View>
136
+ ) : null}
137
+ </View>
138
+ ) : null}
139
+ </View>
140
+ );
141
+ }
@@ -0,0 +1,125 @@
1
+ import type { AgentActivityState, PlanItem } from '../../runtime/thread/agent-activity';
2
+ import { useLabels } from '../../lib/labels';
3
+ import {
4
+ Accordion,
5
+ AccordionContent,
6
+ AccordionItem,
7
+ AccordionTrigger,
8
+ } from '@oxy.so/bloom/accordion';
9
+ import { Admonition } from '@oxy.so/bloom/admonition';
10
+ import { Badge } from '@oxy.so/bloom/badge';
11
+ import { Card, CardBody, CardHeader } from '@oxy.so/bloom/card';
12
+ import { RiAlertLine } from '@oxy.so/bloom/icons/RiAlertLine';
13
+ import { RiCheckboxCircleLine } from '@oxy.so/bloom/icons/RiCheckboxCircleLine';
14
+ import { RiCloseCircleLine } from '@oxy.so/bloom/icons/RiCloseCircleLine';
15
+ import { Item } from '@oxy.so/bloom/item';
16
+ import { Muted, Text } from '@oxy.so/bloom/typography';
17
+ import React, { useState } from 'react';
18
+ import { View } from 'react-native';
19
+
20
+ /**
21
+ * AgentResultCard — the summary an agent run leaves in the chat when it ends.
22
+ *
23
+ * A Bloom `Card`: the outcome as a `Badge` beside the heading, the run's
24
+ * numbers in one secondary line, an error as an `Admonition`, and the plan as
25
+ * an `Accordion` section.
26
+ */
27
+
28
+ interface AgentResultCardProps {
29
+ activity: AgentActivityState;
30
+ agentName?: string;
31
+ }
32
+
33
+ /** `42s`, `3m 5s`, `1h 2m` — how long the run took. */
34
+ export function formatRunDuration(startedAt: number | null, now: number): string {
35
+ if (!startedAt) return '--';
36
+ const seconds = Math.max(0, Math.floor((now - startedAt) / 1000));
37
+ if (seconds < 60) return `${seconds}s`;
38
+ const minutes = Math.floor(seconds / 60);
39
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
40
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
41
+ }
42
+
43
+ const PLAN_ICON = {
44
+ completed: RiCheckboxCircleLine,
45
+ blocked: RiAlertLine,
46
+ } as const;
47
+
48
+ function PlanItemRow({ item }: { item: PlanItem }) {
49
+ const Icon = PLAN_ICON[item.status as keyof typeof PLAN_ICON] ?? RiCloseCircleLine;
50
+ return <Item density="compact" role="listitem" leading={<Icon size="sm" />} title={item.text} />;
51
+ }
52
+
53
+ export const AgentResultCard = React.memo(function AgentResultCard({
54
+ activity,
55
+ agentName,
56
+ }: AgentResultCardProps) {
57
+ const { t } = useLabels();
58
+ const [open, setOpen] = useState<string[]>([]);
59
+ // Measured once, when the card first draws the finished run.
60
+ const [now] = useState(() => Date.now());
61
+
62
+ const { plan, isComplete, hasError, lastError, eventCount, startedAt } = activity;
63
+ const isSuccess = isComplete && !hasError;
64
+
65
+ const stats = [
66
+ formatRunDuration(startedAt, now),
67
+ t('chat.agentRun.steps', { count: String(eventCount) }),
68
+ plan
69
+ ? t('chat.agentRun.planItems', { done: String(plan.completed), total: String(plan.total) })
70
+ : null,
71
+ activity.creditsCharged != null
72
+ ? t('chat.agentRun.credits', { count: String(activity.creditsCharged) })
73
+ : null,
74
+ ].filter((part): part is string => part !== null);
75
+
76
+ return (
77
+ <Card>
78
+ <CardHeader>
79
+ <View className="flex-row items-center gap-2">
80
+ <Badge
81
+ variant="subtle"
82
+ color={isSuccess ? 'success' : hasError ? 'error' : 'default'}
83
+ content={t(
84
+ isSuccess
85
+ ? 'chat.agentRun.completed'
86
+ : hasError
87
+ ? 'chat.agentRun.failed'
88
+ : 'chat.agentRun.unknown',
89
+ )}
90
+ />
91
+ <View className="min-w-0 flex-1">
92
+ <Text variant="headline-semibold">
93
+ {t(isSuccess ? 'chat.agentRun.taskComplete' : 'chat.agentRun.taskFailed')}
94
+ </Text>
95
+ {agentName ? <Muted>{t('chat.agentRun.by', { name: agentName })}</Muted> : null}
96
+ </View>
97
+ </View>
98
+ </CardHeader>
99
+ <CardBody>
100
+ <View className="gap-3">
101
+ <Muted>{stats.join(' · ')}</Muted>
102
+
103
+ {hasError && lastError ? <Admonition type="error">{lastError}</Admonition> : null}
104
+
105
+ {plan && plan.items.length > 0 ? (
106
+ <Accordion
107
+ type="multiple"
108
+ value={open}
109
+ onValueChange={(next) => setOpen(Array.isArray(next) ? next : next ? [next] : [])}
110
+ >
111
+ <AccordionItem value="plan">
112
+ <AccordionTrigger>{t('chat.agentRun.planSummary')}</AccordionTrigger>
113
+ <AccordionContent>
114
+ {plan.items.map((item) => (
115
+ <PlanItemRow key={item.id} item={item} />
116
+ ))}
117
+ </AccordionContent>
118
+ </AccordionItem>
119
+ </Accordion>
120
+ ) : null}
121
+ </View>
122
+ </CardBody>
123
+ </Card>
124
+ );
125
+ });