@alia.onl/sdk 11.1.0 → 13.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/package.json +5 -4
  3. package/src/components/AliaAvatar.tsx +16 -20
  4. package/src/components/AliaChatContent.tsx +234 -96
  5. package/src/components/AliaChatHeader.tsx +102 -76
  6. package/src/components/AliaChatMessageList.tsx +174 -134
  7. package/src/components/AliaChatScreen.tsx +45 -23
  8. package/src/components/AliaChatSheet.tsx +151 -50
  9. package/src/components/AliaWelcomeMessage.tsx +26 -53
  10. package/src/components/AmbientField.tsx +19 -16
  11. package/src/components/ComposerSuggestions.tsx +45 -0
  12. package/src/components/IdentityMark.tsx +3 -1
  13. package/src/components/Markdown.tsx +18 -24
  14. package/src/components/QuestionCard.tsx +35 -33
  15. package/src/components/ThinkingBlock.tsx +9 -13
  16. package/src/components/VoiceModeIcon.tsx +15 -7
  17. package/src/components/voice/VoiceControls.tsx +17 -9
  18. package/src/hooks/useAliaChat.ts +124 -5
  19. package/src/hooks/useComposerSuggestions.ts +84 -0
  20. package/src/index.ts +10 -10
  21. package/src/lib/agent-character.ts +28 -0
  22. package/src/lib/chat-stream.ts +25 -3
  23. package/src/lib/citations.ts +62 -10
  24. package/src/lib/delegation.ts +34 -0
  25. package/src/lib/domain.ts +12 -0
  26. package/src/lib/flights.ts +24 -0
  27. package/src/lib/greeting.ts +15 -0
  28. package/src/lib/image-data-url.ts +26 -5
  29. package/src/lib/labels.tsx +10 -2
  30. package/src/lib/questions.ts +15 -1
  31. package/src/lib/suggestions.ts +53 -0
  32. package/src/lib/task-labels.ts +7 -39
  33. package/src/lib/use-after-delay.ts +19 -0
  34. package/src/lib/work-log.ts +3 -9
  35. package/src/tools.ts +8 -1
  36. package/src/types.ts +6 -5
@@ -17,14 +17,13 @@
17
17
  * `livekit-client` never enters the bundle.
18
18
  */
19
19
 
20
- import React from 'react';
21
- import { useTheme } from '@oxy.so/bloom/theme';
20
+ import React, { useCallback } from 'react';
22
21
  import { View } from 'react-native';
23
22
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
24
23
  import { KeyboardAvoidingView } from '../lib/keyboard';
25
- import { AliaChatContent } from './AliaChatContent';
24
+ import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
26
25
  import { AliaChatHeader } from './AliaChatHeader';
27
- import type { WelcomeSuggestion } from './AliaWelcomeMessage';
26
+ import type { AliaSuggestion } from '../types';
28
27
  import type { AliaChatLabels } from '../lib/labels';
29
28
  import type { VoiceSessionComponent } from '../types';
30
29
 
