@alia.onl/sdk 12.0.0 → 13.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,7 +23,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
23
23
  import { KeyboardAvoidingView } from '../lib/keyboard';
24
24
  import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
25
25
  import { AliaChatHeader } from './AliaChatHeader';
26
- import type { WelcomeSuggestion } from './AliaWelcomeMessage';
26
+ import type { AliaSuggestion } from '../types';
27
27
  import type { AliaChatLabels } from '../lib/labels';
28
28
  import type { VoiceSessionComponent } from '../types';
29
29
 
@@ -34,8 +34,14 @@ export interface AliaChatScreenProps {
34
34
  model?: string;
35
35
  /** API URL override */
36
36
  apiUrl?: string;
37
- /** Optional header left action (e.g., back button) */
37
+ /** Draws the header's back button; omit where there is nowhere to go back to. */
38
+ onBack?: () => void;
39
+ /** Optional header left content instead of the back button (e.g. a drawer's menu button) */
38
40
  headerLeft?: React.ReactNode;
41
+ /** Draws the header's share button once there is a conversation; omitted, no share button. */
42
+ onShare?: () => void;
43
+ /** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
44
+ headerMenu?: React.ReactNode;
39
45
  /** Optional header right action (e.g., settings button) */
40
46
  headerRight?: React.ReactNode;
41
47
  /** Welcome screen greeting */
@@ -43,42 +49,44 @@ export interface AliaChatScreenProps {
43
49
  /** Welcome screen subtitle */
44
50
  welcomeSubtitle?: string;
45
51
  /** Welcome screen suggestions */
46
- welcomeSuggestions?: WelcomeSuggestion[];
52
+ welcomeSuggestions?: AliaSuggestion[];
53
+ /** The agents the host knows, with their look: a delegation's thinking avatar is drawn from them until the agent answers. */
54
+ agents?: AliaChatContentProps['agents'];
55
+ /** What the host found for the typed text, shown over the composer (see `onSuggestionQuery`). */
56
+ suggestionMatches?: AliaSuggestion[];
57
+ /** The typed text, once typing has settled, for the host to search suggestions with. */
58
+ onSuggestionQuery?: (query: string) => void;
47
59
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
48
60
  isDarkMode?: boolean;
49
61
  /** Voice capability — `VoiceSession` from `@alia.onl/sdk/voice`. */
50
62
  voiceSession?: VoiceSessionComponent;
51
63
  /** Overrides for the chat's own copy (header, composer, plan, research…); omitted, English. */
52
64
  labels?: Partial<AliaChatLabels>;
65
+ /** Where the 3D avatar's runtime is served from; omitted, Alia's shared copy (`ALIA_CHARACTER_RUNTIME_URL`). */
66
+ characterRuntimeUrl?: string;
53
67
  }
54
68
 
55
69
  export function AliaChatScreen({
56
70
  clientContext,
57
71
  model,
58
72
  apiUrl,
73
+ onBack,
59
74
  headerLeft,
75
+ onShare,
76
+ headerMenu,
60
77
  headerRight,
61
78
  welcomeGreeting,
62
79
  welcomeSubtitle,
63
80
  welcomeSuggestions,
81
+ agents,
82
+ suggestionMatches,
83
+ onSuggestionQuery,
64
84
  isDarkMode,
65
85
  voiceSession,
66
86
  labels,
87
+ characterRuntimeUrl,
67
88
  }: AliaChatScreenProps) {
68
89
  const insets = useSafeAreaInsets();
69
- const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
70
- ({ hasMessages, clear, markState }) => (
71
- <AliaChatHeader
72
- markState={markState}
73
- hasMessages={hasMessages}
74
- onClear={clear}
75
- leading={headerLeft}
76
- trailing={headerRight}
77
- />
78
- ),
79
- [headerLeft, headerRight],
80
- );
81
-
82
90
  // The screen owns the whole window, so it keeps its composer out of both
83
91
  // system bars: the top inset clears the status bar, the bottom one the
84
92
  // gesture bar or home indicator an edge-to-edge app draws over.
@@ -89,6 +97,22 @@ export function AliaChatScreen({
89
97
  // keyboard's height minus the inset: the composer sits on the keyboard, not
90
98
  // one inset above it. (Its parent-relative layout would read as zero overlap
91
99
  // this deep in a host's tree, which is why the offset is measured.)
100
+ const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
101
+ ({ hasMessages, clear, agents: answered }) => (
102
+ <AliaChatHeader
103
+ hasMessages={hasMessages}
104
+ agents={answered}
105
+ onClear={clear}
106
+ onBack={onBack}
107
+ onShare={onShare}
108
+ menu={headerMenu}
109
+ leading={headerLeft}
110
+ trailing={headerRight}
111
+ />
112
+ ),
113
+ [onBack, onShare, headerMenu, headerLeft, headerRight],
114
+ );
115
+
92
116
  return (
93
117
  <View
94
118
  className="flex-1 bg-background-full"
@@ -102,9 +126,13 @@ export function AliaChatScreen({
102
126
  welcomeGreeting={welcomeGreeting}
103
127
  welcomeSubtitle={welcomeSubtitle}
104
128
  welcomeSuggestions={welcomeSuggestions}
129
+ agents={agents}
130
+ suggestionMatches={suggestionMatches}
131
+ onSuggestionQuery={onSuggestionQuery}
105
132
  isDarkMode={isDarkMode}
106
133
  voiceSession={voiceSession}
107
134
  labels={labels}
135
+ characterRuntimeUrl={characterRuntimeUrl}
108
136
  header={header}
109
137
  />
110
138
  </KeyboardAvoidingView>
@@ -1,11 +1,15 @@
1
+ import { AvatarFlightLayer, useAvatarFlights } from '@oxy.so/bloom/avatar-flight';
1
2
  import { BottomSheet, type BottomSheetRef } from '@oxy.so/bloom/bottom-sheet';
2
- import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
3
- import { useColorScheme, View } from 'react-native';
3
+ import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
+ import { useTheme } from '@oxy.so/bloom/theme';
5
+ import { useWindowDimensions, View } from 'react-native';
6
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
+ import { BREAKPOINTS } from '@oxy.so/bloom/styles';
4
8
  import type { AliaChatLabels } from '../lib/labels';
5
9
  import type { VoiceSessionComponent } from '../types';
6
- import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
10
+ import { AliaChatContent, type AliaChatContentProps, type AliaTurn } from './AliaChatContent';
7
11
  import { AliaChatHeader } from './AliaChatHeader';
8
- import type { WelcomeSuggestion } from './AliaWelcomeMessage';
12
+ import type { AliaSuggestion } from '../types';
9
13
 
10
14
  /**
11
15
  * TODO(bloom): drop this once Bloom's sheet is rounder by default. Its detached
@@ -16,6 +20,9 @@ import type { WelcomeSuggestion } from './AliaWelcomeMessage';
16
20
  */
17
21
  const SHEET_RADIUS = 40;
18
22
 
23
+ /** The sheet floats (detached) from tablet width up; on a phone it is flush with the screen edges. */
24
+ const DETACHED_FROM = BREAKPOINTS.md;
25
+
19
26
  export interface AliaChatSheetProps {
20
27
  /** App context injected into system prompt */
21
28
  clientContext?: string;
@@ -28,7 +35,13 @@ export interface AliaChatSheetProps {
28
35
  /** Welcome screen subtitle */
29
36
  welcomeSubtitle?: string;
30
37
  /** Welcome screen suggestions */
31
- welcomeSuggestions?: WelcomeSuggestion[];
38
+ welcomeSuggestions?: AliaSuggestion[];
39
+ /** The agents the host knows, with their look: a delegation's thinking avatar is drawn from them until the agent answers. */
40
+ agents?: AliaChatContentProps['agents'];
41
+ /** What the host found for the typed text, shown over the composer (see `onSuggestionQuery`). */
42
+ suggestionMatches?: AliaSuggestion[];
43
+ /** The typed text, once typing has settled, for the host to search suggestions with. */
44
+ onSuggestionQuery?: (query: string) => void;
32
45
  /** Dark-mode flag — forwarded to the ambient wave overlay (defaults to the sheet's own color scheme). */
33
46
  isDarkMode?: boolean;
34
47
  /**
@@ -39,6 +52,12 @@ export interface AliaChatSheetProps {
39
52
  voiceSession?: VoiceSessionComponent;
40
53
  /** Overrides for the chat's own copy (header, composer, plan, research…); omitted, English. */
41
54
  labels?: Partial<AliaChatLabels>;
55
+ /** Where the 3D avatar's runtime is served from; omitted, Alia's shared copy (`ALIA_CHARACTER_RUNTIME_URL`). */
56
+ characterRuntimeUrl?: string;
57
+ /** Draws the header's share button once there is a conversation; omitted, no share button. */
58
+ onShare?: () => void;
59
+ /** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
60
+ headerMenu?: React.ReactNode;
42
61
  }
43
62
 
44
63
  export interface AliaChatSheetRef {
@@ -55,83 +74,158 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
55
74
  welcomeGreeting,
56
75
  welcomeSubtitle,
57
76
  welcomeSuggestions,
77
+ agents,
78
+ suggestionMatches,
79
+ onSuggestionQuery,
58
80
  isDarkMode,
59
81
  voiceSession,
60
82
  labels,
83
+ characterRuntimeUrl,
84
+ onShare,
85
+ headerMenu,
61
86
  },
62
87
  ref,
63
88
  ) => {
64
- const isDark = useColorScheme() === 'dark';
65
- const sheetRef = useRef<BottomSheetRef>(null);
66
- const chatStopRef = useRef<(() => void) | null>(null);
89
+ // Bloom's resolved mode, not the system's scheme.
90
+ const { isDark } = useTheme();
91
+ const mainRef = useRef<BottomSheetRef>(null);
92
+ const conversationRef = useRef<BottomSheetRef>(null);
93
+ // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
94
+ const mainStopRef = useRef<(() => void) | null>(null);
95
+ const conversationStopRef = useRef<(() => void) | null>(null);
96
+
97
+ // The conversation sheet, stacked over the main one while a chat is open. The key makes
98
+ // every conversation a fresh chat.
99
+ const [conversation, setConversation] = useState<{ key: number; turn: AliaTurn } | null>(null);
67
100
 
68
- const stopChat = useCallback(() => {
69
- chatStopRef.current?.();
101
+ const registerMainStop = useCallback((stop: (() => void) | null) => {
102
+ mainStopRef.current = stop;
103
+ }, []);
104
+ const registerConversationStop = useCallback((stop: (() => void) | null) => {
105
+ conversationStopRef.current = stop;
70
106
  }, []);
71
107
 
72
- const registerChatStop = useCallback((stop: (() => void) | null) => {
73
- chatStopRef.current = stop;
108
+ const startConversation = useCallback((turn: AliaTurn) => {
109
+ setConversation((current) => ({ key: (current?.key ?? 0) + 1, turn }));
110
+ }, []);
111
+ useEffect(() => {
112
+ if (conversation) conversationRef.current?.present();
113
+ }, [conversation]);
114
+
115
+ // Back, and the menu's Clear: close the conversation, back to the main screen underneath.
116
+ const closeConversation = useCallback(() => {
117
+ conversationStopRef.current?.();
118
+ conversationRef.current?.dismiss();
119
+ }, []);
120
+ const onConversationDismissAttempt = useCallback(() => {
121
+ conversationStopRef.current?.();
122
+ return true;
74
123
  }, []);
75
124
 
76
125
  const handleDismiss = useCallback(() => {
77
- stopChat();
78
- sheetRef.current?.dismiss();
79
- }, [stopChat]);
126
+ conversationStopRef.current?.();
127
+ mainStopRef.current?.();
128
+ conversationRef.current?.dismiss();
129
+ mainRef.current?.dismiss();
130
+ }, []);
80
131
 
81
132
  useImperativeHandle(
82
133
  ref,
83
- () => ({ present: () => sheetRef.current?.present(), dismiss: handleDismiss }),
134
+ () => ({ present: () => mainRef.current?.present(), dismiss: handleDismiss }),
84
135
  [handleDismiss],
85
136
  );
86
137
 
87
- // A drag, backdrop press, Back or Escape starts closing here: stop the turn first.
88
- const onDismissAttempt = useCallback(() => {
89
- stopChat();
138
+ // Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
139
+ const onMainDismissAttempt = useCallback(() => {
140
+ mainStopRef.current?.();
141
+ conversationStopRef.current?.();
142
+ conversationRef.current?.dismiss();
90
143
  return true;
91
- }, [stopChat]);
92
-
93
- // The top padding clears the 28px drag handle, which Bloom floats over the content instead
94
- // of reserving room for. Stable, so the chat's memoised header only re-renders when it must.
95
- const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
96
- ({ hasMessages, clear, markState }) => (
97
- <View className="pt-8">
98
- <AliaChatHeader
99
- markState={markState}
100
- hasMessages={hasMessages}
101
- onClear={clear}
102
- onClose={handleDismiss}
103
- />
104
- </View>
105
- ),
106
- [handleDismiss],
107
- );
144
+ }, []);
145
+
146
+ const { width } = useWindowDimensions();
147
+ const detached = width >= DETACHED_FROM;
148
+ // Flush with the screen edge the sheet runs behind the home indicator: the composer clears it.
149
+ const { bottom } = useSafeAreaInsets();
150
+ const sheetStyle = detached
151
+ ? { maxWidth: 600, alignSelf: 'center' as const, borderRadius: SHEET_RADIUS }
152
+ : undefined;
153
+
154
+ // The avatars fly between the two sheets: one controller, one layer.
155
+ const flights = useAvatarFlights();
156
+ const shared = {
157
+ flights,
158
+ clientContext,
159
+ model,
160
+ apiUrl,
161
+ isDarkMode: isDarkMode ?? isDark,
162
+ voiceSession,
163
+ bottomInset: detached ? 0 : bottom,
164
+ labels,
165
+ characterRuntimeUrl,
166
+ };
108
167
 
109
168
  return (
110
- <BottomSheet
111
- ref={sheetRef}
112
- detached
113
- scrollable={false}
114
- manualActivation
115
- onDismissAttempt={onDismissAttempt}
116
- onDismiss={stopChat}
117
- style={{ maxWidth: 600, alignSelf: 'center', borderRadius: SHEET_RADIUS }}
118
- >
119
- <AliaChatContent
120
- clientContext={clientContext}
121
- model={model}
122
- apiUrl={apiUrl}
123
- welcomeGreeting={welcomeGreeting}
124
- welcomeSubtitle={welcomeSubtitle}
125
- welcomeSuggestions={welcomeSuggestions}
126
- isDarkMode={isDarkMode ?? isDark}
127
- voiceSession={voiceSession}
128
- labels={labels}
129
- onStopReady={registerChatStop}
130
- // The top padding clears the 28px drag handle, which Bloom floats over the content
131
- // instead of reserving room for.
132
- header={header}
133
- />
134
- </BottomSheet>
169
+ <>
170
+ <BottomSheet
171
+ ref={mainRef}
172
+ detached={detached}
173
+ scrollable={false}
174
+ manualActivation
175
+ onDismissAttempt={onMainDismissAttempt}
176
+ onDismiss={() => setConversation(null)}
177
+ style={sheetStyle}
178
+ >
179
+ {/* The main screen: the greeting, the suggestions and the composer. A first turn opens
180
+ the conversation in its own sheet over this one, so this chat stays empty. */}
181
+ <AliaChatContent
182
+ {...shared}
183
+ welcomeGreeting={welcomeGreeting}
184
+ welcomeSubtitle={welcomeSubtitle}
185
+ welcomeSuggestions={welcomeSuggestions}
186
+ agents={agents}
187
+ suggestionMatches={suggestionMatches}
188
+ onSuggestionQuery={onSuggestionQuery}
189
+ onStopReady={registerMainStop}
190
+ onStart={startConversation}
191
+ />
192
+ </BottomSheet>
193
+
194
+ {conversation ? (
195
+ <BottomSheet
196
+ key={conversation.key}
197
+ ref={conversationRef}
198
+ detached={detached}
199
+ scrollable={false}
200
+ manualActivation
201
+ onDismissAttempt={onConversationDismissAttempt}
202
+ onDismiss={() => setConversation(null)}
203
+ style={sheetStyle}
204
+ >
205
+ <AliaChatContent
206
+ {...shared}
207
+ initialTurn={conversation.turn}
208
+ agents={agents}
209
+ onStopReady={registerConversationStop}
210
+ // The bar is the conversation's: back to the main screen, the profile, share and
211
+ // the menu.
212
+ header={({ agents }) => (
213
+ <View className="pt-8">
214
+ <AliaChatHeader
215
+ hasMessages
216
+ agents={agents}
217
+ onClear={closeConversation}
218
+ onBack={closeConversation}
219
+ onShare={onShare}
220
+ menu={headerMenu}
221
+ />
222
+ </View>
223
+ )}
224
+ />
225
+ </BottomSheet>
226
+ ) : null}
227
+ <AvatarFlightLayer flights={flights} />
228
+ </>
135
229
  );
136
230
  },
137
231
  );
@@ -1,71 +1,44 @@
1
- import { Item } from '@oxy.so/bloom/item';
1
+ import { AvatarAnchor } from '@oxy.so/bloom/avatar-flight';
2
2
  import { Text } from '@oxy.so/bloom/typography';
3
3
  import { View } from 'react-native';
4
- import Animated, { FadeIn } from 'react-native-reanimated';
5
- import type { WelcomeSuggestion } from '../types';
4
+ import { ALIA_ANCHORS, useAliaFlights } from '../lib/flights';
5
+ import { greetingLines } from '../lib/greeting';
6
6
  import { AliaAvatar } from './AliaAvatar';
7
7
 
8
- export type { WelcomeSuggestion };
9
-
10
8
  export interface AliaWelcomeMessageProps {
11
9
  /** The greeting, e.g. "Hi Nate, what's on your mind?". */
12
10
  greeting: string;
13
11
  subtitle?: string;
14
- suggestions?: WelcomeSuggestion[];
15
- onSuggestionPress?: (text: string) => void;
16
12
  markSize?: number;
17
- /** Leave the avatar out, where the surrounding header already shows it. */
18
- hideMark?: boolean;
19
13
  }
20
14
 
21
15
  /**
22
- * The empty chat, as the Alia app has it: the avatar beside the greeting at display
23
- * size, centred (stacked on a phone, side by side from `md`), and the suggestions as
24
- * compact rows under it.
16
+ * The empty chat's greeting: the avatar above it, both centred, the address on top ("Hi Nate,")
17
+ * and the rest under it (`greetingLines`). The Alia app and the SDK chat
18
+ * draw exactly this. The greeting has a max width (`max-w-xs`, 20rem) so it sets in two lines at
19
+ * any width, as a headline under a mark rather than one long line across a wide column. The
20
+ * suggestions are not part of it; they sit over the composer (`ComposerSuggestions`).
25
21
  */
26
- export function AliaWelcomeMessage({
27
- greeting,
28
- subtitle,
29
- suggestions = [],
30
- onSuggestionPress,
31
- markSize = 38,
32
- hideMark = false,
33
- }: AliaWelcomeMessageProps) {
22
+ export function AliaWelcomeMessage({ greeting, subtitle, markSize = 88 }: AliaWelcomeMessageProps) {
23
+ const flights = useAliaFlights();
24
+ const avatar = <AliaAvatar size={markSize} interactive />;
34
25
  return (
35
- <View className="flex-1 items-center justify-center gap-6 px-4">
36
- <View className="items-center gap-2">
37
- <View className="flex-col items-center justify-center gap-3 md:flex-row">
38
- {hideMark ? null : <AliaAvatar size={markSize} interactive />}
39
- <Text
40
- selectable={false}
41
- className="text-center text-4xl tracking-tight text-text-primary md:text-left"
42
- >
43
- {greeting}
44
- </Text>
45
- </View>
46
- {subtitle ? (
47
- <Text className="text-center text-lg text-text-secondary">{subtitle}</Text>
48
- ) : null}
26
+ <View className="items-center gap-2">
27
+ <View className="items-center justify-center gap-4">
28
+ {/* It flies up into the header's profile when the conversation begins. */}
29
+ {flights ? (
30
+ <AvatarAnchor flights={flights} anchor={ALIA_ANCHORS.welcome}>
31
+ {avatar}
32
+ </AvatarAnchor>
33
+ ) : (
34
+ avatar
35
+ )}
36
+ <Text selectable={false} className="max-w-xs shrink text-center text-4xl text-text-primary">
37
+ {greetingLines(greeting).join('\n')}
38
+ </Text>
49
39
  </View>
50
-
51
- {suggestions.length > 0 ? (
52
- <Animated.View
53
- entering={FadeIn.duration(400)}
54
- accessibilityRole="list"
55
- className="w-full max-w-[768px]"
56
- >
57
- {suggestions.map((item) => (
58
- <Item
59
- key={item.id}
60
- density="compact"
61
- accessibilityRole="button"
62
- accessibilityLabel={item.title}
63
- onPress={() => onSuggestionPress?.(item.description)}
64
- title={<Text numberOfLines={1}>{item.title}</Text>}
65
- subtitle={item.description}
66
- />
67
- ))}
68
- </Animated.View>
40
+ {subtitle ? (
41
+ <Text className="text-center text-lg text-text-secondary">{subtitle}</Text>
69
42
  ) : null}
70
43
  </View>
71
44
  );
@@ -0,0 +1,45 @@
1
+ import { Item } from '@oxy.so/bloom/item';
2
+ import { Text } from '@oxy.so/bloom/typography';
3
+ import { View } from 'react-native';
4
+ import type { Completion } from '../lib/suggestions';
5
+ import type { AliaSuggestion } from '../types';
6
+
7
+ /**
8
+ * Suggestions over the composer, one line each, the typed part highlighted and the row the
9
+ * keyboard is on marked. The Alia app and the SDK chat draw exactly this.
10
+ */
11
+ export function ComposerSuggestions<T extends AliaSuggestion>({
12
+ completions,
13
+ selected,
14
+ onPick,
15
+ }: {
16
+ completions: readonly Completion<T>[];
17
+ selected: number;
18
+ onPick: (suggestion: T) => void;
19
+ }) {
20
+ if (completions.length === 0) return null;
21
+ return (
22
+ <View accessibilityRole="list" className="w-full max-w-[768px] self-center">
23
+ {completions.map(({ suggestion, matchStart, matchEnd }, index) => {
24
+ const { text } = suggestion;
25
+ return (
26
+ <Item
27
+ key={suggestion.id}
28
+ density="compact"
29
+ highlighted={index === selected}
30
+ accessibilityRole="button"
31
+ accessibilityLabel={text}
32
+ onPress={() => onPick(suggestion)}
33
+ title={
34
+ <Text numberOfLines={1}>
35
+ {text.slice(0, matchStart)}
36
+ <Text className="font-medium text-primary">{text.slice(matchStart, matchEnd)}</Text>
37
+ {text.slice(matchEnd)}
38
+ </Text>
39
+ }
40
+ />
41
+ );
42
+ })}
43
+ </View>
44
+ );
45
+ }
@@ -1,30 +1,15 @@
1
1
  import React, { useMemo, type ReactNode } from 'react';
2
- import { View, Text, Platform, Linking, useColorScheme } from 'react-native';
2
+ import { View, Text, Platform, Linking } from 'react-native';
3
+ import { useTheme } from '@oxy.so/bloom/theme';
3
4
  import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
4
5
  import { CodeBlock } from '@oxy.so/bloom/code';
5
6
  import type { AliaColors } from '../theme';
6
7
 
7
8
  // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
8
9
  // The main app always passes resolved colors via the `colors` prop.
9
- const FALLBACK_LIGHT: Pick<
10
- AliaColors,
11
- 'text' | 'muted' | 'border' | 'primary' | 'mutedForeground'
12
- > = {
13
- text: '#11181C',
14
- muted: '#F4F4F5',
15
- border: '#E5E5EA',
16
- primary: '#7C3AED',
17
- mutedForeground: '#71717A',
18
- };
19
- const FALLBACK_DARK: typeof FALLBACK_LIGHT = {
20
- text: '#ECEDEE',
21
- muted: '#27272A',
22
- border: '#2C2C2E',
23
- primary: '#A78BFA',
24
- mutedForeground: '#A1A1AA',
25
- };
26
-
27
- const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
10
+ /** The body text of a reply: its bubble sets its padding from these. */
11
+ export const MARKDOWN_BODY = { fontSize: 16, lineHeight: 28 } as const;
12
+ const BODY_TEXT = MARKDOWN_BODY;
28
13
  const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
29
14
  const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
30
15
 
@@ -180,7 +165,8 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
180
165
  <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
181
166
  {bullet}
182
167
  </Text>
183
- <Text style={{ flex: 1, ...BODY_TEXT, color: textColor }}>{children}</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>
184
170
  </View>
185
171
  );
186
172
  },
@@ -476,9 +462,17 @@ export function AliaMarkdown({
476
462
  fontFamily,
477
463
  renderCodeBlock,
478
464
  }: AliaMarkdownProps) {
479
- const scheme = useColorScheme();
480
- const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
481
- const colors = { ...fallback, ...colorOverrides } as AliaColors;
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;
482
476
 
483
477
  const customRules = useMemo(
484
478
  () => createRules(colors, renderCodeBlock),