@alia.onl/sdk 13.1.0 → 15.2.1

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 (55) hide show
  1. package/CHANGELOG.md +190 -0
  2. package/README.md +83 -20
  3. package/package.json +8 -4
  4. package/src/components/AliaAvatarIcon.tsx +30 -0
  5. package/src/components/AliaChatContent.tsx +57 -107
  6. package/src/components/AliaChatMessageList.tsx +2 -10
  7. package/src/components/AliaChatScreen.tsx +28 -25
  8. package/src/components/AliaChatSheet.tsx +30 -8
  9. package/src/components/AliaComposer.tsx +382 -0
  10. package/src/components/ComposerDropZone.tsx +235 -0
  11. package/src/components/voice/VoiceControls.tsx +28 -4
  12. package/src/components/voice/VoiceSession.tsx +23 -7
  13. package/src/hooks/useAliaActivity.ts +31 -0
  14. package/src/hooks/useAliaChat.ts +14 -3
  15. package/src/hooks/useAmbientWave.ts +3 -3
  16. package/src/hooks/useAttachmentIntake.ts +401 -0
  17. package/src/hooks/useAudioLevelMonitor.ts +2 -2
  18. package/src/hooks/useDictation.ts +295 -0
  19. package/src/hooks/useFocusWhenPresented.ts +41 -0
  20. package/src/hooks/usePendingQuestions.ts +19 -0
  21. package/src/hooks/useTTS.ts +24 -63
  22. package/src/hooks/useVoiceRoom.ts +148 -671
  23. package/src/index.ts +19 -3
  24. package/src/lib/activity-socket.ts +102 -0
  25. package/src/lib/activity-store.ts +53 -0
  26. package/src/lib/api-url.ts +2 -0
  27. package/src/lib/attachment-intake.ts +154 -0
  28. package/src/lib/attachment-tiles.ts +74 -0
  29. package/src/lib/audio-level.ts +2 -2
  30. package/src/lib/dictation-recorder-types.ts +37 -0
  31. package/src/lib/dictation-recorder.native.ts +90 -0
  32. package/src/lib/dictation-recorder.ts +131 -0
  33. package/src/lib/dictation.ts +208 -0
  34. package/src/lib/labels.tsx +50 -1
  35. package/src/lib/pcm.ts +57 -0
  36. package/src/lib/speech-language.ts +1 -1
  37. package/src/lib/speech-messages.ts +43 -65
  38. package/src/lib/speech-synthesis.ts +104 -9
  39. package/src/lib/turn-state.ts +15 -0
  40. package/src/lib/voice-audio-engine-types.ts +59 -0
  41. package/src/lib/voice-audio-engine.native.ts +232 -0
  42. package/src/lib/voice-audio-engine.ts +350 -0
  43. package/src/lib/voice-call.ts +539 -0
  44. package/src/lib/voice-levels.ts +7 -3
  45. package/src/lib/voice-protocol.ts +309 -0
  46. package/src/tools.ts +1 -0
  47. package/src/types.ts +63 -4
  48. package/src/voice.ts +31 -10
  49. package/tsconfig.json +2 -0
  50. package/src/hooks/useSpeechToText.ts +0 -268
  51. package/src/lib/speech-recognition-types.ts +0 -108
  52. package/src/lib/speech-recognition.native.ts +0 -237
  53. package/src/lib/speech-recognition.ts +0 -308
  54. package/src/lib/voice-text.ts +0 -198
  55. package/src/lib/voice-turn.ts +0 -89
@@ -2,39 +2,37 @@
2
2
  * Shared chat content component used by both AliaChatSheet and AliaChatScreen.
3
3
  *
4
4
  * Owns the text chat; voice is an injected capability (`voiceSession`) so that
5
- * nothing here reaches `livekit-client` at module-evaluation time. Containers
5
+ * nothing here reaches the call or its audio engine at module-evaluation time. Containers
6
6
  * only handle layout chrome (sheet modal, full-screen SafeAreaView). The
7
7
  * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
8
8
  */
9
9
 