@@ -35,8 +34,14 @@ export interface AliaChatScreenProps {
35
34
  model?: string;
36
35
  /** API URL override */
37
36
  apiUrl?: string;
38
- /** 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) */
39
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;
40
45
  /** Optional header right action (e.g., settings button) */
41
46
  headerRight?: React.ReactNode;
42
47
  /** Welcome screen greeting */
@@ -44,7 +49,13 @@ export interface AliaChatScreenProps {
44
49
  /** Welcome screen subtitle */
45
50
  welcomeSubtitle?: string;
46
51
  /** Welcome screen suggestions */
47
- 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;
48
59
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
49
60
  isDarkMode?: boolean;
50
61
  /** Voice capability — `VoiceSession` from `@alia.onl/sdk/voice`. */
@@ -57,18 +68,22 @@ export function AliaChatScreen({
57
68
  clientContext,
58
69
  model,
59
70
  apiUrl,
71
+ onBack,
60
72
  headerLeft,
73
+ onShare,
74
+ headerMenu,
61
75
  headerRight,
62
76
  welcomeGreeting,
63
77
  welcomeSubtitle,
64
78
  welcomeSuggestions,
79
+ agents,
80
+ suggestionMatches,
81
+ onSuggestionQuery,
65
82
  isDarkMode,
66
83
  voiceSession,
67
84
  labels,
68
85
  }: AliaChatScreenProps) {
69
86
  const insets = useSafeAreaInsets();
70
- const { colors } = useTheme();
71
-
72
87
  // The screen owns the whole window, so it keeps its composer out of both
73
88
  // system bars: the top inset clears the status bar, the bottom one the
74
89
  // gesture bar or home indicator an edge-to-edge app draws over.
@@ -79,14 +94,26 @@ export function AliaChatScreen({
79
94
  // keyboard's height minus the inset: the composer sits on the keyboard, not
80
95
  // one inset above it. (Its parent-relative layout would read as zero overlap
81
96
  // this deep in a host's tree, which is why the offset is measured.)
97
+ const header = useCallback<NonNullable<AliaChatContentProps['header']>>(
98
+ ({ hasMessages, clear, agents: answered }) => (
99
+ <AliaChatHeader
100
+ hasMessages={hasMessages}
101
+ agents={answered}
102
+ onClear={clear}
103
+ onBack={onBack}
104
+ onShare={onShare}
105
+ menu={headerMenu}
106
+ leading={headerLeft}
107
+ trailing={headerRight}
108
+ />
109
+ ),
110
+ [onBack, onShare, headerMenu, headerLeft, headerRight],
111
+ );
112
+
82
113
  return (
83
114
  <View
84
- className="flex-1"
85
- style={{
86
- backgroundColor: colors.background,
87
- paddingTop: insets.top,
88
- paddingBottom: insets.bottom,
89
- }}
115
+ className="flex-1 bg-background-full"
116
+ style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
90
117
  >
91
118
  <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
92
119
  <AliaChatContent
@@ -96,18 +123,13 @@ export function AliaChatScreen({
96
123
  welcomeGreeting={welcomeGreeting}
97
124
  welcomeSubtitle={welcomeSubtitle}
98
125
  welcomeSuggestions={welcomeSuggestions}
126
+ agents={agents}
127
+ suggestionMatches={suggestionMatches}
128
+ onSuggestionQuery={onSuggestionQuery}
99
129
  isDarkMode={isDarkMode}
100
130
  voiceSession={voiceSession}
101
131
  labels={labels}
102
- title={null}
103
- headerLeading={({ hasMessages, clear }) => (
104
- <AliaChatHeader
105
- hasMessages={hasMessages}
106
- onClear={clear}
107
- leading={headerLeft}
108
- trailing={headerRight}
109
- />
110
- )}
132
+ header={header}
111
133
  />
112
134
  </KeyboardAvoidingView>
113
135
  </View>
@@ -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 } 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,10 @@ 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
+ /** Draws the header's share button once there is a conversation; omitted, no share button. */
56
+ onShare?: () => void;
57
+ /** More rows for the header's menu (`DropdownMenuItem`s from `@oxy.so/bloom/dropdown-menu`). */
58
+ headerMenu?: React.ReactNode;
42
59
  }
43
60
 
44
61
  export interface AliaChatSheetRef {
@@ -55,72 +72,156 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
55
72
  welcomeGreeting,
56
73
  welcomeSubtitle,
57
74
  welcomeSuggestions,
75
+ agents,
76
+ suggestionMatches,
77
+ onSuggestionQuery,
58
78
  isDarkMode,
59
79
  voiceSession,
60
80
  labels,
81
+ onShare,
82
+ headerMenu,
61
83
  },
62
84
  ref,
63
85
  ) => {
64
- const isDark = useColorScheme() === 'dark';
65
- const sheetRef = useRef<BottomSheetRef>(null);
66
- const chatStopRef = useRef<(() => void) | null>(null);
86
+ // Bloom's resolved mode, not the system's scheme.
87
+ const { isDark } = useTheme();
88
+ const mainRef = useRef<BottomSheetRef>(null);
89
+ const conversationRef = useRef<BottomSheetRef>(null);
90
+ // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
91
+ const mainStopRef = useRef<(() => void) | null>(null);
92
+ const conversationStopRef = useRef<(() => void) | null>(null);
93
+
94
+ // The conversation sheet, stacked over the main one while a chat is open. The key makes
95
+ // every conversation a fresh chat.
96
+ const [conversation, setConversation] = useState<{ key: number; turn: AliaTurn } | null>(null);
67
97
 
68
- const stopChat = useCallback(() => {
69
- chatStopRef.current?.();
98
+ const registerMainStop = useCallback((stop: (() => void) | null) => {
99
+ mainStopRef.current = stop;
100
+ }, []);
101
+ const registerConversationStop = useCallback((stop: (() => void) | null) => {
102
+ conversationStopRef.current = stop;
70
103
  }, []);
71
104
 
72
- const registerChatStop = useCallback((stop: (() => void) | null) => {
73
- chatStopRef.current = stop;
105
+ const startConversation = useCallback((turn: AliaTurn) => {
106
+ setConversation((current) => ({ key: (current?.key ?? 0) + 1, turn }));
107
+ }, []);
108
+ useEffect(() => {
109
+ if (conversation) conversationRef.current?.present();
110
+ }, [conversation]);
111
+
112
+ // Back, and the menu's Clear: close the conversation, back to the main screen underneath.
113
+ const closeConversation = useCallback(() => {
114
+ conversationStopRef.current?.();
115
+ conversationRef.current?.dismiss();
116
+ }, []);
117
+ const onConversationDismissAttempt = useCallback(() => {
118
+ conversationStopRef.current?.();
119
+ return true;
74
120
  }, []);
75
121
 
76
122
  const handleDismiss = useCallback(() => {
77
- stopChat();
78
- sheetRef.current?.dismiss();
79
- }, [stopChat]);
123
+ conversationStopRef.current?.();
124
+ mainStopRef.current?.();
125
+ conversationRef.current?.dismiss();
126
+ mainRef.current?.dismiss();
127
+ }, []);
80
128
 
81
129
  useImperativeHandle(
82
130
  ref,
83
- () => ({ present: () => sheetRef.current?.present(), dismiss: handleDismiss }),
131
+ () => ({ present: () => mainRef.current?.present(), dismiss: handleDismiss }),
84
132
  [handleDismiss],
85
133
  );
86
134
 
87
- // A drag, backdrop press, Back or Escape starts closing here: stop the turn first.
88
- const onDismissAttempt = useCallback(() => {
89
- stopChat();
135
+ // Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
136
+ const onMainDismissAttempt = useCallback(() => {
137
+ mainStopRef.current?.();
138
+ conversationStopRef.current?.();
139
+ conversationRef.current?.dismiss();
90
140
  return true;
91
- }, [stopChat]);
141
+ }, []);
142
+
143
+ const { width } = useWindowDimensions();
144
+ const detached = width >= DETACHED_FROM;
145
+ // Flush with the screen edge the sheet runs behind the home indicator: the composer clears it.
146
+ const { bottom } = useSafeAreaInsets();
147
+ const sheetStyle = detached
148
+ ? { maxWidth: 600, alignSelf: 'center' as const, borderRadius: SHEET_RADIUS }
149
+ : undefined;
150
+
151
+ // The avatars fly between the two sheets: one controller, one layer.
152
+ const flights = useAvatarFlights();
153
+ const shared = {
154
+ flights,
155
+ clientContext,
156
+ model,
157
+ apiUrl,
158
+ isDarkMode: isDarkMode ?? isDark,
159
+ voiceSession,
160
+ bottomInset: detached ? 0 : bottom,
161
+ labels,
162
+ };
92
163
 
93
164
  return (
94
- <BottomSheet
95
- ref={sheetRef}
96
- detached
97
- scrollable={false}
98
- manualActivation
99
- onDismissAttempt={onDismissAttempt}
100
- onDismiss={stopChat}
101
- style={{ maxWidth: 600, alignSelf: 'center', borderRadius: SHEET_RADIUS }}
102
- >
103
- <AliaChatContent
104
- clientContext={clientContext}
105
- model={model}
106
- apiUrl={apiUrl}
107
- welcomeGreeting={welcomeGreeting}
108
- welcomeSubtitle={welcomeSubtitle}
109
- welcomeSuggestions={welcomeSuggestions}
110
- isDarkMode={isDarkMode ?? isDark}
111
- voiceSession={voiceSession}
112
- labels={labels}
113
- onStopReady={registerChatStop}
114
- // The top padding clears the 28px drag handle, which Bloom floats over the content
115
- // instead of reserving room for.
116
- title={null}
117
- headerLeading={({ hasMessages, clear }) => (
118
- <View className="pt-8">
119
- <AliaChatHeader hasMessages={hasMessages} onClear={clear} onClose={handleDismiss} />
120
- </View>
121
- )}
122
- />
123
- </BottomSheet>
165
+ <>
166
+ <BottomSheet
167
+ ref={mainRef}
168
+ detached={detached}
169
+ scrollable={false}
170
+ manualActivation
171
+ onDismissAttempt={onMainDismissAttempt}
172
+ onDismiss={() => setConversation(null)}
173
+ style={sheetStyle}
174
+ >
175
+ {/* The main screen: the greeting, the suggestions and the composer. A first turn opens
176
+ the conversation in its own sheet over this one, so this chat stays empty. */}
177
+ <AliaChatContent
178
+ {...shared}
179
+ welcomeGreeting={welcomeGreeting}
180
+ welcomeSubtitle={welcomeSubtitle}
181
+ welcomeSuggestions={welcomeSuggestions}
182
+ agents={agents}
183
+ suggestionMatches={suggestionMatches}
184
+ onSuggestionQuery={onSuggestionQuery}
185
+ onStopReady={registerMainStop}
186
+ onStart={startConversation}
187
+ />
188
+ </BottomSheet>
189
+
190
+ {conversation ? (
191
+ <BottomSheet
192
+ key={conversation.key}
193
+ ref={conversationRef}
194
+ detached={detached}
195
+ scrollable={false}
196
+ manualActivation
197
+ onDismissAttempt={onConversationDismissAttempt}
198
+ onDismiss={() => setConversation(null)}
199
+ style={sheetStyle}
200
+ >
201
+ <AliaChatContent
202
+ {...shared}
203
+ initialTurn={conversation.turn}
204
+ agents={agents}
205
+ onStopReady={registerConversationStop}
206
+ // The bar is the conversation's: back to the main screen, the profile, share and
207
+ // the menu.
208
+ header={({ agents }) => (
209
+ <View className="pt-8">
210
+ <AliaChatHeader
211
+ hasMessages
212
+ agents={agents}
213
+ onClear={closeConversation}
214
+ onBack={closeConversation}
215
+ onShare={onShare}
216
+ menu={headerMenu}
217
+ />
218
+ </View>
219
+ )}
220
+ />
221
+ </BottomSheet>
222
+ ) : null}
223
+ <AvatarFlightLayer flights={flights} />
224
+ </>
124
225
  );
125
226
  },
126
227
  );
@@ -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
  );
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { memo, useEffect, useRef, useState } from 'react';
2
2
  import { AppState, Platform, View, type LayoutChangeEvent, type ViewStyle } from 'react-native';
3
3
  import Animated, {
4
4
  cancelAnimation,
@@ -55,16 +55,16 @@ const EXIT_EASE = Easing.bezier(0.2, 0.7, 0.2, 1);
55
55
  const PULSE_DURATION = 900;
56
56
 
57
57
  /**
58
- * A field shorter than a phone screen (the chat's sheet) is not drawn at its own scale: the
59
- * blobs are sized in `vmax` of the field, which would be its small side, and laid out across
60
- * its width, which would pile them on the centre. It takes the blob size of a phone screen,
61
- * and its blobs are spread wider (`SHORT_FIELD_SPREAD`), cropped by its edges — the blue, warm
62
- * and green of the intro side by side, not one smudge in the middle.
58
+ * How the field lays itself out in a container that is not a screen. The blobs are sized in
59
+ * `vmax` of the field and laid out across its width, which on a short, wide card (the chat's
60
+ * sheet) would make them half as big and pile them on the centre. The `sheet` layout draws
61
+ * them at the size of a phone screen (`reference`), spread wider across the width (`spread`)
62
+ * and shifted back toward the right (`shift`, a share of the width, since the spread leaves the
63
+ * composition leaning left), cropped by the card's edges: the blue, warm and green of the
64
+ * intro side by side. Asked for, not inferred from the height, so a short app screen is
65
+ * never reinterpreted.
63
66
  */
64
- const MIN_FIELD_REFERENCE = 800;
65
- const SHORT_FIELD_SPREAD = 1.4;
66
- /** The spread leaves the composition leaning left; this much of the width puts it back. */
67
- const SHORT_FIELD_SHIFT = 0.17;
67
+ const SHEET_LAYOUT = { reference: 800, spread: 1.4, shift: 0.17 } as const;
68
68
 
69
69
  /** One keyframe stop of a float loop: [translateX in vw, translateY in vh, scale]. */
70
70
  type FloatStop = readonly [number, number, number];
@@ -368,6 +368,8 @@ function Flourish({
368
368
  }
369
369
 
370
370
  export interface AmbientFieldProps {
371
+ /** `screen` (default) fills a page; `sheet` is for a short card, see `SHEET_LAYOUT`. */
372
+ layout?: 'screen' | 'sheet';
371
373
  /**
372
374
  * Combined amplitude from `useAmbientWave` — swells and brightens the blobs.
373
375
  * Omit where there is no audio (the intro) and the field simply rests.
@@ -420,7 +422,8 @@ export interface AmbientFieldProps {
420
422
  * layer, nothing is seen THROUGH it, and the translucent surfaces over it are
421
423
  * Bloom's, which follow that setting themselves.
422
424
  */
423
- export function AmbientField({
425
+ export const AmbientField = memo(function AmbientField({
426
+ layout = 'screen',
424
427
  waveAmplitude,
425
428
  agentState = 'idle',
426
429
  intensity = 1,
@@ -487,11 +490,11 @@ export function AmbientField({
487
490
  setField({ width, height });
488
491
  };
489
492
 
490
- const short = field.height > 0 && field.height < MIN_FIELD_REFERENCE;
491
- const vw = (field.width / 100) * (short ? SHORT_FIELD_SPREAD : 1);
493
+ const sheet = layout === 'sheet';
494
+ const vw = (field.width / 100) * (sheet ? SHEET_LAYOUT.spread : 1);
492
495
  const vh = field.height / 100;
493
- const vmax = Math.max(field.width, field.height, short ? MIN_FIELD_REFERENCE : 0) / 100;
494
- const shiftX = short ? field.width * SHORT_FIELD_SHIFT : 0;
496
+ const vmax = Math.max(field.width, field.height, sheet ? SHEET_LAYOUT.reference : 0) / 100;
497
+ const shiftX = sheet ? field.width * SHEET_LAYOUT.shift : 0;
495
498
 
496
499
  const fieldStyle = useAnimatedStyle(() => ({
497
500
  // On the way out the field keeps 45% of its opacity, sinking as it goes.
@@ -572,6 +575,6 @@ export function AmbientField({
572
575
  ) : null}
573
576
  </View>
574
577
  );
575
- }
578
+ });
576
579
 
577
580
  export { PARALLAX_DURATION, PARALLAX_EASE };
@@ -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,5 +1,6 @@
1
1
  import { useState } from 'react';
2
2
  import { Pressable, View } from 'react-native';
3
+ import type { AliaAvatarState } from './AliaAvatar';
3
4
  import * as Haptics from 'expo-haptics';
4
5
  import Svg, { Path } from 'react-native-svg';
5
6
  import Animated, {
@@ -13,7 +14,8 @@ import Animated, {
13
14
  Easing,
14
15
  } from 'react-native-reanimated';
15
16
 
16
- export type IdentityMarkState = 'idle' | 'thinking' | 'working' | 'writing';
17
+ /** What the mark is doing: the same states as the avatar's. */
18
+ export type IdentityMarkState = AliaAvatarState;
17
19
 
18
20
  export interface IdentityMarkProps {
19
21
  /** Width & height in px. Default 24. */