@alia.onl/sdk 15.4.0 → 15.6.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 15.6.0
4
+
5
+ - **The composer has its loader again.** While a turn runs, the pill is wrapped in Bloom's
6
+ `ComposerLoader` (the light that laps its rim) with `surface={false}` and `frosted`, as the
7
+ composer had before it became `ComposerPill` (15.1.0) and lost it. A question Alia asks sits
8
+ beside the loader instead of inside the pill's `question` slot, so the light does not ring it.
9
+
10
+ ## 15.5.0
11
+
12
+ - **Replies are set by Bloom's `Markdown`.** `AliaMarkdown` is a thin wrapper over
13
+ `@oxy.so/bloom/markdown` at its chat reading size (`lg`, 16/28): the parser, the streaming
14
+ behaviour and the colours are Bloom's. `react-native-markdown-display` — and with it
15
+ `markdown-it` and the `linkify-it` ReDoS advisory — is no longer a dependency, so apps stop
16
+ installing it. `AliaMarkdown` loses its `colors` and `fontFamily` props (nothing used them: the
17
+ text follows the app's Bloom theme) and gains `streaming`. Peer `@oxy.so/bloom` is `>=10.7.0`.
18
+
3
19
  ## 15.4.0
4
20
 
5
21
  - **New leaf entry `@alia.onl/sdk/avatar`.** `AliaAvatarIcon`, `AliaAvatar`, `ALIA_AVATAR` and
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "15.4.0",
4
- "description": "Alia AI chat SDK — reusable chat UI, voice calls, dictation and streaming components for Oxy apps",
3
+ "version": "15.6.0",
5
4
  "main": "src/index.ts",
6
5
  "react-native": "src/index.ts",
7
6
  "source": "src/index.ts",
@@ -32,7 +31,6 @@
32
31
  },