10
10
  import React, { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
11
- import { View } from 'react-native';
11
+ import { View, type TextInput } from 'react-native';
12
+ import type { SharedValue } from 'react-native-reanimated';
13
+ import { turnActivityState } from '../lib/turn-state';
12
14
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
13
15
  import { useTTS } from '../hooks/useTTS';
14
- import { useSpeechToText } from '../hooks/useSpeechToText';
16
+ import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
17
+ import { usePendingQuestions } from '../hooks/usePendingQuestions';
15
18
  import { useImagePicker } from '../hooks/useImagePicker';
16
19
  import { imageUriToDataUrl } from '../lib/image-data-url';
17
20
  import { useAmbientWave } from '../hooks/useAmbientWave';
18
21
  import { AmbientField } from './AmbientField';
19
- import { QuestionCard } from './QuestionCard';
20
22
  import { getAccountDisplayName } from '@oxy.so/core';
21
23
  import { useOxy } from '@oxy.so/services/ui/client';
22
- import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
23
- import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
24
- import { pendingQuestions } from '../lib/questions';
25
24
  import { AliaChatMessageList } from './AliaChatMessageList';
26
25
  import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
27
26
  import { AliaWelcomeMessage } from './AliaWelcomeMessage';
28
- import { ComposerSuggestions } from './ComposerSuggestions';
27
+ import { AliaComposer } from './AliaComposer';
29
28
  import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
30
- import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
31
29
  import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
32
30
  import {
33
31
  AvatarFlightLayer,
34
32
  useAvatarFlights,
35
33
  type AvatarFlights,
36
34
  } from '@oxy.so/bloom/avatar-flight';
37
- import { ComposerPill, type ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
35
+ import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
38
36
  import { RiImageLine } from '@oxy.so/bloom/icons';
39
37
  import { toast } from '@oxy.so/bloom/toast';
40
38
  import { Button } from '@oxy.so/bloom/button';
@@ -42,6 +40,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
42
40
  import type { AliaAvatarState } from './AliaAvatar';
43
41
  import { VoiceModeIcon } from './VoiceModeIcon';
44
42
  import type {
43
+ AliaAttachment,
45
44
  AliaSuggestion,
46
45
  ChatMessage,
47
46
  VoiceMessage,
@@ -137,14 +136,21 @@ export interface AliaChatContentProps {
137
136
  flights?: AvatarFlights;
138
137
  /** A turn sent once as this chat mounts: the conversation a sheet was opened for. */
139
138
  initialTurn?: AliaTurn;
139
+ /**
140
+ * The `animatedProgress` of the Bloom sheet this chat is in. The composer takes focus (and on
141
+ * native opens the keyboard) once it reads `1`, so the person can type as soon as the sheet is
142
+ * up. Omitted, the chat is not in a sheet and focus is left alone.
143
+ */
144
+ presentedProgress?: SharedValue<number>;
140
145
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
141
146
  onStopReady?: (stop: (() => void) | null) => void;
142
147
  /** Overrides for the chat's own copy; anything omitted is English. */
143
148
  labels?: Partial<AliaChatLabels>;
144
- /** Where the 3D avatar's runtime is served from; omitted, Alia's shared copy (`ALIA_CHARACTER_RUNTIME_URL`). */
145
- characterRuntimeUrl?: string;
146
149
  }
147
150
 
151
+ /** The chat API carries photos (`image_url` parts) and nothing else. */
152
+ const PHOTOS_ONLY = ['image'] as const;
153
+
148
154
  /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
149
155
  function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
150
156
  return [
@@ -192,6 +198,7 @@ function AliaChatContentBody({
192
198
  agents: knownAgents,
193
199
  flights: sharedFlights,
194
200
  initialTurn,
201
+ presentedProgress,
195
202
  onStopReady,
196
203
  }: AliaChatContentProps) {
197
204
  const ownFlights = useAvatarFlights();
@@ -212,15 +219,7 @@ function AliaChatContentBody({
212
219
  const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
213
220
  useAliaChat(chatOptions);
214
221
  const agents = useMemo(() => participatingAgents(messages), [messages]);
215
- // What Alia asked in her last turn and the person has not answered: asked above the composer,
216
- // not in the thread. Any later message, typed or sent from the card, is the answer.
217
- const lastMessage = messages.at(-1);
218
- const questionsAsked = useMemo(
219
- () => pendingQuestions(messages, isStreaming),
220
- [messages, isStreaming],
221
- );
222
- const [dismissedQuestionsOf, setDismissedQuestionsOf] = useState<string | null>(null);
223
- const asking = questionsAsked.length > 0 && dismissedQuestionsOf !== lastMessage?.id;
222
+ const asked = usePendingQuestions(messages, isStreaming);
224
223
 
225
224
  // The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
226
225
  const startsElsewhere = onStart !== undefined && messages.length === 0;
@@ -258,9 +257,7 @@ function AliaChatContentBody({
258
257
  const [inputValue, setInputValue] = useState('');
259
258
 
260
259
  // Picked photos wait here, as tiles above the field, until the turn is sent.
261
- const [photos, setPhotos] = useState<
262
- Array<{ id: string; name: string; uri: string; mimeType?: string }>
263
- >([]);
260
+ const [photos, setPhotos] = useState<AliaAttachment[]>([]);
264
261
  const { pickImage } = useImagePicker();
265
262
 
266
263
  const handleAddMenuSelect = useCallback(
@@ -272,8 +269,10 @@ function AliaChatContentBody({
272
269
  ...prev,
273
270
  ...assets.map((asset) => ({
274
271
  id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
272
+ type: 'image' as const,
275
273
  name: asset.name,
276
274
  uri: asset.uri,
275
+ size: asset.size,
277
276
  mimeType: asset.mimeType,
278
277
  })),
279
278
  ]);
@@ -323,24 +322,6 @@ function AliaChatContentBody({
323
322
  [submitTurn, photos, reportError],
324
323
  );
325
324
 
326
- // ── Dictation: the pill's mic toggles it, the transcript lands in the field ──
327
- const stt = useSpeechToText();
328
- useEffect(() => {
329
- if (stt.error) reportError(stt.error);
330
- }, [stt.error, reportError]);
331
- const sttRef = useRef(stt);
332
- sttRef.current = stt;
333
- const handleListeningChange = useCallback(async (listening: boolean) => {
334
- const current = sttRef.current;
335
- if (listening) {
336
- if (!current.isRecording && !current.isTranscribing) current.startRecording();
337
- return;
338
- }
339
- if (!current.isRecording) return;
340
- const text = await current.stopAndTranscribe();
341
- if (text) setInputValue((prev) => (prev ? `${prev} ${text}` : text));
342
- }, []);
343
-
344
325
  // ── TTS ──
345
326
  const tts = useTTS({ apiUrl });
346
327
 
@@ -397,12 +378,7 @@ function AliaChatContentBody({
397
378
  if (voiceAgentState === 'thinking') return 'thinking';
398
379
  return 'idle';
399
380
  }
400
- if (!isStreaming) return 'idle';
401
- const lastMsg = messages[messages.length - 1];
402
- if (lastMsg?.role === 'assistant' && lastMsg.toolInvocations?.some((t) => t.state === 'call'))
403
- return 'working';
404
- if (lastMsg?.thinking) return 'thinking';
405
- return 'writing';
381
+ return turnActivityState(messages, isStreaming);
406
382
  }, [messages, isStreaming, isVoiceActive, voiceAgentState]);
407
383
 
408
384
  const { user } = useOxy();
@@ -452,16 +428,8 @@ function AliaChatContentBody({
452
428
  [VoiceSession, activateVoice, labels.startVoice],
453
429
  );
454
430
 
455
- const pillAttachments = useMemo(
456
- () =>
457
- photos.map((photo) => ({
458
- id: photo.id,
459
- name: photo.name,
460
- kind: 'image' as const,
461
- src: photo.uri,
462
- })),
463
- [photos],
464
- );
431
+ const composerRef = useRef<TextInput | null>(null);
432
+ useFocusWhenPresented(presentedProgress, () => composerRef.current?.focus());
465
433
 
466
434
  const composer =
467
435
  VoiceSession && isVoiceActive ? (
@@ -470,45 +438,33 @@ function AliaChatContentBody({
470
438
  <VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
471
439
  </Suspense>
472
440
  ) : (
473
- <>
474
- <ComposerSuggestions
475
- completions={suggestions.completions}
476
- selected={suggestions.selected}
477
- onPick={handlePickSuggestion}
478
- />
479
- {/* Beside the loader, not inside the pill: its light traces the box it wraps. */}
480
- {asking ? (
481
- <View className="mb-2">
482
- <QuestionCard
483
- questions={questionsAsked}
484
- onSubmit={(message) => send(message)}
485
- onDismiss={() => setDismissedQuestionsOf(lastMessage?.id ?? null)}
486
- />
487
- </View>
488
- ) : null}
489
- <ComposerLoader active={isStreaming}>
490
- <ComposerPill
491
- onKeyPress={suggestions.onKeyPress}
492
- surface={false}
493
- frosted={isStreaming}
494
- value={inputValue}
495
- onValueChange={setInputValue}
496
- onSubmit={handleSubmit}
497
- busy={isStreaming}
498
- onStop={stop}
499
- emptyAction={voiceActivateButton}
500
- placeholder={labels.placeholder}
501
- addMenu={addMenuGroups}
502
- onAddMenuSelect={handleAddMenuSelect}
503
- attachments={pillAttachments}
504
- onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
505
- voiceState={
506
- stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'
507
- }
508
- onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
509
- />
510
- </ComposerLoader>
511
- </>
441
+ <AliaComposer
442
+ apiUrl={apiUrl}
443
+ inputRef={composerRef}
444
+ disableKeyboardAvoidance
445
+ onKeyPress={suggestions.onKeyPress}
446
+ suggestions={{ ...suggestions, onPick: handlePickSuggestion }}
447
+ // Asked beside the field, not in the thread: the card docks above the composer.
448
+ questions={{
449
+ items: asked.questions,
450
+ onSubmit: (message) => send(message),
451
+ onDismiss: asked.dismiss,
452
+ }}
453
+ value={inputValue}
454
+ onValueChange={setInputValue}
455
+ onSubmit={() => void handleSubmit(inputValue)}
456
+ busy={isStreaming}
457
+ onStop={stop}
458
+ emptyAction={voiceActivateButton}
459
+ placeholder={labels.placeholder}
460
+ addMenu={addMenuGroups}
461
+ onAddMenuSelect={handleAddMenuSelect}
462
+ attachments={photos}
463
+ onAddAttachment={(photo) => setPhotos((prev) => [...prev, photo])}
464
+ onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
465
+ attachmentKinds={PHOTOS_ONLY}
466
+ onError={onError}
467
+ />
512
468
  );
513
469
 
514
470
  const hasMessages = messages.length > 0;
@@ -572,16 +528,10 @@ function AliaChatContentBody({
572
528
  }
573
529
 
574
530
  /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
575
- export function AliaChatContent({
576
- labels,
577
- characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
578
- ...props
579
- }: AliaChatContentProps) {
531
+ export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
580
532
  return (
581
- <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
582
- <AliaChatLabelsProvider labels={labels}>
583
- <AliaChatContentBody {...props} />
584
- </AliaChatLabelsProvider>
585
- </AgentAvatarProvider>
533
+ <AliaChatLabelsProvider labels={labels}>
534
+ <AliaChatContentBody {...props} />
535
+ </AliaChatLabelsProvider>
586
536
  );
587
537
  }
@@ -1,13 +1,6 @@
1
1
  import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
- import { View, Pressable, Platform } from 'react-native';
3
- import Animated, {
4
- useSharedValue,
5
- useAnimatedStyle,
6
- withRepeat,
7
- withTiming,
8
- withSequence,
9
- cancelAnimation,
10
- } from 'react-native-reanimated';
2
+ import { View, Platform } from 'react-native';
3
+ import Animated from 'react-native-reanimated';
11
4
  import { Textarea } from '@oxy.so/bloom/textarea';
12
5
  import { TaskList } from '@oxy.so/bloom/task-list';
13
6
  import { Text } from '@oxy.so/bloom/typography';
@@ -41,7 +34,6 @@ import { AskedQuestions } from './QuestionCard';
41
34
  import { askedQuestions, isQuestionInvocation } from '../lib/questions';
42
35
  import { extractCitationSources, linkifyCitations } from '../lib/citations';
43
36
  import { chatMessageEnter } from '../animations';
44
- import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
45
37
  import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
46
38
  import type { ChatMessage } from '../types';
47
39
 
@@ -14,12 +14,14 @@
14
14
  *
15
15
  * Voice is opt-in — pass `voiceSession={VoiceSession}` from
16
16
  * `@alia.onl/sdk/voice` to offer calls. Without it the screen is text-only and
17
- * `livekit-client` never enters the bundle.
17
+ * `react-native-audio-api` never enters the bundle.
18
18
  */
19
19
 
20
20
  import React, { useCallback } from 'react';
21
21
  import { View } from 'react-native';
22
22
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
23
+ import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
24
+ import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
23
25
  import { KeyboardAvoidingView } from '../lib/keyboard';
24
26
  import { AliaChatContent, type AliaChatContentProps } from './AliaChatContent';
25
27
  import { AliaChatHeader } from './AliaChatHeader';
@@ -84,7 +86,7 @@ export function AliaChatScreen({
84
86
  isDarkMode,
85
87
  voiceSession,
86
88
  labels,
87
- characterRuntimeUrl,
89
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
88
90
  }: AliaChatScreenProps) {
89
91
  const insets = useSafeAreaInsets();
90
92
  // The screen owns the whole window, so it keeps its composer out of both
@@ -114,28 +116,29 @@ export function AliaChatScreen({
114
116
  );
115
117
 
116
118
  return (
117
- <View
118
- className="flex-1 bg-background-full"
119
- style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
120
- >
121
- <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
122
- <AliaChatContent
123
- clientContext={clientContext}
124
- model={model}
125
- apiUrl={apiUrl}
126
- welcomeGreeting={welcomeGreeting}
127
- welcomeSubtitle={welcomeSubtitle}
128
- welcomeSuggestions={welcomeSuggestions}
129
- agents={agents}
130
- suggestionMatches={suggestionMatches}
131
- onSuggestionQuery={onSuggestionQuery}
132
- isDarkMode={isDarkMode}
133
- voiceSession={voiceSession}
134
- labels={labels}
135
- characterRuntimeUrl={characterRuntimeUrl}
136
- header={header}
137
- />
138
- </KeyboardAvoidingView>
139
- </View>
119
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
120
+ <View
121
+ className="flex-1 bg-background-full"
122
+ style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
123
+ >
124
+ <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
125
+ <AliaChatContent
126
+ clientContext={clientContext}
127
+ model={model}
128
+ apiUrl={apiUrl}
129
+ welcomeGreeting={welcomeGreeting}
130
+ welcomeSubtitle={welcomeSubtitle}
131
+ welcomeSuggestions={welcomeSuggestions}
132
+ agents={agents}
133
+ suggestionMatches={suggestionMatches}
134
+ onSuggestionQuery={onSuggestionQuery}
135
+ isDarkMode={isDarkMode}
136
+ voiceSession={voiceSession}
137
+ labels={labels}
138
+ header={header}
139
+ />
140
+ </KeyboardAvoidingView>
141
+ </View>
142
+ </AgentAvatarProvider>
140
143
  );
141
144
  }
@@ -1,10 +1,13 @@
1
+ import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
1
2
  import { AvatarFlightLayer, useAvatarFlights } from '@oxy.so/bloom/avatar-flight';
2
3
  import { BottomSheet, type BottomSheetRef } from '@oxy.so/bloom/bottom-sheet';
3
4
  import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
5
  import { useTheme } from '@oxy.so/bloom/theme';
5
6
  import { useWindowDimensions, View } from 'react-native';
7
+ import { useSharedValue } from 'react-native-reanimated';
6
8
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
9
  import { BREAKPOINTS } from '@oxy.so/bloom/styles';
10
+ import { ALIA_CHARACTER_RUNTIME_URL } from '../lib/character-runtime';
8
11
  import type { AliaChatLabels } from '../lib/labels';
9
12
  import type { VoiceSessionComponent } from '../types';
10
13
  import { AliaChatContent, type AliaChatContentProps, type AliaTurn } from './AliaChatContent';
@@ -46,7 +49,7 @@ export interface AliaChatSheetProps {
46
49
  isDarkMode?: boolean;
47
50
  /**
48
51
  * Voice capability — pass `VoiceSession` from `@alia.onl/sdk/voice` to offer
49
- * voice calls. Omitted, the sheet is text-only and `livekit-client` never
52
+ * voice calls. Omitted, the sheet is text-only and `react-native-audio-api` never
50
53
  * enters the bundle.
51
54
  */
52
55
  voiceSession?: VoiceSessionComponent;
@@ -61,7 +64,12 @@ export interface AliaChatSheetProps {
61
64
  }
62
65
 
63
66
  export interface AliaChatSheetRef {
64
- present: () => void;
67
+ /**
68
+ * Opens the sheet. Given a `turn`, the conversation opens over it with that message already
69
+ * sent (an "ask Alia about this" button: a profile, a post, a file); without one, the sheet
70
+ * opens on its welcome, with the composer focused.
71
+ */
72
+ present: (turn?: AliaTurn) => void;
65
73
  dismiss: () => void;
66
74
  }
67
75
 
@@ -80,7 +88,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
80
88
  isDarkMode,
81
89
  voiceSession,
82
90
  labels,
83
- characterRuntimeUrl,
91
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
84
92
  onShare,
85
93
  headerMenu,
86
94
  },
@@ -88,6 +96,9 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
88
96
  ) => {
89
97
  // Bloom's resolved mode, not the system's scheme.
90
98
  const { isDark } = useTheme();
99
+ // How far each sheet is presented (0 closed, 1 up): its chat takes the composer's focus at 1.
100
+ const mainProgress = useSharedValue(0);
101
+ const conversationProgress = useSharedValue(0);
91
102
  const mainRef = useRef<BottomSheetRef>(null);
92
103
  const conversationRef = useRef<BottomSheetRef>(null);
93
104
  // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
@@ -131,8 +142,14 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
131
142
 
132
143
  useImperativeHandle(
133
144
  ref,
134
- () => ({ present: () => mainRef.current?.present(), dismiss: handleDismiss }),
135
- [handleDismiss],
145
+ () => ({
146
+ present: (turn) => {
147
+ mainRef.current?.present();
148
+ if (turn) startConversation(turn);
149
+ },
150
+ dismiss: handleDismiss,
151
+ }),
152
+ [handleDismiss, startConversation],
136
153
  );
137
154
 
138
155
  // Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
@@ -162,16 +179,18 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
162
179
  voiceSession,
163
180
  bottomInset: detached ? 0 : bottom,
164
181
  labels,
165
- characterRuntimeUrl,
166
182
  };
167
183
 
184
+ // The provider wraps the sheets AND the flight layer: both draw avatars, and an avatar drawn
185
+ // outside it would fall back to the host's own `/bloom-character/runtime.mjs`.
168
186
  return (
169
- <>
187
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
170
188
  <BottomSheet
171
189
  ref={mainRef}
172
190
  detached={detached}
173
191
  scrollable={false}
174
192
  manualActivation
193
+ animatedProgress={mainProgress}
175
194
  onDismissAttempt={onMainDismissAttempt}
176
195
  onDismiss={() => setConversation(null)}
177
196
  style={sheetStyle}
@@ -186,6 +205,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
186
205
  agents={agents}
187
206
  suggestionMatches={suggestionMatches}
188
207
  onSuggestionQuery={onSuggestionQuery}
208
+ presentedProgress={mainProgress}
189
209
  onStopReady={registerMainStop}
190
210
  onStart={startConversation}
191
211
  />
@@ -198,6 +218,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
198
218
  detached={detached}
199
219
  scrollable={false}
200
220
  manualActivation
221
+ animatedProgress={conversationProgress}
201
222
  onDismissAttempt={onConversationDismissAttempt}
202
223
  onDismiss={() => setConversation(null)}
203
224
  style={sheetStyle}
@@ -205,6 +226,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
205
226
  <AliaChatContent
206
227
  {...shared}
207
228
  initialTurn={conversation.turn}
229
+ presentedProgress={conversationProgress}
208
230
  agents={agents}
209
231
  onStopReady={registerConversationStop}
210
232
  // The bar is the conversation's: back to the main screen, the profile, share and
@@ -225,7 +247,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
225
247
  </BottomSheet>
226
248
  ) : null}
227
249
  <AvatarFlightLayer flights={flights} />
228
- </>
250
+ </AgentAvatarProvider>
229
251
  );
230
252
  },
231
253
  );