@alia.onl/sdk 15.7.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 (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -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/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -1,31 +1,515 @@
1
- import type { ReactNode } from 'react';
2
- import { Markdown } from '@oxy.so/bloom/markdown';
1
+ import React, { useMemo, type ReactNode } from 'react';
2
+ import { View, Text, Platform, Linking } from 'react-native';
3
+ import { useTheme } from '@oxy.so/bloom/theme';
4
+ import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
5
+ import { CodeBlock } from '@oxy.so/bloom/code';
6
+ import { fontFamilies } from '@oxy.so/bloom/fonts';
7
+ import type { AliaColors } from '../theme';
3
8
 
4
- /** The body text of a reply: its bubble sets its padding from these. Bloom's `lg` ramp. */
5
- export const MARKDOWN_BODY = { fontSize: 16, lineHeight: 28 } as const;
9
+ // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
10
+ // The main app always passes resolved colors via the `colors` prop.
11
+ const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
12
+ const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
13
+ const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
6
14
 
15
+ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
16
+ const { text: textColor, muted, border, primary, mutedForeground } = colors;
17
+
18
+ /**
19
+ * Fenced and indented code: Bloom's `CodeBlock` unless the host brings a
20
+ * code card of its own.
21
+ */
22
+ const drawCodeBlock: RenderCodeBlock =
23
+ renderCodeBlock ?? ((code, language) => <CodeBlock code={code} language={language} />);
24
+ const codeRules = {
25
+ fence: (node: ASTNode) => (
26
+ <View key={node.key} style={{ marginVertical: 8 }}>
27
+ {drawCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
28
+ </View>
29
+ ),
30
+ code_block: (node: ASTNode) => (
31
+ <View key={node.key} style={{ marginVertical: 8 }}>
32
+ {drawCodeBlock(node.content.replace(/\n$/, ''), undefined)}
33
+ </View>
34
+ ),
35
+ };
36
+
37
+ return {
38
+ ...codeRules,
39
+ heading1: (node: ASTNode, children: ReactNode[]) => (
40
+ <Text
41
+ key={node.key}
42
+ style={{
43
+ marginBottom: 8,
44
+ marginTop: 12,
45
+ fontSize: 18,
46
+ fontWeight: '600',
47
+ lineHeight: 24,
48
+ color: textColor,
49
+ }}
50
+ >
51
+ {children}
52
+ </Text>
53
+ ),
54
+ heading2: (node: ASTNode, children: ReactNode[]) => (
55
+ <Text
56
+ key={node.key}
57
+ style={{
58
+ marginBottom: 8,
59
+ marginTop: 12,
60
+ ...HEADING_TEXT,
61
+ fontWeight: '600',
62
+ color: textColor,
63
+ }}
64
+ >
65
+ {children}
66
+ </Text>
67
+ ),
68
+ heading3: (node: ASTNode, children: ReactNode[]) => (
69
+ <Text
70
+ key={node.key}
71
+ style={{
72
+ marginBottom: 6,
73
+ marginTop: 8,
74
+ ...HEADING_TEXT,
75
+ fontWeight: '600',
76
+ color: textColor,
77
+ }}
78
+ >
79
+ {children}
80
+ </Text>
81
+ ),
82
+ heading4: (node: ASTNode, children: ReactNode[]) => (
83
+ <Text
84
+ key={node.key}
85
+ style={{
86
+ marginBottom: 6,
87
+ marginTop: 8,
88
+ ...HEADING_TEXT,
89
+ fontWeight: '500',
90
+ color: textColor,
91
+ }}
92
+ >
93
+ {children}
94
+ </Text>
95
+ ),
96
+ heading5: (node: ASTNode, children: ReactNode[]) => (
97
+ <Text
98
+ key={node.key}
99
+ style={{
100
+ marginBottom: 4,
101
+ marginTop: 8,
102
+ ...HEADING_TEXT,
103
+ fontWeight: '500',
104
+ color: textColor,
105
+ }}
106
+ >
107
+ {children}
108
+ </Text>
109
+ ),
110
+ heading6: (node: ASTNode, children: ReactNode[]) => (
111
+ <Text
112
+ key={node.key}
113
+ style={{
114
+ marginBottom: 4,
115
+ marginTop: 8,
116
+ ...HEADING_TEXT,
117
+ fontWeight: '500',
118
+ color: textColor,
119
+ }}
120
+ >
121
+ {children}
122
+ </Text>
123
+ ),
124
+ code: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
125
+ return parent.length > 1 ? (
126
+ <View
127
+ key={node.key}
128
+ style={{
129
+ marginVertical: 8,
130
+ overflow: 'hidden',
131
+ borderRadius: 6,
132
+ backgroundColor: muted,
133
+ borderWidth: 1,
134
+ borderColor: border,
135
+ padding: 12,
136
+ }}
137
+ >
138
+ <Text style={{ fontSize: 13, fontFamily: MONO_FONT }}>{children}</Text>
139
+ </View>
140
+ ) : (
141
+ <Text
142
+ key={node.key}
143
+ style={{
144
+ backgroundColor: muted,
145
+ paddingHorizontal: 4,
146
+ paddingVertical: 1,
147
+ fontSize: 13,
148
+ fontFamily: MONO_FONT,
149
+ borderRadius: 3,
150
+ }}
151
+ >
152
+ {children}
153
+ </Text>
154
+ );
155
+ },
156
+ list_item: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
157
+ const isOrdered = parent[parent.length - 1]?.type === 'ordered_list';
158
+ const bullet = isOrdered
159
+ ? `${parent[parent.length - 1].children.indexOf(node) + 1}.`
160
+ : '\u2022';
161
+
162
+ return (
163
+ <View key={node.key} style={{ flexDirection: 'row', paddingVertical: 2, paddingLeft: 16 }}>
164
+ <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
165
+ {bullet}
166
+ </Text>
167
+ {/* A View, not a Text: a nested list is a View, and inside a Text it runs inline. */}
168
+ <View style={{ flex: 1 }}>{children}</View>
169
+ </View>
170
+ );
171
+ },
172
+ ordered_list: (node: ASTNode, children: ReactNode[]) => (
173
+ <View key={node.key} style={{ marginVertical: 8 }}>
174
+ {children}
175
+ </View>
176
+ ),
177
+ unordered_list: (node: ASTNode, children: ReactNode[]) => (
178
+ <View key={node.key} style={{ marginVertical: 8 }}>
179
+ {children}
180
+ </View>
181
+ ),
182
+ strong: (node: ASTNode, children: ReactNode[]) => (
183
+ <Text key={node.key} style={{ fontWeight: '600', ...BODY_TEXT, color: textColor }}>
184
+ {children}
185
+ </Text>
186
+ ),
187
+ link: (node: ASTNode, children: ReactNode[]) => (
188
+ <Text
189
+ key={node.key}
190
+ style={{ color: primary, textDecorationLine: 'underline', ...BODY_TEXT }}
191
+ onPress={() => {
192
+ if (node.attributes?.href) Linking.openURL(node.attributes.href);
193
+ }}
194
+ >
195
+ {children}
196
+ </Text>
197
+ ),
198
+ paragraph: (node: ASTNode, children: ReactNode[]) => (
199
+ <Text key={node.key} style={{ marginBottom: 8, ...BODY_TEXT, color: textColor }}>
200
+ {children}
201
+ </Text>
202
+ ),
203
+ blockquote: (node: ASTNode, children: ReactNode[]) => (
204
+ <View
205
+ key={node.key}
206
+ style={{
207
+ marginVertical: 8,
208
+ borderLeftWidth: 4,
209
+ borderLeftColor: border,
210
+ backgroundColor: muted,
211
+ paddingHorizontal: 12,
212
+ paddingVertical: 4,
213
+ borderTopRightRadius: 6,
214
+ borderBottomRightRadius: 6,
215
+ }}
216
+ >
217
+ {children}
218
+ </View>
219
+ ),
220
+ hr: (node: ASTNode) => (
221
+ <View
222
+ key={node.key}
223
+ style={{
224
+ marginVertical: 16,
225
+ height: 1,
226
+ backgroundColor: border,
227
+ }}
228
+ />
229
+ ),
230
+ body: (node: ASTNode, children: ReactNode[]) => {
231
+ return (
232
+ <View key={node.key} style={{ marginBottom: -8 }}>
233
+ {children}
234
+ </View>
235
+ );
236
+ },
237
+ };
238
+ }
239
+
240
+ function createStyles(colors: AliaColors, fontFamily: string | undefined) {
241
+ const { text: textColor, muted, border, primary, mutedForeground } = colors;
242
+
243
+ return {
244
+ // On WEB the family arrives through the cascade down to `html`, so naming
245
+ // one here would override Bloom rather than complement it.
246
+ //
247
+ // On NATIVE nothing arrives. Bloom's only app-wide mechanism is the
248
+ // `Text.defaultProps.style` mutation in its `FontLoader.native.tsx`, and
249
+ // **React 19 drops `defaultProps` under the automatic JSX runtime** that
250
+ // Expo compiles to — measured on react@19.2.3: `createElement(Foo, {})`
251
+ // yields `{style:{fontFamily}}` while `jsx(Foo, {})` yields `{}`, for
252
+ // function AND class components alike. So that mutation reaches no `<Text>`
253
+ // in any Expo consumer, and this renderer cannot inherit a face.
254
+ //
255
+ // `fontFamily` therefore comes from the caller, which is the only party
256
+ // that both knows the family and is allowed to name it: react-native-markdown-display
257
+ // takes RN style objects, so there is no className path to NativeWind's token.
258
+ body: { ...BODY_TEXT, color: textColor, fontFamily },
259
+ text: { ...BODY_TEXT, color: textColor, fontFamily },
260
+ paragraph: { marginTop: 0, marginBottom: 8 },
261
+ strong: { fontWeight: '600' as const },
262
+ em: { fontStyle: 'italic' as const },
263
+ s: { textDecorationLine: 'line-through' as const },
264
+ bullet_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
265
+ ordered_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
266
+ bullet_list_content: { flex: 1 },
267
+ ordered_list_content: { flex: 1 },
268
+ code_inline: {
269
+ fontSize: 13,
270
+ fontFamily: MONO_FONT,
271
+ backgroundColor: muted,
272
+ borderWidth: 0,
273
+ borderRadius: 3,
274
+ paddingHorizontal: 4,
275
+ paddingVertical: 1,
276
+ },
277
+ code_block: {
278
+ fontSize: 13,
279
+ fontFamily: MONO_FONT,
280
+ backgroundColor: muted,
281
+ borderWidth: 1,
282
+ borderColor: border,
283
+ borderRadius: 6,
284
+ padding: 12,
285
+ },
286
+ fence: {
287
+ fontSize: 13,
288
+ fontFamily: MONO_FONT,
289
+ backgroundColor: muted,
290
+ borderWidth: 1,
291
+ borderColor: border,
292
+ borderRadius: 6,
293
+ padding: 12,
294
+ },
295
+ table: {
296
+ borderWidth: 1,
297
+ borderColor: border,
298
+ borderRadius: 12,
299
+ marginVertical: 8,
300
+ overflow: 'hidden' as const,
301
+ },
302
+ thead: { backgroundColor: muted },
303
+ th: { flex: 1, padding: 8, fontWeight: '600' as const, ...BODY_TEXT, color: textColor },
304
+ td: { flex: 1, padding: 8, ...BODY_TEXT, color: textColor },
305
+ tr: { borderBottomWidth: 1, borderColor: border, flexDirection: 'row' as const },
306
+ link: { textDecorationLine: 'underline' as const, color: primary, ...BODY_TEXT },
307
+ hr: { backgroundColor: border, height: 1, marginVertical: 16 },
308
+ heading1: { fontSize: 18, fontWeight: '600' as const },
309
+ heading2: { ...HEADING_TEXT, fontWeight: '600' as const },
310
+ heading3: { ...HEADING_TEXT, fontWeight: '600' as const },
311
+ heading4: { ...HEADING_TEXT, fontWeight: '500' as const },
312
+ heading5: { ...HEADING_TEXT, fontWeight: '500' as const },
313
+ heading6: { ...HEADING_TEXT, fontWeight: '500' as const },
314
+ };
315
+ }
316
+
317
+ /** Draws one block of code: its source, and the fence's language when it named one. */
7
318
  export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