33
32
  "dependencies": {
34
33
  "clsx": "^2.1.1",
35
- "react-native-markdown-display": "^7.0.2",
36
34
  "socket.io-client": "^4.8.3",
37
35
  "tailwind-merge": "^3.7.0",
38
36
  "zustand": "^5.0.15"
@@ -41,7 +39,7 @@
41
39
  "react": "^19.2.3",
42
40
  "react-native": "^0.86.0",
43
41
  "@expo/vector-icons": "^15.1.1",
44
- "@oxy.so/bloom": ">=10.6.0",
42
+ "@oxy.so/bloom": ">=10.7.0",
45
43
  "@oxy.so/core": ">=3.0.0",
46
44
  "@oxy.so/services": ">=8.0.0",
47
45
  "@react-native-async-storage/async-storage": "^2.2.0",
@@ -7,7 +7,8 @@
7
7
  * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
8
8
  */
9
9
 
10
- import React, { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
10
+ import type React from 'react';
11
+ import { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
11
12
  import { View, type TextInput } from 'react-native';
12
13
  import type { SharedValue } from 'react-native-reanimated';
13
14
  import { turnActivityState } from '../lib/turn-state';
@@ -371,7 +371,9 @@ const AssistantMessage = React.memo(function AssistantMessage({
371
371
  agent={message.agentInfo}
372
372
  thinking={thinking}
373
373
  reasoning={
374
- thinking && hasThinking ? <AliaMarkdown content={message.thinking!} /> : undefined
374
+ thinking && hasThinking ? (
375
+ <AliaMarkdown content={message.thinking!} streaming={message.isStreaming} />
376
+ ) : undefined
375
377
  }
376
378
  actions={replyActions}
377
379
  >
@@ -383,7 +385,7 @@ const AssistantMessage = React.memo(function AssistantMessage({
383
385
  ) : renderMarkdown ? (
384
386
  renderMarkdown(citedText)
385
387
  ) : (
386
- <AliaMarkdown content={citedText} />
388
+ <AliaMarkdown content={citedText} streaming={message.isStreaming} />
387
389
  )}
388
390
  </ReplyBody>
389
391
  </Collapsible>
@@ -17,7 +17,8 @@
17
17
  * `react-native-audio-api` never enters the bundle.
18
18
  */
19
19
 
20
- import React, { useCallback } from 'react';
20
+ import type React from 'react';
21
+ import { useCallback } from 'react';
21
22
  import { View } from 'react-native';
22
23
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
23
24
  import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
@@ -19,6 +19,7 @@ import {
19
19
  type ComposerPanelAttachment,
20
20
  type ComposerPanelPermissionOption,
21
21
  } from '@oxy.so/bloom/composer-panel';
22
+ import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
22
23
  import { toast } from '@oxy.so/bloom/toast';
23
24
  import { Text } from '@oxy.so/bloom/typography';
24
25
  import { useAttachmentIntake } from '../hooks/useAttachmentIntake';
@@ -302,47 +303,52 @@ function AliaComposerBody<T extends AliaSuggestion>({
302
303
  {labels.dictationRemaining(remainingSeconds)}
303
304
  </Text>
304
305
  ) : null}
305
- <ComposerPill
306
- value={value}
307
- onValueChange={onValueChange}
308
- onSubmit={() => onSubmit()}
309
- inputRef={inputRef}
310
- busy={busy}
311
- onStop={onStop}
312
- disabled={disabled || intake.isBusy || !hasContent}
313
- placeholder={placeholder ?? labels.placeholder}
314
- addMenu={addMenu ?? EMPTY_ADD_MENU}
315
- onAddMenuSelect={onAddMenuSelect}
316
- permissions={modes ?? NO_MODES}
317
- permission={mode}
318
- onPermissionChange={onModeChange}
319
- voiceState={
320
- dictation.isTranscribing
321
- ? 'transcribing'
322
- : dictation.isRecording || dictation.state === 'requesting-permission'
323
- ? 'recording'
324
- : 'idle'
325
- }
326
- onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
327
- attachments={tiles}
328
- onRemoveAttachment={removeTile}
329
- // Clears the tile's error and starts its progress again: `intake.retry`
330
- // puts the item back to `reading` over the same `File`.
331
- onAttachmentRetry={intake.retry}
332
- question={
333
- questions && questions.items.length > 0 ? (
334
- <QuestionCard
335
- questions={questions.items}
336
- onSubmit={questions.onSubmit}
337
- onDismiss={questions.onDismiss}
338
- />
339
- ) : undefined
340
- }
341
- status={status}
342
- emptyAction={emptyAction}
343
- onKeyPress={onKeyPress}
344
- labels={panelLabels}
345
- />
306
+ {questions && questions.items.length > 0 ? (
307
+ // Beside the loader, not through the pill's `question` slot: the light traces the box it
308
+ // wraps, so a question inside it would be ringed too.
309
+ <QuestionCard
310
+ questions={questions.items}
311
+ onSubmit={questions.onSubmit}
312
+ onDismiss={questions.onDismiss}
313
+ />
314
+ ) : null}
315
+ <ComposerLoader active={busy}>
316
+ <ComposerPill
317
+ value={value}
318
+ onValueChange={onValueChange}
319
+ onSubmit={() => onSubmit()}
320
+ inputRef={inputRef}
321
+ busy={busy}
322
+ onStop={onStop}
323
+ disabled={disabled || intake.isBusy || !hasContent}
324
+ placeholder={placeholder ?? labels.placeholder}
325
+ addMenu={addMenu ?? EMPTY_ADD_MENU}
326
+ onAddMenuSelect={onAddMenuSelect}
327
+ permissions={modes ?? NO_MODES}
328
+ permission={mode}
329
+ onPermissionChange={onModeChange}
330
+ voiceState={
331
+ dictation.isTranscribing
332
+ ? 'transcribing'
333
+ : dictation.isRecording || dictation.state === 'requesting-permission'
334
+ ? 'recording'
335
+ : 'idle'
336
+ }
337
+ onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
338
+ attachments={tiles}
339
+ onRemoveAttachment={removeTile}
340
+ // Clears the tile's error and starts its progress again: `intake.retry`
341
+ // puts the item back to `reading` over the same `File`.
342
+ onAttachmentRetry={intake.retry}
343
+ status={status}
344
+ emptyAction={emptyAction}
345
+ onKeyPress={onKeyPress}
346
+ labels={panelLabels}
347
+ // The loader paints the pill's surface behind its light; the controls go frosted over it.
348
+ surface={false}
349
+ frosted={busy}
350
+ />
351
+ </ComposerLoader>
346
352
  <ComposerDropOverlay visible={takesFiles && isDragOver} enabled={acceptsFiles} />
347
353
  {accessory ? (
348
354
  <View pointerEvents="box-none" className="absolute bottom-full right-0 mb-2">
@@ -1,508 +1,31 @@
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 type { AliaColors } from '../theme';
1
+ import type { ReactNode } from 'react';
2
+ import { Markdown } from '@oxy.so/bloom/markdown';
7
3
 
8
- // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
9
- // The main app always passes resolved colors via the `colors` prop.
10
- /** The body text of a reply: its bubble sets its padding from these. */
4
+ /** The body text of a reply: its bubble sets its padding from these. Bloom's `lg` ramp. */
11
5
  export const MARKDOWN_BODY = { fontSize: 16, lineHeight: 28 } as const;
12
- const BODY_TEXT = MARKDOWN_BODY;
13
- const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
14
- const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
15
6
 
16
- function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
17
- const { text: textColor, muted, border, primary, mutedForeground } = colors;
18
-
19
- /**
20
- * Fenced and indented code: Bloom's `CodeBlock` unless the host brings a
21
- * code card of its own.
22
- */
23
- const drawCodeBlock: RenderCodeBlock =
24
- renderCodeBlock ?? ((code, language) => <CodeBlock code={code} language={language} />);
25
- const codeRules = {
26
- fence: (node: ASTNode) => (
27
- <View key={node.key} style={{ marginVertical: 8 }}>
28
- {drawCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
29
- </View>
30
- ),
31
- code_block: (node: ASTNode) => (
32
- <View key={node.key} style={{ marginVertical: 8 }}>
33
- {drawCodeBlock(node.content.replace(/\n$/, ''), undefined)}
34
- </View>
35
- ),
36
- };
37
-
38
- return {
39
- ...codeRules,
40
- heading1: (node: ASTNode, children: ReactNode[]) => (
41
- <Text
42
- key={node.key}
43
- style={{
44
- marginBottom: 8,
45
- marginTop: 12,
46
- fontSize: 18,
47
- fontWeight: '600',
48
- lineHeight: 24,
49
- color: textColor,
50
- }}
51
- >
52
- {children}
53
- </Text>
54
- ),
55
- heading2: (node: ASTNode, children: ReactNode[]) => (
56
- <Text
57
- key={node.key}
58
- style={{
59
- marginBottom: 8,
60
- marginTop: 12,
61
- ...HEADING_TEXT,
62
- fontWeight: '600',
63
- color: textColor,
64
- }}
65
- >
66
- {children}
67
- </Text>
68
- ),
69
- heading3: (node: ASTNode, children: ReactNode[]) => (
70
- <Text
71
- key={node.key}
72
- style={{
73
- marginBottom: 6,
74
- marginTop: 8,
75
- ...HEADING_TEXT,
76
- fontWeight: '600',
77
- color: textColor,
78
- }}
79
- >
80
- {children}
81
- </Text>
82
- ),
83
- heading4: (node: ASTNode, children: ReactNode[]) => (
84
- <Text
85
- key={node.key}
86
- style={{
87
- marginBottom: 6,
88
- marginTop: 8,
89
- ...HEADING_TEXT,
90
- fontWeight: '500',
91
- color: textColor,
92
- }}
93
- >
94
- {children}
95
- </Text>
96
- ),
97
- heading5: (node: ASTNode, children: ReactNode[]) => (
98
- <Text
99
- key={node.key}
100
- style={{
101
- marginBottom: 4,
102
- marginTop: 8,
103
- ...HEADING_TEXT,
104
- fontWeight: '500',
105
- color: textColor,
106
- }}
107
- >
108
- {children}
109
- </Text>
110
- ),
111
- heading6: (node: ASTNode, children: ReactNode[]) => (
112
- <Text
113
- key={node.key}
114
- style={{
115
- marginBottom: 4,
116
- marginTop: 8,
117
- ...HEADING_TEXT,
118
- fontWeight: '500',
119
- color: textColor,
120
- }}
121
- >
122
- {children}
123
- </Text>
124
- ),
125
- code: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
126
- return parent.length > 1 ? (
127
- <View
128
- key={node.key}
129
- style={{
130
- marginVertical: 8,
131
- overflow: 'hidden',
132
- borderRadius: 6,
133
- backgroundColor: muted,
134
- borderWidth: 1,
135
- borderColor: border,
136
- padding: 12,
137
- }}
138
- >
139
- <Text style={{ fontSize: 13, fontFamily: MONO_FONT }}>{children}</Text>
140
- </View>
141
- ) : (
142
- <Text
143
- key={node.key}
144
- style={{
145
- backgroundColor: muted,
146
- paddingHorizontal: 4,
147
- paddingVertical: 1,
148
- fontSize: 13,
149
- fontFamily: MONO_FONT,
150
- borderRadius: 3,
151
- }}
152
- >
153
- {children}
154
- </Text>
155
- );
156
- },
157
- list_item: (node: ASTNode, children: ReactNode[], parent: ASTNode[]) => {
158
- const isOrdered = parent[parent.length - 1]?.type === 'ordered_list';
159
- const bullet = isOrdered
160
- ? `${parent[parent.length - 1].children.indexOf(node) + 1}.`
161
- : '\u2022';
162
-
163
- return (
164
- <View key={node.key} style={{ flexDirection: 'row', paddingVertical: 2, paddingLeft: 16 }}>
165
- <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
166
- {bullet}
167
- </Text>
168
- {/* A View, not a Text: a nested list is a View, and inside a Text it runs inline. */}
169
- <View style={{ flex: 1 }}>{children}</View>
170
- </View>
171
- );
172
- },
173
- ordered_list: (node: ASTNode, children: ReactNode[]) => (
174
- <View key={node.key} style={{ marginVertical: 8 }}>
175
- {children}
176
- </View>
177
- ),
178
- unordered_list: (node: ASTNode, children: ReactNode[]) => (
179
- <View key={node.key} style={{ marginVertical: 8 }}>
180
- {children}
181
- </View>
182
- ),
183
- strong: (node: ASTNode, children: ReactNode[]) => (
184
- <Text key={node.key} style={{ fontWeight: '600', ...BODY_TEXT, color: textColor }}>
185
- {children}
186
- </Text>
187
- ),
188
- link: (node: ASTNode, children: ReactNode[]) => (
189
- <Text
190
- key={node.key}
191
- style={{ color: primary, textDecorationLine: 'underline', ...BODY_TEXT }}
192
- onPress={() => {
193
- if (node.attributes?.href) Linking.openURL(node.attributes.href);
194
- }}
195
- >
196
- {children}
197
- </Text>
198
- ),
199
- paragraph: (node: ASTNode, children: ReactNode[]) => (
200
- <Text key={node.key} style={{ marginBottom: 8, ...BODY_TEXT, color: textColor }}>
201
- {children}
202
- </Text>
203
- ),
204
- blockquote: (node: ASTNode, children: ReactNode[]) => (
205
- <View
206
- key={node.key}
207
- style={{
208
- marginVertical: 8,
209
- borderLeftWidth: 4,
210
- borderLeftColor: border,
211
- backgroundColor: muted,
212
- paddingHorizontal: 12,
213
- paddingVertical: 4,
214
- borderTopRightRadius: 6,
215
- borderBottomRightRadius: 6,
216
- }}
217
- >
218
- {children}
219
- </View>
220
- ),
221
- hr: (node: ASTNode) => (
222
- <View
223
- key={node.key}
224
- style={{
225
- marginVertical: 16,
226
- height: 1,
227
- backgroundColor: border,
228
- }}
229
- />
230
- ),
231
- body: (node: ASTNode, children: ReactNode[]) => {
232
- return (
233
- <View key={node.key} style={{ marginBottom: -8 }}>
234
- {children}
235
- </View>
236
- );
237
- },
238
- };
239
- }
240
-
241
- function createStyles(colors: AliaColors, fontFamily: string | undefined) {
242
- const { text: textColor, muted, border, primary, mutedForeground } = colors;
243
-
244
- return {
245
- // On WEB the family arrives through the cascade down to `html`, so naming
246
- // one here would override Bloom rather than complement it.
247
- //
248
- // On NATIVE nothing arrives. Bloom's only app-wide mechanism is the
249
- // `Text.defaultProps.style` mutation in its `FontLoader.native.tsx`, and
250
- // **React 19 drops `defaultProps` under the automatic JSX runtime** that
251
- // Expo compiles to — measured on react@19.2.3: `createElement(Foo, {})`
252
- // yields `{style:{fontFamily}}` while `jsx(Foo, {})` yields `{}`, for
253
- // function AND class components alike. So that mutation reaches no `<Text>`
254
- // in any Expo consumer, and this renderer cannot inherit a face.
255
- //
256
- // `fontFamily` therefore comes from the caller, which is the only party
257
- // that both knows the family and is allowed to name it: react-native-markdown-display
258
- // takes RN style objects, so there is no className path to NativeWind's token.
259
- body: { ...BODY_TEXT, color: textColor, fontFamily },
260
- text: { ...BODY_TEXT, color: textColor, fontFamily },
261
- paragraph: { marginTop: 0, marginBottom: 8 },
262
- strong: { fontWeight: '600' as const },
263
- em: { fontStyle: 'italic' as const },
264
- s: { textDecorationLine: 'line-through' as const },
265
- bullet_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
266
- ordered_list_icon: { marginLeft: 0, marginRight: 8, ...BODY_TEXT, color: mutedForeground },
267
- bullet_list_content: { flex: 1 },
268
- ordered_list_content: { flex: 1 },
269
- code_inline: {
270
- fontSize: 13,
271
- fontFamily: MONO_FONT,
272
- backgroundColor: muted,
273
- borderWidth: 0,
274
- borderRadius: 3,
275
- paddingHorizontal: 4,
276
- paddingVertical: 1,
277
- },
278
- code_block: {
279
- fontSize: 13,
280
- fontFamily: MONO_FONT,
281
- backgroundColor: muted,
282
- borderWidth: 1,
283
- borderColor: border,
284
- borderRadius: 6,
285
- padding: 12,
286
- },
287
- fence: {
288
- fontSize: 13,
289
- fontFamily: MONO_FONT,
290
- backgroundColor: muted,
291
- borderWidth: 1,
292
- borderColor: border,
293
- borderRadius: 6,
294
- padding: 12,
295
- },
296
- table: {
297
- borderWidth: 1,
298
- borderColor: border,
299
- borderRadius: 12,
300
- marginVertical: 8,
301
- overflow: 'hidden' as const,
302
- },
303
- thead: { backgroundColor: muted },
304
- th: { flex: 1, padding: 8, fontWeight: '600' as const, ...BODY_TEXT, color: textColor },
305
- td: { flex: 1, padding: 8, ...BODY_TEXT, color: textColor },
306
- tr: { borderBottomWidth: 1, borderColor: border, flexDirection: 'row' as const },
307
- link: { textDecorationLine: 'underline' as const, color: primary, ...BODY_TEXT },
308
- hr: { backgroundColor: border, height: 1, marginVertical: 16 },
309
- heading1: { fontSize: 18, fontWeight: '600' as const },
310
- heading2: { ...HEADING_TEXT, fontWeight: '600' as const },
311
- heading3: { ...HEADING_TEXT, fontWeight: '600' as const },
312
- heading4: { ...HEADING_TEXT, fontWeight: '500' as const },
313
- heading5: { ...HEADING_TEXT, fontWeight: '500' as const },
314
- heading6: { ...HEADING_TEXT, fontWeight: '500' as const },
315
- };
316
- }
317
-
318
- /** Draws one block of code: its source, and the fence's language when it named one. */
319
7
  export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
320
8
 
321
- /** The fence's info string up to the first space (```ts title=x → `ts`). */
322
- function codeLanguage(node: ASTNode): string | undefined {
323
- const info = (node as ASTNode & { sourceInfo?: string }).sourceInfo?.trim() ?? '';
324
- const language = info.split(/\s+/)[0];
325
- return language === '' ? undefined : language;
326
- }
327
-
328
9
  export interface AliaMarkdownProps {
329
- /**
330
- * The sans family for body text, as a SINGLE family name — RN's `fontFamily`
331
- * takes one, not a CSS stack.
332
- *
333
- * Omitted, body text inherits: correct on web, where Bloom's
334
- * `--bloom-font-sans` reaches it through the cascade. On native nothing is
335
- * inherited (see `createStyles`), so a native caller that wants Bloom's face
336
- * has to pass it — this renderer takes RN style objects, so it cannot
337
- * read Bloom's token itself.
338
- */
339
- fontFamily?: string;
340
10
  content: string;
341
- colors?: Partial<AliaColors>;
11
+ /** The text is still arriving: an unfinished trailing construct closes implicitly. */
12
+ streaming?: boolean;
342
13
  /**
343
- * Draw fenced code with the host's own code card. Keep the function's
344
- * identity stable (a module-level function or a `useCallback`): it is part of
345
- * the rules' memo, and a new one per render re-parses every block.
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.
346
16
  */
347
17
  renderCodeBlock?: RenderCodeBlock;
348
18
  }
349
19
 
350
- const FENCE_RE = /^\s*(?:```|~~~)/;
351
- const LIST_OR_TABLE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\|)/;
352
- // Link reference definitions ([1]: https://…) resolve per-parse, so a document
353
- // using them cannot be split into independently parsed blocks.
354
- const LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s/m;
355
-
356
- /**
357
- * Split markdown into top-level blocks at blank-line boundaries, keeping
358
- * fenced code, list runs, and tables intact. During streaming only the last
359
- * block's text changes, so every completed block hits React.memo and skips
360
- * its markdown-it re-parse — the per-flush cost drops from O(document) to
361
- * O(trailing block).
362
- */
363
- function splitMarkdownBlocks(content: string): string[] {
364
- if (LINK_REF_DEF_RE.test(content)) return [content];
365
-
366
- const lines = content.split('\n');
367
- const blocks: string[] = [];
368
- let current: string[] = [];
369
- let lastNonBlank = '';
370
- let inFence = false;
371
- let i = 0;
372
-
373
- while (i < lines.length) {
374
- const line = lines[i];
375
- if (FENCE_RE.test(line)) {
376
- inFence = !inFence;
377
- current.push(line);
378
- lastNonBlank = line;
379
- i += 1;
380
- continue;
381
- }
382
- if (!inFence && line.trim() === '') {
383
- let j = i;
384
- while (j < lines.length && lines[j].trim() === '') j += 1;
385
- const next = j < lines.length ? lines[j] : '';
386
- // Splitting a list or table at a blank line would restart ordered-list
387
- // numbering and break table parsing — keep those runs in one block.
388
- const keepTogether = LIST_OR_TABLE_RE.test(lastNonBlank) || LIST_OR_TABLE_RE.test(next);
389
- if (keepTogether || current.length === 0) {
390
- for (; i < j; i += 1) current.push(lines[i]);
391
- } else {
392
- blocks.push(current.join('\n'));
393
- current = [];
394
- i = j;
395
- }
396
- continue;
397
- }
398
- current.push(line);
399
- lastNonBlank = line;
400
- i += 1;
401
- }
402
- if (current.length > 0) blocks.push(current.join('\n'));
403
- return blocks.length > 0 ? blocks : [content];
404
- }
405
-
406
- type MarkdownBlockProps = {
407
- content: string;
408
- rules: ReturnType<typeof createRules>;
409
- styles: ReturnType<typeof createStyles>;
410
- };
411
-
412
20
  /**
413
- * `react-native-markdown-display` gives three of its props DEFAULT VALUES that
414
- * are new objects on every render: `markdownit = MarkdownIt({ typographer:
415
- * true })`, `allowedImageHandlers = [...]` and `topLevelMaxExceededItem =
416
- * <Text>`. A default parameter is evaluated per call, so each render of each
417
- * `<Markdown>` built a parser (compiling linkify-it's regexes), and the two
418
- * others sit in the deps of its renderer's `useMemo`, so the renderer — and
419
- * the `StyleSheet.create` of every style inside it — was rebuilt every render
420
- * too. A streamed answer re-renders its last block per token, and opening a
421
- * thread renders every block once: both paid for it. Passing the defaults'
422
- * own values, created once, makes both memos hold. The parser is shared:
423
- * `parse` keeps no state between calls.
424
- *
425
- * The typings omit the last two props, which the component does take.
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.
426
24
  */
427
- const PARSER = MarkdownIt({ typographer: true });
428
- const IMAGE_HANDLERS = [
429
- 'data:image/png;base64',
430
- 'data:image/gif;base64',
431
- 'data:image/jpeg;base64',
432
- 'https://',
433
- 'http://',
434
- ];
435
- const TOP_LEVEL_EXCEEDED = <Text key="dotdotdot">...</Text>;
436
- const StableMarkdown = Markdown as React.ComponentType<
437
- React.ComponentProps<typeof Markdown> & {
438
- allowedImageHandlers?: string[];
439
- topLevelMaxExceededItem?: ReactNode;
440
- }
441
- >;
442
-
443
- function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
25
+ export function AliaMarkdown({ content, streaming, renderCodeBlock }: AliaMarkdownProps) {
444
26
  return (
445
- <StableMarkdown
446
- rules={rules}
447
- style={styles}
448
- markdownit={PARSER}
449
- allowedImageHandlers={IMAGE_HANDLERS}
450
- topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
451
- >
27
+ <Markdown size="lg" streaming={streaming} renderCodeBlock={renderCodeBlock}>
452
28
  {content}
453
- </StableMarkdown>
454
- );
455
- }
456
-
457
- const MarkdownBlock = React.memo(ParsedMarkdown);
458
-
459
- export function AliaMarkdown({
460
- content,
461
- colors: colorOverrides,
462
- fontFamily,
463
- renderCodeBlock,
464
- }: AliaMarkdownProps) {
465
- // Bloom's theme, which follows the app's chosen mode, not the system's scheme: a person who
466
- // picked light on a dark OS gets dark text on a light surface, not the reverse.
467
- const { colors: theme } = useTheme();
468
- const colors = {
469
- text: theme.text,
470
- muted: theme.backgroundSecondary,
471
- border: theme.border,
472
- primary: theme.primary,
473
- mutedForeground: theme.textSecondary,
474
- ...colorOverrides,
475
- } as AliaColors;
476
-
477
- const customRules = useMemo(
478
- () => createRules(colors, renderCodeBlock),
479
- [
480
- colors.text,
481
- colors.muted,
482
- colors.border,
483
- colors.primary,
484
- colors.mutedForeground,
485
- renderCodeBlock,
486
- ],
487
- );
488
- const markdownStyles = useMemo(
489
- () => createStyles(colors, fontFamily),
490
- [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily],
491
- );
492
- const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
493
-
494
- if (blocks.length === 1) {
495
- return <ParsedMarkdown content={blocks[0]} rules={customRules} styles={markdownStyles} />;
496
- }
497
-
498
- // Each block's `body` rule cancels its own trailing paragraph margin
499
- // (marginBottom: -8), so rowGap restores the exact inter-paragraph spacing
500
- // a single continuous parse would produce.
501
- return (
502
- <View style={{ rowGap: 8 }}>
503
- {blocks.map((block, i) => (
504
- <MarkdownBlock key={i} content={block} rules={customRules} styles={markdownStyles} />
505
- ))}
506
- </View>
29
+ </Markdown>
507
30
  );
508
31
  }
@@ -37,7 +37,7 @@ export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
37
37
 
38
38
  return (
39
39
  <AgentThinking thinking={false} label={finishedLabel}>
40
- <AliaMarkdown content={text} />
40
+ <AliaMarkdown content={text} streaming={streaming} />
41
41
  </AgentThinking>
42
42
  );
43
43
  }