@alia.onl/sdk 19.0.0 → 20.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 20.0.0
4
+
5
+ Generic chat UI is Bloom's; the SDK composes it and keeps only what is Alia's. Two copies the SDK
6
+ carried of things Bloom already had are deleted (a clean cut: no aliases).
7
+
8
+ ### Removed
9
+
10
+ - **`AliaMarkdown`, `AliaMarkdownProps` and `RenderCodeBlock`.** Replies render with Bloom's
11
+ `Markdown` (`@oxy.so/bloom/markdown`, `size="lg"`), which streams (an unfinished trailing
12
+ construct is closed instead of showing its markers, and only the last block is re-read), draws
13
+ accessible links (`role="link"`, an anchor on web) and vets every URL. `react-native-markdown-display`
14
+ is no longer a dependency. A Markdown image (an exclamation mark before a link) now reads as a link carrying its alt
15
+ text and is not fetched; Alia's own images arrive as `[ALIA_IMAGE]` blocks and are unchanged.
16
+ - **`joinFlights`.** The join choreography is Bloom's `useAvatarJoin` / `avatarJoinFlights`
17
+ (`@oxy.so/bloom/avatar-flight`), the same one Bloom's `MultiAgentChat` uses; `useParticipants`
18
+ wires the conversation's consulted agents into it, with the same behaviour.
19
+
20
+ ### Changed
21
+
22
+ - **`ReplyMarkdown`** takes `streaming`, and `onCitationPress` now also receives an inline `[n]`
23
+ marker that names a source (Bloom's `onLinkPress`), not only the references list.
24
+ - **`AliaParticipantsHeader`** is Bloom's `AiChatHeader` around `AliaParticipants`.
25
+ - **Peer `@oxy.so/bloom` >= 10.17.0**, the first release with `useAvatarJoin` and `AiChatHeader`.
26
+
27
+ ## 19.1.0
28
+
29
+ An app's own agent may be confined to that app by Oxy (Alia ADR 0021, Oxy ADRs 0044 and 0045).
30
+
31
+ ### Added
32
+
33
+ - **The SDK says which Oxy app it is in.** Every request that can open an agent — the REST API
34
+ (`useAliaApi`: profiles, features, conversations, threads), the chat stream and the voice call —
35
+ carries the host's Oxy-signed app assertion as `X-Oxy-App-Assertion` (on the voice socket, as
36
+ `appAssertion` in the `auth` frame, because a socket cannot carry a header). It is read at send
37
+ time from the host's `OxyProvider` (`useAppAssertion()` of `@oxy.so/services`), and nothing is
38
+ sent when the provider has none. Alia verifies it and opens an agent Oxy confines to an app only
39
+ inside that app; every other agent is unaffected. Dictation and read-aloud reach no agent and
40
+ send none.
41
+
42
+ ### Changed
43
+
44
+ - **Peer `@oxy.so/services` >= 11.12.0**, the first release whose `OxyProvider` obtains the app
45
+ assertion.
46
+
3
47
  ## 19.0.0
4
48
 