8
319
 
320
+ /** The fence's info string up to the first space (```ts title=x → `ts`). */
321
+ function codeLanguage(node: ASTNode): string | undefined {
322
+ const info = (node as ASTNode & { sourceInfo?: string }).sourceInfo?.trim() ?? '';
323
+ const language = info.split(/\s+/)[0];
324
+ return language === '' ? undefined : language;
325
+ }
326
+
327
+ /**
328
+ * The body face, which `react-native-markdown-display` cannot inherit on native.
329
+ *
330
+ * It is driven by RN STYLE OBJECTS and supports no `className`, so NativeWind's `--font-sans`
331
+ * cannot reach the `<Text>` elements it renders. And Bloom's app-wide native default — the
332
+ * `Text.defaultProps` mutation in its `FontLoader.native.tsx` — does not apply: React 19 drops
333
+ * `defaultProps` under the automatic JSX runtime Expo compiles to. So the family is handed over
334
+ * explicitly, taken from Bloom's own token so it has one owner. `fontFamilies.sans` is a CSS
335
+ * STACK and RN's `fontFamily` takes a single family, hence the leading entry. Web is left
336
+ * undefined on purpose: there the cascade from `html` already carries `--bloom-font-sans`, and
337
+ * naming a family would override Bloom instead of complementing it.
338
+ */
339
+ const BODY_FONT = Platform.OS === 'web' ? undefined : fontFamilies.sans.split(',')[0].trim();
340
+
9
341
  export interface AliaMarkdownProps {
342
+ /**
343
+ * The sans family for body text, as a SINGLE family name — RN's `fontFamily` takes one, not a
344
+ * CSS stack. Omitted, Bloom's own (see `BODY_FONT`).
345
+ */
346
+ fontFamily?: string;
10
347
  content: string;
11
- /** The text is still arriving: an unfinished trailing construct closes implicitly. */
12
- streaming?: boolean;
348
+ colors?: Partial<AliaColors>;
13
349
  /**
14
- * Draw fenced code with the host's own code card. Keep the function's identity stable (a
15
- * module-level function or a `useCallback`): it is called once per render of each block.
350
+ * Draw fenced code with the host's own code card. Keep the function's
351
+ * identity stable (a module-level function or a `useCallback`): it is part of
352
+ * the rules' memo, and a new one per render re-parses every block.
16
353
  */
17
354
  renderCodeBlock?: RenderCodeBlock;
18
355
  }
19
356
 
357
+ const FENCE_RE = /^\s*(?:```|~~~)/;
358
+ const LIST_OR_TABLE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\|)/;
359
+ // Link reference definitions ([1]: https://…) resolve per-parse, so a document
360
+ // using them cannot be split into independently parsed blocks.
361
+ const LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s/m;
362
+
20
363
  /**
21
- * A reply or a thought, set in Bloom's `Markdown` at its chat reading size. Bloom owns the parser
22
- * (no `markdown-it`, no `linkify-it`), the streaming behaviour (only the last block is re-read as
23
- * the text grows) and the colours, which follow the app's chosen theme.
364
+ * Split markdown into top-level blocks at blank-line boundaries, keeping
365
+ * fenced code, list runs, and tables intact. During streaming only the last
366
+ * block's text changes, so every completed block hits React.memo and skips
367
+ * its markdown-it re-parse — the per-flush cost drops from O(document) to
368
+ * O(trailing block).
24
369
  */
25
- export function AliaMarkdown({ content, streaming, renderCodeBlock }: AliaMarkdownProps) {
370
+ function splitMarkdownBlocks(content: string): string[] {
371
+ if (LINK_REF_DEF_RE.test(content)) return [content];
372
+
373
+ const lines = content.split('\n');
374
+ const blocks: string[] = [];
375
+ let current: string[] = [];
376
+ let lastNonBlank = '';
377
+ let inFence = false;
378
+ let i = 0;
379
+
380
+ while (i < lines.length) {
381
+ const line = lines[i];
382
+ if (FENCE_RE.test(line)) {
383
+ inFence = !inFence;
384
+ current.push(line);
385
+ lastNonBlank = line;
386
+ i += 1;
387
+ continue;
388
+ }
389
+ if (!inFence && line.trim() === '') {
390
+ let j = i;
391
+ while (j < lines.length && lines[j].trim() === '') j += 1;
392
+ const next = j < lines.length ? lines[j] : '';
393
+ // Splitting a list or table at a blank line would restart ordered-list
394
+ // numbering and break table parsing — keep those runs in one block.
395
+ const keepTogether = LIST_OR_TABLE_RE.test(lastNonBlank) || LIST_OR_TABLE_RE.test(next);
396
+ if (keepTogether || current.length === 0) {
397
+ for (; i < j; i += 1) current.push(lines[i]);
398
+ } else {
399
+ blocks.push(current.join('\n'));
400
+ current = [];
401
+ i = j;
402
+ }
403
+ continue;
404
+ }
405
+ current.push(line);
406
+ lastNonBlank = line;
407
+ i += 1;
408
+ }
409
+ if (current.length > 0) blocks.push(current.join('\n'));
410
+ return blocks.length > 0 ? blocks : [content];
411
+ }
412
+
413
+ type MarkdownBlockProps = {
414
+ content: string;
415
+ rules: ReturnType<typeof createRules>;
416
+ styles: ReturnType<typeof createStyles>;
417
+ };
418
+
419
+ /**
420
+ * `react-native-markdown-display` gives three of its props DEFAULT VALUES that
421
+ * are new objects on every render: `markdownit = MarkdownIt({ typographer:
422
+ * true })`, `allowedImageHandlers = [...]` and `topLevelMaxExceededItem =
423
+ * <Text>`. A default parameter is evaluated per call, so each render of each
424
+ * `<Markdown>` built a parser (compiling linkify-it's regexes), and the two
425
+ * others sit in the deps of its renderer's `useMemo`, so the renderer — and
426
+ * the `StyleSheet.create` of every style inside it — was rebuilt every render
427
+ * too. A streamed answer re-renders its last block per token, and opening a
428
+ * thread renders every block once: both paid for it. Passing the defaults'
429
+ * own values, created once, makes both memos hold. The parser is shared:
430
+ * `parse` keeps no state between calls.
431
+ *
432
+ * The typings omit the last two props, which the component does take.
433
+ */
434
+ const PARSER = MarkdownIt({ typographer: true });
435
+ const IMAGE_HANDLERS = [
436
+ 'data:image/png;base64',
437
+ 'data:image/gif;base64',
438
+ 'data:image/jpeg;base64',
439
+ 'https://',
440
+ 'http://',
441
+ ];
442
+ const TOP_LEVEL_EXCEEDED = <Text key="dotdotdot">...</Text>;
443
+ const StableMarkdown = Markdown as React.ComponentType<
444
+ React.ComponentProps<typeof Markdown> & {
445
+ allowedImageHandlers?: string[];
446
+ topLevelMaxExceededItem?: ReactNode;
447
+ }
448
+ >;
449
+
450
+ function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
26
451
  return (
27
- <Markdown size="lg" streaming={streaming} renderCodeBlock={renderCodeBlock}>
452
+ <StableMarkdown
453
+ rules={rules}
454
+ style={styles}
455
+ markdownit={PARSER}
456
+ allowedImageHandlers={IMAGE_HANDLERS}
457
+ topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
458
+ >
28
459
  {content}
29
- </Markdown>
460
+ </StableMarkdown>
461
+ );
462
+ }
463
+
464
+ const MarkdownBlock = React.memo(ParsedMarkdown);
465
+
466
+ export function AliaMarkdown({
467
+ content,
468
+ colors: colorOverrides,
469
+ fontFamily = BODY_FONT,
470
+ renderCodeBlock,
471
+ }: AliaMarkdownProps) {
472
+ // Bloom's theme, which follows the app's chosen mode, not the system's scheme: a person who
473
+ // picked light on a dark OS gets dark text on a light surface, not the reverse.
474
+ const { colors: theme } = useTheme();
475
+ const colors = {
476
+ text: theme.text,
477
+ muted: theme.backgroundSecondary,
478
+ border: theme.border,
479
+ primary: theme.primary,
480
+ mutedForeground: theme.textSecondary,
481
+ ...colorOverrides,
482
+ } as AliaColors;
483
+
484
+ const customRules = useMemo(
485
+ () => createRules(colors, renderCodeBlock),
486
+ [
487
+ colors.text,
488
+ colors.muted,
489
+ colors.border,
490
+ colors.primary,
491
+ colors.mutedForeground,
492
+ renderCodeBlock,
493
+ ],
494
+ );
495
+ const markdownStyles = useMemo(
496
+ () => createStyles(colors, fontFamily),
497
+ [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily],
498
+ );
499
+ const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
500
+
501
+ if (blocks.length === 1) {
502
+ return <ParsedMarkdown content={blocks[0]} rules={customRules} styles={markdownStyles} />;
503
+ }
504
+
505
+ // Each block's `body` rule cancels its own trailing paragraph margin
506
+ // (marginBottom: -8), so rowGap restores the exact inter-paragraph spacing
507
+ // a single continuous parse would produce.
508
+ return (
509
+ <View style={{ rowGap: 8 }}>
510
+ {blocks.map((block, i) => (
511
+ <MarkdownBlock key={i} content={block} rules={customRules} styles={markdownStyles} />
512
+ ))}
513
+ </View>
30
514
  );
31
515
  }
@@ -1,6 +1,5 @@
1
- import { Button } from '@oxy.so/bloom/button';
2
- import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@oxy.so/bloom/card';
3
- import { RiCheckLine, RiCloseLine, RiListCheck3 } from '@oxy.so/bloom/icons';
1
+ import { Card, CardBody, CardHeader, CardTitle } from '@oxy.so/bloom/card';
2
+ import { RiListCheck3 } from '@oxy.so/bloom/icons';
4
3
  import { Item } from '@oxy.so/bloom/item';
5
4
  import { useTheme } from '@oxy.so/bloom/theme';
6
5
  import { Text } from '@oxy.so/bloom/typography';
@@ -12,35 +11,16 @@ import type { PlanStep } from '../types';
12
11
 
13
12
  interface PlanPreviewCardProps {
14
13
  steps: PlanStep[];
15
- onApprove: () => void;
16
- onReject: () => void;
17
- approved?: boolean;
18
- rejected?: boolean;
19
14
  }
20
15
 
21
- /** The plan Alia proposes, until the person approves or cancels it; then one line saying which. */
22
- export function PlanPreviewCard({
23
- steps,
24
- onApprove,
25
- onReject,
26
- approved,
27
- rejected,
28
- }: PlanPreviewCardProps) {
16
+ /**
17
+ * The plan the agent shows before it works. It is only shown: the agent does not wait on it, and
18
+ * nobody is asked to approve it.
19
+ */
20
+ export function PlanPreviewCard({ steps }: PlanPreviewCardProps) {
29
21
  const labels = useLabels();
30
22
  const { colors } = useTheme();
31
23
 
32
- if (approved || rejected) {
33
- const Icon = approved ? RiCheckLine : RiCloseLine;
34
- return (
35
- <View className="flex-row items-center gap-1.5 py-1">
36
- <Icon width={14} height={14} fill={approved ? colors.success : colors.error} />
37
- <Text className="text-xs text-text-secondary">
38
- {approved ? labels.planApproved : labels.planCancelled}
39
- </Text>
40
- </View>
41
- );
42
- }
43
-
44
24
  return (
45
25
  <Animated.View entering={planPreviewEnter} className="my-2">
46
26
  <Card appearance="outline">
@@ -67,28 +47,6 @@ export function PlanPreviewCard({
67
47
  />
68
48
  ))}
69
49
  </CardBody>
70
- <CardFooter>
71
- <View className="flex-1 flex-row gap-2">
72
- <Button
73
- tone="action"
74
- size="sm"
75
- className="flex-1"
76
- leadingIcon={RiCheckLine}
77
- onPress={onApprove}
78
- >
79
- {labels.planApprove}
80
- </Button>
81
- <Button
82
- tone="neutral"
83
- appearance="subtle"
84
- size="sm"
85
- className="flex-1"
86
- onPress={onReject}
87
- >
88
- {labels.planCancel}
89
- </Button>
90
- </View>
91
- </CardFooter>
92
50
  </Card>
93
51
  </Animated.View>
94
52
  );