5
49
  Every agent is the same agent, and any app embeds any of them (ADR 0020; the wire contract is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "19.0.0",
3
+ "version": "20.0.0",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -43,7 +43,6 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "clsx": "^2.1.1",
46
- "react-native-markdown-display": "^7.0.2",
47
46
  "socket.io-client": "^4.8.3",
48
47
  "tailwind-merge": "^3.7.0",
49
48
  "zustand": "^5.0.15"
@@ -52,9 +51,9 @@
52
51
  "react": "^19.2.3",
53
52
  "react-native": "^0.86.0",
54
53
  "@expo/vector-icons": "^15.1.1",
55
- "@oxy.so/bloom": ">=10.9.0",
54
+ "@oxy.so/bloom": ">=10.17.0",
56
55
  "@oxy.so/core": ">=3.0.0",
57
- "@oxy.so/services": ">=8.0.0",
56
+ "@oxy.so/services": ">=11.12.0",
58
57
  "@tanstack/react-query": "^5.101",
59
58
  "@react-native-async-storage/async-storage": "^2.2.0",
60
59
  "expo-blur": "^57.0.0",
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * The conversation's participants: Bloom's `AgentProfile` with `[lead, ...joined]`, and the
3
- * choreography by which a consulted agent joins it (ADR 0020 §7).
3
+ * agents joining it (ADR 0020 §7).
4
4
  *
5
5
  * When the lead consults another agent (`alia.agent_start`), the agent's reply appears in the
6
6
  * thread under its own identity (anchor `reply:<agentId>`), and that avatar flies into the header
7
- * — Bloom's multi-agent join: `centeredLanding`, merging into the profile, staggered when several
8
- * join at once. The header gains the agent only when its flight lands; with reduced motion, or
9
- * when there is nothing to measure, it gains it at once. An agent already in the conversation
10
- * when the chat opens is simply there.
7
+ * through Bloom's `useAvatarJoin`: one choreography for every multi-agent conversation, Bloom's
8
+ * own `MultiAgentChat` included. The header gains the agent only when its flight lands; with
9
+ * reduced motion, or when there is nothing to measure, it gains it at once. An agent already in
10
+ * the conversation when the chat opens is simply there.
11
11
  *
12
- * This is the ONLY file that draws `AgentProfile` or builds a join flight
13
- * (`one-thread.test.tsx` holds it), so the header of the sheet, the screen and the Alia app are
14
- * one header.
12
+ * This is the ONLY file that draws `AgentProfile` or joins agents to it (`one-thread.test.tsx`
13
+ * holds it), so the header of the sheet, the screen and the Alia app are one header.
15
14
  */
16
15
 
17
16
  import { AgentProfile } from '@oxy.so/bloom/agent-reply';
18
- import { type AvatarFlights, centeredLanding } from '@oxy.so/bloom/avatar-flight';
19
- import { memo, useEffect, useMemo, useRef, useState } from 'react';
17
+ import { type AvatarFlights, useAvatarJoin } from '@oxy.so/bloom/avatar-flight';
18
+ import { memo, useMemo } from 'react';
20
19
  import { ANCHORS, useAliaFlights } from '../lib/flights';
21
20
  import {
22
21
  type AliaChatParticipants,
@@ -26,45 +25,6 @@ import {
26
25
  } from '../lib/participants';
27
26
  import type { Message } from '../runtime/types';
28
27
 
29
- /** As Bloom's multi-agent chat flies a waiting agent into its profile. */
30
- const JOIN_DURATION = 950;
31
- const JOIN_STAGGER = 120;
32
- const JOIN_LANDING_SIZE = 14;
33
-
34
- /** The flights of agents joining the header: from each one's reply, merging into the profile. */
35
- export function joinFlights(agents: readonly AliaParticipant[]) {
36
- return agents.map((agent, index) => ({
37
- key: `join:${agent.id}`,
38
- character: agent.character,
39
- from: ANCHORS.reply(agent.id),
40
- to: ANCHORS.profile,
41
- landing: centeredLanding(JOIN_LANDING_SIZE),
42
- duration: JOIN_DURATION,
43
- delay: index * JOIN_STAGGER,
44
- merge: true,
45
- }));
46
- }
47
-
48
- /** Two frames: the reply that carries the avatar mounts, then lays out, before it is measured. */
49
- function afterLayout(run: () => void): () => void {
50
- const raf =
51
- typeof requestAnimationFrame === 'function'
52
- ? requestAnimationFrame
53
- : (callback: () => void) => setTimeout(callback, 16) as unknown as number;
54
- const cancelRaf =
55
- typeof cancelAnimationFrame === 'function'
56
- ? cancelAnimationFrame
57
- : (id: number) => clearTimeout(id as unknown as ReturnType<typeof setTimeout>);
58
- let second: number | undefined;
59
- const first = raf(() => {
60
- second = raf(run);
61
- });
62
- return () => {
63
- cancelRaf(first);
64
- if (second !== undefined) cancelRaf(second);
65
- };
66
- }
67
-
68
28
  export interface UseParticipantsOptions {
69
29
  lead: AliaLead;
70
30
  messages: readonly Message[];
@@ -72,97 +32,33 @@ export interface UseParticipantsOptions {
72
32
  flights: AvatarFlights | null;
73
33
  }
74
34
 
75
- /**
76
- * Who the header shows, and the joins under way.
77
- *
78
- * One batch flies at a time: agents that start answering while a batch (or the lead's own
79
- * welcome flight) is in the air wait for it to land, then fly together, staggered.
80
- */
35
+ const fromReply = (agent: AliaParticipant) => ANCHORS.reply(agent.id);
36
+ const characterOf = (agent: AliaParticipant) => agent.character;
37
+
38
+ /** Who the header shows: the lead, then the agents it consulted as each one's avatar lands. */
81
39
  export function useParticipants({
82
40
  lead,
83
41
  messages,
84
42
  flights,
85
43
  }: UseParticipantsOptions): AliaChatParticipants {
86
- const consulted = useMemo(() => consultedAgents(messages, lead.id), [messages, lead.id]);
87
- const [joined, setJoined] = useState<AliaParticipant[]>([]);
88
- /** Agents waiting for a flight, and the batch in the air (`launched` once it took off). */
89
- const queue = useRef<AliaParticipant[]>([]);
90
- const [batch, setBatch] = useState<{ agents: AliaParticipant[]; launched: boolean } | null>(null);
91
- const [queued, setQueued] = useState(0);
92
- const flightsRef = useRef(flights);
93
- flightsRef.current = flights;
94
-
95
- // A new lead is a new conversation: nobody has joined it.
96
- // biome-ignore lint/correctness/useExhaustiveDependencies: reset on the lead alone
97
- useEffect(() => {
98
- queue.current = [];
99
- setJoined([]);
100
- setBatch(null);
101
- }, [lead.id]);
102
-
103
- // Everybody who answered is in the header, or on the way to it.
104
- useEffect(() => {
105
- const known = new Set([
106
- ...joined.map((agent) => agent.id),
107
- ...queue.current.map((agent) => agent.id),
108
- ...(batch?.agents.map((agent) => agent.id) ?? []),
109
- ]);
110
- const already: AliaParticipant[] = [];
111
- let waiting = false;
112
- for (const { participant, live } of consulted) {
113
- if (known.has(participant.id)) continue;
114
- if (live && flightsRef.current !== null) {
115
- queue.current = [...queue.current, participant];
116
- waiting = true;
117
- } else {
118
- already.push(participant);
119
- }
120
- }
121
- if (already.length > 0) setJoined((current) => [...current, ...already]);
122
- if (waiting) setQueued((count) => count + 1);
123
- }, [consulted, joined, batch]);
124
-
125
- // A batch takes off once nothing else is landing on the header.
126
- const landing = flights?.isLanding(ANCHORS.profile) ?? false;
127
- // biome-ignore lint/correctness/useExhaustiveDependencies: `queued` signals the queue (a ref) grew
128
- useEffect(() => {
129
- if (batch !== null || landing || queue.current.length === 0) return;
130
- const agents = queue.current;
131
- queue.current = [];
132
- setBatch({ agents, launched: false });
133
- }, [batch, landing, queued]);
134
-
135
- useEffect(() => {
136
- if (batch === null || batch.launched) return;
137
- let live = true;
138
- const cancel = afterLayout(() => {
139
- const current = flightsRef.current;
140
- void (current ? current.launch(joinFlights(batch.agents)) : Promise.resolve(false)).then(
141
- (flying) => {
142
- if (!live) return;
143
- if (flying) setBatch({ agents: batch.agents, launched: true });
144
- else {
145
- // Reduced motion, nothing measurable, or superseded: they join without flying.
146
- setJoined((current) => [...current, ...batch.agents]);
147
- setBatch(null);
148
- }
149
- },
150
- );
151
- });
152
- return () => {
153
- live = false;
154
- cancel();
155
- };
156
- }, [batch]);
157
-
158
- // The batch is in: the header gains its agents as their avatars land.
159
- useEffect(() => {
160
- if (batch === null || !batch.launched || landing) return;
161
- setJoined((current) => [...current, ...batch.agents]);
162
- setBatch(null);
163
- }, [batch, landing]);
164
-
165
- return useMemo(() => ({ lead, joined }), [lead, joined]);
44
+ const arrivals = useMemo(
45
+ () =>
46
+ consultedAgents(messages, lead.id).map(({ participant, live }) => ({
47
+ agent: participant,
48
+ live,
49
+ })),
50
+ [messages, lead.id],
51
+ );
52
+ const joined = useAvatarJoin({
53
+ arrivals,
54
+ flights,
55
+ from: fromReply,
56
+ to: ANCHORS.profile,
57
+ character: characterOf,
58
+ // A new lead is a new conversation: nobody has joined it.
59
+ resetKey: lead.id,
60
+ });
61
+ return useMemo(() => ({ lead, joined: [...joined] }), [lead, joined]);
166
62
  }
167
63
 
168
64
  export interface AliaParticipantsProps {
@@ -1,8 +1,5 @@
1
- import { useAiChatShell } from '@oxy.so/bloom/ai-chat';
2
- import { Button } from '@oxy.so/bloom/button';
3
- import { RiMenuLine } from '@oxy.so/bloom/icons/RiMenuLine';
1
+ import { AiChatHeader } from '@oxy.so/bloom/ai-chat';
4
2
  import { memo } from 'react';
5
- import { View } from 'react-native';
6
3
  import type { AliaChatParticipants } from '../lib/participants';
7
4
  import { AliaParticipants } from './AliaParticipants';
8
5
 
@@ -13,48 +10,18 @@ export interface AliaParticipantsHeaderProps {
13
10
  }
14
11
 
15
12
  /**
16
- * The conversation's header inside Bloom's AI chat shell: the participants as its title, between
17
- * the shell's own buttons — the navigation drawer's on a narrow screen and the panel's below `xl`,
18
- * as `AiChatMobileHeader` places them. That header's title is a string; this one is the
19
- * conversation's participants, so a host puts this in `chrome.header` instead of drawing a title
20
- * of its own. Outside a shell it is the participants alone.
13
+ * The conversation's header inside Bloom's AI chat shell: the participants as its title, in
14
+ * Bloom's `AiChatHeader`, between the shell's own navigation and panel buttons on a compact
15
+ * screen. A host puts this in `chrome.header` instead of drawing a title of its own. Outside a
16
+ * shell it is the participants alone.
21
17
  */
22
18
  export const AliaParticipantsHeader = memo(function AliaParticipantsHeader({
23
19
  participants,
24
20
  onPress,
25
21
  }: AliaParticipantsHeaderProps) {
26
- const shell = useAiChatShell();
27
- const nav = shell?.compact === true && shell.navCollapsed && shell.hasNav;
28
- const panel = shell?.compact === true && shell.hasPanel;
29
22
  return (
30
- <View className="min-h-12 shrink-0 flex-row items-center justify-between px-3 pt-3">
31
- <View className="w-10">
32
- {nav && shell ? (
33
- <Button
34
- size="md"
35
- icon={RiMenuLine}
36
- accessibilityLabel={shell.labels.openNavigation}
37
- onPress={shell.openNav}
38
- appearance="plain"
39
- tone="neutral"
40
- />
41
- ) : null}
42
- </View>
43
- <View className="min-w-0 shrink items-center">
44
- <AliaParticipants participants={participants} onPress={onPress} />
45
- </View>
46
- <View className="w-10 items-end">
47
- {panel && shell ? (
48
- <Button
49
- size="md"
50
- icon={shell.panelIcon}
51
- accessibilityLabel={shell.labels.openPanel(shell.panelLabel)}
52
- onPress={shell.openPanel}
53
- appearance="plain"
54
- tone="neutral"
55
- />
56
- ) : null}
57
- </View>
58
- </View>
23
+ <AiChatHeader>
24
+ <AliaParticipants participants={participants} onPress={onPress} />
25
+ </AiChatHeader>
59
26
  );
60
27
  });
@@ -1,4 +1,5 @@
1
1
  import { CodeBlock } from '@oxy.so/bloom/code';
2
+ import { Markdown } from '@oxy.so/bloom/markdown';
2
3
  import { Text } from '@oxy.so/bloom/typography';
3
4
  import * as Clipboard from 'expo-clipboard';
4
5
  import * as WebBrowser from 'expo-web-browser';
@@ -13,7 +14,6 @@ import {
13
14
  } from '../lib/citations';
14
15
  import { useLabels } from '../lib/labels';
15
16
  import type { ToolInvocation } from '../runtime/types';
16
- import { AliaMarkdown, type RenderCodeBlock } from './Markdown';
17
17
  import { Banner, CompactList, Comparison, Credibility, RichImage, Timeline } from './RichBlocks';
18
18
 
19
19
  // Cheap pre-check: plain markdown (the overwhelmingly common case) skips the
@@ -227,13 +227,11 @@ function openSource(url: string): void {
227
227
  /**
228
228
  * The references section of a research answer, as links.
229
229
  *
230
- * The API now writes each entry as a Markdown link, and the SDK's renderer
231
- * would make those pressable on its own — but not ACCESSIBLE: its link rule
232
- * is a `Text` with `onPress`, which a screen reader reads as text and a
233
- * keyboard cannot reach. So the section is cut off the Markdown and drawn
234
- * here, one `accessibilityRole="link"` per entry — an `<a href>` on web, so
235
- * Tab reaches it and Enter opens it — named "Source n: title". Older answers,
236
- * whose entries were a title over a bare URL, split the same way.
230
+ * The API writes each entry as a Markdown link. The section is cut off the
231
+ * Markdown and drawn here as a list of sources rather than running text: one
232
+ * `accessibilityRole="link"` per entry — an `<a href>` on web, so Tab reaches
233
+ * it and Enter opens it — named "Source n: title", with its domain. Older
234
+ * answers, whose entries were a title over a bare URL, split the same way.
237
235
  */
238
236
  function ReferenceList({
239
237
  entries,
@@ -300,10 +298,10 @@ const copyCode = async (code: string) => {
300
298
 
301
299
  /**
302
300
  * Fenced code as Bloom's code card: the language chip, a copy button and
303
- * numbered lines. Module-level so its identity is stable — it is part of the
304
- * SDK renderer's rules memo.
301
+ * numbered lines. Module-level so its identity is stable, as Bloom's
302
+ * `Markdown` asks of `renderCodeBlock`.
305
303
  */
306
- const renderCodeBlock: RenderCodeBlock = (code, language) => (
304
+ const renderCodeBlock = (code: string, language?: string) => (
307
305
  <CodeBlock code={code} language={language} onCopy={copyCode} />
308
306
  );
309
307
 
@@ -318,20 +316,23 @@ const renderCodeBlock: RenderCodeBlock = (code, language) => (
318
316
  * are normalised first. A message with neither prop renders exactly as
319
317
  * before.
320
318
  *
321
- * `onCitationPress` replaces opening the URL for the references list. The
322
- * inline markers are rendered by the SDK's link rule, which opens the URL
323
- * itself and offers no hook, so they do not reach it.
319
+ * `onCitationPress` replaces opening a source's URL, for the references list
320
+ * and for an inline marker alike; any other link opens as Bloom's `Markdown`
321
+ * opens it. `streaming` is the reply still arriving: Bloom's `Markdown` closes
322
+ * an unfinished trailing construct instead of showing its raw markers.
324
323
  */
325
324
  export function ReplyMarkdown({
326
325
  content,
327
326
  toolInvocations,
328
327
  researchSources,
329
328
  onCitationPress,
329
+ streaming = false,
330
330
  }: {
331
331
  content: string;
332
332
  toolInvocations?: ToolInvocation[];
333
333
  researchSources?: Array<{ id: number; url: string; title: string }> | null;
334
334
  onCitationPress?: (source: CitationSource) => void;
335
+ streaming?: boolean;
335
336
  }) {
336
337
  const sources = useMemo(
337
338
  () => extractCitationSources(toolInvocations, researchSources),
@@ -340,13 +341,29 @@ export function ReplyMarkdown({
340
341
  const { body, references } = useMemo(() => splitReferences(content, sources), [content, sources]);
341
342
  const linked = useMemo(() => linkifyCitations(body, sources), [body, sources]);
342
343
  const blocks = useMemo(() => parseSpecialBlocks(linked), [linked]);
344
+ const onLinkPress = useMemo(() => {
345
+ if (!onCitationPress) return undefined;
346
+ return (url: string) => {
347
+ const source = sources.find((entry) => entry.url === url);
348
+ if (source) onCitationPress(source);
349
+ else openSource(url);
350
+ };
351
+ }, [onCitationPress, sources]);
343
352
 
344
353
  return (
345
354
  <View>
346
355
  {blocks.map((block, idx) => {
347
356
  if (block.type === 'text') {
348
357
  return (
349
- <AliaMarkdown key={idx} content={block.content} renderCodeBlock={renderCodeBlock} />
358
+ <Markdown
359
+ key={idx}
360
+ size="lg"
361
+ streaming={streaming && idx === blocks.length - 1}
362
+ renderCodeBlock={renderCodeBlock}
363
+ onLinkPress={onLinkPress}
364
+ >
365
+ {block.content}
366
+ </Markdown>
350
367
  );
351
368
  } else if (block.type === 'block' && block.blockType) {
352
369
  return renderBlock(block.blockType, block.data, idx);
@@ -447,6 +447,7 @@ function ReplyTurn({
447
447
  content={text}
448
448
  toolInvocations={message.toolInvocations}
449
449
  researchSources={message.researchProgress?.sources}
450
+ streaming={message.isStreaming === true}
450
451
  />
451
452
  )}
452
453
  </AiChatAssistantMessage>
@@ -25,6 +25,7 @@ import { VoiceCall, type VoiceCallSnapshot, type VoiceSocketFactory } from '../l
25
25
  import { VoiceLevelChannel, type VoiceLevelSource } from '../lib/voice-levels';
26
26
  import type { ProductVoice, VoiceCloseReason } from '../lib/voice-protocol';
27
27
  import { ALIA_API_URL } from '../lib/origins';
28
+ import { useAppAssertionToken } from '../runtime/app-assertion';
28
29
 
29
30
  export interface UseVoiceRoomOptions {
30
31
  apiUrl?: string;
@@ -99,6 +100,7 @@ export function agentStateOf(state: VoiceCallState): AgentState {
99
100
 
100
101
  export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
101
102
  const { oxyServices } = useOxy();
103
+ const getAppAssertion = useAppAssertionToken();
102
104
  const levels = useMemo(() => new VoiceLevelChannel(), []);
103
105
  const [snapshot, setSnapshot] = useState<VoiceCallSnapshot>(IDLE);
104
106
  const callRef = useRef<VoiceCall | null>(null);
@@ -119,6 +121,7 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
119
121
  const call = new VoiceCall({
120
122
  apiUrl: current.apiUrl || ALIA_API_URL,
121
123
  getToken,
124
+ getAppAssertion,
122
125
  conversationId: current.conversationId ?? newConversationId(),
123
126
  ...(current.agentId === undefined ? {} : { agentId: current.agentId }),
124
127
  voice: current.voicePreference ?? 'female',
@@ -134,7 +137,7 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
134
137
  });
135
138
  callRef.current = call;
136
139
  await call.start();
137
- }, [getToken, levels]);
140
+ }, [getToken, getAppAssertion, levels]);
138
141
 
139
142
  /** Hang up and forget the call: the room is back to `disconnected`. */
140
143
  const disconnect = useCallback(() => {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ export type {
10
10
  AliaChatContentProps,
11
11
  AliaChatState,
12
12
  } from './components/AliaChatContent';
13
- export { AliaParticipants, joinFlights, useParticipants } from './components/AliaParticipants';
13
+ export { AliaParticipants, useParticipants } from './components/AliaParticipants';
14
14
  export type { AliaParticipantsProps, UseParticipantsOptions } from './components/AliaParticipants';
15
15
  export { AliaParticipantsHeader } from './components/AliaParticipantsHeader';
16
16
  export type { AliaParticipantsHeaderProps } from './components/AliaParticipantsHeader';
@@ -47,8 +47,6 @@ export { useAliaActivity } from './hooks/useAliaActivity';
47
47
  export type { UseAliaActivityOptions } from './hooks/useAliaActivity';
48
48
  export { IdentityMark } from './components/IdentityMark';
49
49
  export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
50
- export { AliaMarkdown } from './components/Markdown';
51
- export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
52
50
  export { ReplyMarkdown } from './components/ReplyMarkdown';
53
51
  export { AliaThread } from './components/thread/AliaThread';
54
52
  export type { AliaThreadProps, NewConversationOfferProps } from './components/thread/AliaThread';
@@ -77,6 +77,11 @@ export interface VoiceCallOptions {
77
77
  readonly apiUrl: string;
78
78
  /** The person's Oxy session token, read at connect and every few minutes. */
79
79
  readonly getToken: () => string | null;
80
+ /**
81
+ * The host's Oxy app assertion, sent with the first `auth` of each socket so
82
+ * an agent Oxy confines to an app opens only there (ADR 0021). Absent: none.
83
+ */
84
+ readonly getAppAssertion?: () => Promise<string | null>;
80
85
  readonly conversationId: string;
81
86
  readonly agentId?: string;
82
87
  readonly voice: ProductVoice;
@@ -251,7 +256,23 @@ export class VoiceCall {
251
256
  return;
252
257
  }
253
258
  this.lastToken = token;
254
- socket.send(JSON.stringify({ type: 'auth', token } satisfies VoiceClientEvent));
259
+ const getAppAssertion = this.options.getAppAssertion;
260
+ if (getAppAssertion === undefined) {
261
+ socket.send(JSON.stringify({ type: 'auth', token } satisfies VoiceClientEvent));
262
+ return;
263
+ }
264
+ void getAppAssertion()
265
+ .catch(() => null)
266
+ .then((appAssertion) => {
267
+ if (this.socket !== socket || this.finished || socket.readyState !== OPEN) return;
268
+ socket.send(
269
+ JSON.stringify({
270
+ type: 'auth',
271
+ token,
272
+ ...(appAssertion ? { appAssertion } : {}),
273
+ } satisfies VoiceClientEvent),
274
+ );
275
+ });
255
276
  };
256
277
  socket.onmessage = (event) => {
257
278
  if (this.socket !== socket || this.finished) return;
@@ -85,7 +85,8 @@ const CLOSE_REASONS: ReadonlySet<string> = new Set<VoiceCloseReason>([
85
85
  export type VoiceRole = 'user' | 'assistant';
86
86
 
87
87
  export type VoiceClientEvent =
88
- | { type: 'auth'; token: string }
88
+ /** `appAssertion`: the host's Oxy app assertion (ADR 0021); a socket cannot carry the header. */
89
+ | { type: 'auth'; token: string; appAssertion?: string }
89
90
  | {
90
91
  type: 'session.start';
91
92
  protocol: number;
@@ -1,5 +1,10 @@
1
1
  import { useOxy } from '@oxy.so/services/ui/client';
2
2
  import { useMemo } from 'react';
3
+ import {
4
+ type AppAssertionHeaders,
5
+ NO_APP_ASSERTION,
6
+ useAppAssertionHeaders,
7
+ } from './app-assertion';
3
8
  import { useAliaRuntime } from './config';
4
9
 
5
10
  /**
@@ -79,24 +84,33 @@ export interface LinkableOxy {
79
84
  };
80
85
  }
81
86
 
82
- /** Build the API over an Oxy client for `apiUrl`. Exposed for tests and for non-React callers. */
83
- export function createAliaApi(oxyServices: LinkableOxy, apiUrl: string): AliaApi {
87
+ /**
88
+ * Build the API over an Oxy client for `apiUrl`. Exposed for tests and for non-React callers.
89
+ *
90
+ * @param appAssertionHeaders the host's `X-Oxy-App-Assertion` (`app-assertion.ts`), on every request
91
+ */
92
+ export function createAliaApi(
93
+ oxyServices: LinkableOxy,
94
+ apiUrl: string,
95
+ appAssertionHeaders: AppAssertionHeaders = NO_APP_ASSERTION,
96
+ ): AliaApi {
84
97
  async function send<T>(
85
98
  method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
86
99
  path: string,
87
100
  request: AliaApiRequest = {},
88
101
  ): Promise<AliaApiResult<T>> {
102
+ const assertion = await appAssertionHeaders();
89
103
  const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
90
104
  try {
105
+ const headers = {
106
+ ...assertion,
107
+ ...(request.body === undefined ? {} : { 'Content-Type': 'application/json' }),
108
+ };
91
109
  const response = await linked.client.requestAuthenticatedResponse({
92
110
  method,
93
111
  url: withParams(path, request.params),
94
- ...(request.body === undefined
95
- ? {}
96
- : {
97
- body: JSON.stringify(request.body),
98
- headers: { 'Content-Type': 'application/json' },
99
- }),
112
+ ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
113
+ ...(Object.keys(headers).length === 0 ? {} : { headers }),
100
114
  ...(request.signal ? { signal: request.signal } : {}),
101
115
  });
102
116
  const data = await readBody(response);
@@ -119,5 +133,9 @@ export function createAliaApi(oxyServices: LinkableOxy, apiUrl: string): AliaApi
119
133
  export function useAliaApi(): AliaApi {
120
134
  const { oxyServices } = useOxy();
121
135
  const { apiUrl } = useAliaRuntime();
122
- return useMemo(() => createAliaApi(oxyServices as LinkableOxy, apiUrl), [oxyServices, apiUrl]);
136
+ const appAssertionHeaders = useAppAssertionHeaders();
137
+ return useMemo(
138
+ () => createAliaApi(oxyServices as LinkableOxy, apiUrl, appAssertionHeaders),
139
+ [oxyServices, apiUrl, appAssertionHeaders],
140
+ );
123
141
  }
@@ -0,0 +1,53 @@
1
+ // `@oxy.so/services/ui`, not the root barrel (which ships the sign-in panels, `check:entries`):
2
+ // `ui/client` does not export `useAppAssertion`.
3
+ import { useAppAssertion } from '@oxy.so/services/ui';
4
+ import { useMemo } from 'react';
5
+
6
+ /**
7
+ * Which Oxy app this conversation is in, for Alia (Alia ADR 0021, Oxy ADR 0045).
8
+ *
9
+ * An Oxy app may confine its own agent to itself — Clarity's assistant opens
10
+ * only inside Clarity — and the official apps share one session that names no
11
+ * app, so Alia reads the app from the Oxy-signed assertion the host's
12
+ * `OxyProvider` obtains and keeps current. Every request the SDK makes to Alia
13
+ * carries it as `X-Oxy-App-Assertion` when there is one; without one (an app
14
+ * Oxy cannot recognise yet, a signed-out person) nothing is sent, and only a
15
+ * confined agent is affected.
16
+ *
17
+ * The SDK never builds, stores or inspects the assertion: it is the host's
18
+ * provider's, read at send time so it is never stale.
19
+ */
20
+ export type AppAssertionHeaders = () => Promise<Record<string, string>>;
21
+
22
+ /** No app assertion: what a caller outside a React tree, or a test, passes. */
23
+ export const NO_APP_ASSERTION: AppAssertionHeaders = async () => ({});
24
+
25
+ /** The host's app assertion as request headers, `{}` when it has none or cannot get one. */
26
+ export function useAppAssertionHeaders(): AppAssertionHeaders {
27
+ const { getAppAssertionHeaders } = useAppAssertion();
28
+ return useMemo(
29
+ () => async () => {
30
+ try {
31
+ return await getAppAssertionHeaders();
32
+ } catch {
33
+ return {};
34
+ }
35
+ },
36
+ [getAppAssertionHeaders],
37
+ );
38
+ }
39
+
40
+ /** The host's app assertion itself, for the voice socket, which cannot carry a header. */
41
+ export function useAppAssertionToken(): () => Promise<string | null> {
42
+ const { getAppAssertion } = useAppAssertion();
43
+ return useMemo(
44
+ () => async () => {
45
+ try {
46
+ return await getAppAssertion();
47
+ } catch {
48
+ return null;
49
+ }
50
+ },
51
+ [getAppAssertion],
52
+ );
53
+ }
@@ -3,6 +3,7 @@ import { useOxy } from '@oxy.so/services/ui/client';
3
3
  import { useQueryClient } from '@tanstack/react-query';
4
4
  import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  import { useLabels } from '../lib/labels';
6
+ import { useAppAssertionHeaders } from './app-assertion';
6
7
  import { type AliaArtifact, useAliaRuntime } from './config';
7
8
  import { errorCode, errorMessage as getErrorMessage, errorName, errorStatus } from './error-utils';
8
9
  import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
@@ -201,6 +202,7 @@ export function useConversationStream({
201
202
  */
202
203
  const turnOptionsRef = useRef(new Map<string, Pick<SendOptions, 'mcpServerId' | 'skillNames'>>());
203
204
  const { oxyServices, user } = useOxy();
205
+ const appAssertionHeaders = useAppAssertionHeaders();
204
206
  const queryClient = useQueryClient();
205
207
  const abortControllerRef = useRef<AbortController | null>(null);
206
208
 
@@ -615,11 +617,14 @@ export function useConversationStream({
615
617
 
616
618
  // Authentication, the preflight refresh and the single 401 replay belong
617
619
  // to the linked Oxy client; this layer never reads or writes a bearer.
620
+ // Which Oxy app this turn is in (ADR 0021): an agent Oxy confines to an app opens only there.
621
+ const appAssertion = await appAssertionHeaders();
618
622
  linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
619
623
  const response = await linked.client.requestAuthenticatedResponse({
620
624
  method: 'POST',
621
625
  url: API_ROUTES.chat,
622
626
  headers: {
627
+ ...appAssertion,
623
628
  Accept: 'text/event-stream',
624
629
  'Content-Type': 'application/json',
625
630
  ...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
@@ -1347,6 +1352,7 @@ export function useConversationStream({
1347
1352
  host,
1348
1353
  labels,
1349
1354
  oxyServices,
1355
+ appAssertionHeaders,
1350
1356
  queryClient,
1351
1357
  conversationId,
1352
1358
  model,
@@ -1,515 +0,0 @@
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';
8
-
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' })!;
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. */
318
- export type RenderCodeBlock = (code: string, language: string | undefined) => ReactNode;
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
-
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;
347
- content: string;
348
- colors?: Partial<AliaColors>;
349
- /**
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.
353
- */
354
- renderCodeBlock?: RenderCodeBlock;
355
- }
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
-
363
- /**
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).
369
- */
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) {
451
- return (
452
- <StableMarkdown
453
- rules={rules}
454
- style={styles}
455
- markdownit={PARSER}
456
- allowedImageHandlers={IMAGE_HANDLERS}
457
- topLevelMaxExceededItem={TOP_LEVEL_EXCEEDED}
458
- >
459
- {content}
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>
514
- );
515
- }