@alia.onl/sdk 13.1.0 → 13.2.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,66 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 13.1.1
4
+
5
+ - **The avatar flights load the runtime too.** 13.1.0 mounted the character runtime provider inside
6
+ the chat, but the sheet's avatar-flight layer (the avatar that flies from the welcome into the
7
+ header) is drawn outside it, so it asked the host for its own `/bloom-character/runtime.mjs` and got
8
+ the app's HTML back ("Expected a JavaScript-or-Wasm module script but the server responded with
9
+ a MIME type of text/html"). `AliaChatSheet` and `AliaChatScreen` now wrap everything they draw in the
10
+ provider, once; `AliaChatContent` no longer mounts one (it is not an entry point on its own).
11
+
12
+ ## 13.2.0
13
+
14
+ One composer for the sheet, the screen and the Alia app: the app's composer moved here and the
15
+ app consumes it.
16
+
17
+ - **`AliaComposer`** (exported) is Bloom's `ComposerPanel` plus what every Alia surface needs the
18
+ same way: files by paste and drop (read with real progress, retried, refused with a sentence),
19
+ dictation, the suggestions over the field and the questions Alia asked, docked above it. What
20
+ differs per surface is a prop: `addMenu` / `onAddMenuSelect`, `modes` / `mode` /
21
+ `onModeChange` (a power-level selector), `attachments` / `onAddAttachment` /
22
+ `onRemoveAttachment` (a composer given no `onAddAttachment` takes no paste or drop),
23
+ `attachmentKinds` (`['image']` refuses a document with a sentence), `suggestions`, `questions`,
24
+ `status`, `emptyAction`, `accessory`, `onKeyPress`, `speechLanguage`, `labels`, `onError`.
25
+ `AliaChatContent` (so the sheet and the screen) renders it with photos, voice and suggestions;
26
+ a host gets today's behaviour with no setup.
27
+ - **The sheet's composer is the panel, not the pill.** The pill has no mode selector, status tab
28
+ or question slot, which the Alia app needs; the sheet and the app now draw the same card. The
29
+ agent-working light (`ComposerLoader`) is not drawn around it, as in the app: a turn in flight
30
+ is its stop control and the thread's thinking line.
31
+ - **Photos are `AliaAttachment`s** (exported type): the chat's picked photos wait as the same
32
+ tiles a dropped or pasted image becomes, with the same remove and error states.
33
+ - **`AliaChatLabels` gains** `composerMessage`, `composerAddMenu`, `composerModes`,
34
+ `composerVoice`, `composerSend`, `composerStop`, `composerRemove`, `composerRetry`,
35
+ `composerUntitled`, `fileReadFailed`, `fileTooLarge`, `fileEmpty`, `fileUnsupported`,
36
+ `dropHint`, `dropUnavailable` and `voiceError(code)`, all with English defaults. A nested
37
+ `AliaChatLabelsProvider` now refines the labels already in reach instead of resetting them.
38
+ - **`usePendingQuestions(messages, isStreaming)`** (exported) is the "asked above the composer,
39
+ put away until she asks again" rule both the chat and the app used to keep a copy of.
40
+ - **`classifyIntake` / `MAX_ATTACHMENT_BYTES`** (from `@alia.onl/sdk/tools`) are the rules a file
41
+ passes to be attached.
42
+ - **The sheet focuses its composer once it has presented.** `AliaChatSheet` hands each sheet's
43
+ `animatedProgress` (Bloom's own "fully presented" signal, no timer) to its chat, whose composer
44
+ takes focus when it reads `1`: the person can type at once and the keyboard opens on native. It
45
+ focuses once per `present()` for the main sheet and for the conversation sheet over it; dragging
46
+ a sheet part-way and letting go does not take the focus again. `AliaChatContent` takes
47
+ `presentedProgress` for a host that builds its own sheet; the screen and the app's own screens
48
+ pass none and keep their focus. Not verified on a device.
49
+
50
+ A voice call that cannot speak ends and says why, instead of listening on in silence:
51
+
52
+ - **`requestSpeechClip` throws a `SpeechClipError`** (exported) with the route's HTTP `status`,
53
+ its typed `code` (`SPEECH_UNAVAILABLE`, `SPEECH_LIMIT_REACHED`, `SPEECH_REJECTED`,
54
+ `SPEECH_PROVIDER_FAILED`, `SPEECH_GENERATION_FAILED`, `INVALID_REQUEST`, `NOT_AUTHENTICATED`, or
55
+ `null`), `retryable` and `requestId`. An empty token is `NOT_AUTHENTICATED` without a request.
56
+ - **`useVoiceRoom` ends the call when a clip cannot be made or played**, after the answer has
57
+ finished streaming into the transcript, with `errorCode` `synthesis-unavailable`,
58
+ `synthesis-limited`, `synthesis-rejected`, `synthesis-failed`, `not-authenticated` (no session
59
+ token) or `not-played`; `synthesisError` is the `SpeechClipError` behind it. A turn no longer
60
+ skips a sentence it could not speak, and `turnErrorCode` is no longer `not-played`.
61
+ - `VoiceErrorCode` gains the four `synthesis-*` codes; an app that translates every code needs
62
+ their sentences.
63
+
3
64
  ## 13.1.0
4
65
 
5
66
  The chat works in any app without host setup it did not document in code:
package/README.md CHANGED
@@ -112,6 +112,37 @@ components render. Those events are the product runtime's, not part of the Oxy
112
112
  generic inference contract, and this package is written against them on
113
113
  purpose: it is a **product** client.
114
114
 
115
+ ## The composer
116
+
117
+ The sheet, the screen and the Alia app share one composer, `AliaComposer`: Bloom's
118
+ `ComposerPanel` with files by paste and drop (read with real progress, retried, refused with a
119
+ sentence), dictation, suggestions over the field and Alia's questions docked above it.
120
+ `AliaChatSheet` and `AliaChatScreen` render it with photos, voice and suggestions, and
121
+ `AliaChatSheet` focuses it once the sheet has presented. To build your own surface, mount it and
122
+ pass what the surface has:
123
+
124
+ ```tsx
125
+ import { AliaComposer } from '@alia.onl/sdk';
126
+
127
+ <AliaComposer
128
+ value={draft}
129
+ onValueChange={setDraft}
130
+ onSubmit={send}
131
+ busy={isStreaming}
132
+ onStop={stop}
133
+ modes={powerLevels} // the mode selector; omit to hide it
134
+ mode={level}
135
+ onModeChange={setLevel}
136
+ addMenu={groups} // the add menu's rows, and what pressing one does
137
+ onAddMenuSelect={onAdd}
138
+ attachments={files} // omit `onAddAttachment` and the composer takes no paste or drop
139
+ onAddAttachment={addFile}
140
+ onRemoveAttachment={removeFile}
141
+ attachmentKinds={['image']} // what your send carries; others are refused with a sentence
142
+ labels={{ composerSend: 'Enviar' }} // anything omitted is English
143
+ />;
144
+ ```
145
+
115
146
  ## Which endpoint the SDK calls, and why
116
147
 
117
148
  The hook sends its turns to `POST {apiUrl}/v1/chat/completions`, with `apiUrl`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "13.1.0",
3
+ "version": "13.2.0",
4
4
  "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -8,33 +8,30 @@
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';
12
13
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
13
14
  import { useTTS } from '../hooks/useTTS';
14
- import { useSpeechToText } from '../hooks/useSpeechToText';
15
+ import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
16
+ import { usePendingQuestions } from '../hooks/usePendingQuestions';
15
17
  import { useImagePicker } from '../hooks/useImagePicker';
16
18
  import { imageUriToDataUrl } from '../lib/image-data-url';
17
19
  import { useAmbientWave } from '../hooks/useAmbientWave';
18
20
  import { AmbientField } from './AmbientField';
19
- import { QuestionCard } from './QuestionCard';
20
21
  import { getAccountDisplayName } from '@oxy.so/core';
21
22
  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
23
  import { AliaChatMessageList } from './AliaChatMessageList';
26
24
  import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
27
25
  import { AliaWelcomeMessage } from './AliaWelcomeMessage';
28
- import { ComposerSuggestions } from './ComposerSuggestions';
26
+ import { AliaComposer } from './AliaComposer';
29
27
  import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
30
- import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
31
28
  import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
32
29
  import {
33
30
  AvatarFlightLayer,
34
31
  useAvatarFlights,
35
32
  type AvatarFlights,
36
33
  } from '@oxy.so/bloom/avatar-flight';
37
- import { ComposerPill, type ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
34
+ import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
38
35
  import { RiImageLine } from '@oxy.so/bloom/icons';
39
36
  import { toast } from '@oxy.so/bloom/toast';
40
37
  import { Button } from '@oxy.so/bloom/button';
@@ -42,6 +39,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
42
39
  import type { AliaAvatarState } from './AliaAvatar';
43
40
  import { VoiceModeIcon } from './VoiceModeIcon';
44
41
  import type {
42
+ AliaAttachment,
45
43
  AliaSuggestion,
46
44
  ChatMessage,
47
45
  VoiceMessage,
@@ -137,14 +135,21 @@ export interface AliaChatContentProps {
137
135
  flights?: AvatarFlights;
138
136
  /** A turn sent once as this chat mounts: the conversation a sheet was opened for. */
139
137
  initialTurn?: AliaTurn;
138
+ /**
139
+ * The `animatedProgress` of the Bloom sheet this chat is in. The composer takes focus (and on
140
+ * native opens the keyboard) once it reads `1`, so the person can type as soon as the sheet is
141
+ * up. Omitted, the chat is not in a sheet and focus is left alone.
142
+ */
143
+ presentedProgress?: SharedValue<number>;
140
144
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
141
145
  onStopReady?: (stop: (() => void) | null) => void;
142
146
  /** Overrides for the chat's own copy; anything omitted is English. */
143
147
  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
148
  }
147
149
 
150
+ /** The chat API carries photos (`image_url` parts) and nothing else. */
151
+ const PHOTOS_ONLY = ['image'] as const;
152
+
148
153
  /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
149
154
  function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
150
155
  return [
@@ -192,6 +197,7 @@ function AliaChatContentBody({
192
197
  agents: knownAgents,
193
198
  flights: sharedFlights,
194
199
  initialTurn,
200
+ presentedProgress,
195
201
  onStopReady,
196
202
  }: AliaChatContentProps) {
197
203
  const ownFlights = useAvatarFlights();
@@ -212,15 +218,7 @@ function AliaChatContentBody({
212
218
  const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
213
219
  useAliaChat(chatOptions);
214
220
  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;
221
+ const asked = usePendingQuestions(messages, isStreaming);
224
222
 
225
223
  // The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
226
224
  const startsElsewhere = onStart !== undefined && messages.length === 0;
@@ -258,9 +256,7 @@ function AliaChatContentBody({
258
256
  const [inputValue, setInputValue] = useState('');
259
257
 
260
258
  // 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
- >([]);
259
+ const [photos, setPhotos] = useState<AliaAttachment[]>([]);
264
260
  const { pickImage } = useImagePicker();
265
261
 
266
262
  const handleAddMenuSelect = useCallback(
@@ -272,8 +268,10 @@ function AliaChatContentBody({
272
268
  ...prev,
273
269
  ...assets.map((asset) => ({
274
270
  id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
271
+ type: 'image' as const,
275
272
  name: asset.name,
276
273
  uri: asset.uri,
274
+ size: asset.size,
277
275
  mimeType: asset.mimeType,
278
276
  })),
279
277
  ]);
@@ -323,24 +321,6 @@ function AliaChatContentBody({
323
321
  [submitTurn, photos, reportError],
324
322
  );
325
323
 
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
324
  // ── TTS ──
345
325
  const tts = useTTS({ apiUrl });
346
326
 
@@ -452,16 +432,8 @@ function AliaChatContentBody({
452
432
  [VoiceSession, activateVoice, labels.startVoice],
453
433
  );
454
434
 
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
- );
435
+ const composerRef = useRef<TextInput | null>(null);
436
+ useFocusWhenPresented(presentedProgress, () => composerRef.current?.focus());
465
437
 
466
438
  const composer =
467
439
  VoiceSession && isVoiceActive ? (
@@ -470,45 +442,32 @@ function AliaChatContentBody({
470
442
  <VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
471
443
  </Suspense>
472
444
  ) : (
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
- </>
445
+ <AliaComposer
446
+ inputRef={composerRef}
447
+ disableKeyboardAvoidance
448
+ onKeyPress={suggestions.onKeyPress}
449
+ suggestions={{ ...suggestions, onPick: handlePickSuggestion }}
450
+ // Asked beside the field, not in the thread: the card docks above the composer.
451
+ questions={{
452
+ items: asked.questions,
453
+ onSubmit: (message) => send(message),
454
+ onDismiss: asked.dismiss,
455
+ }}
456
+ value={inputValue}
457
+ onValueChange={setInputValue}
458
+ onSubmit={() => void handleSubmit(inputValue)}
459
+ busy={isStreaming}
460
+ onStop={stop}
461
+ emptyAction={voiceActivateButton}
462
+ placeholder={labels.placeholder}
463
+ addMenu={addMenuGroups}
464
+ onAddMenuSelect={handleAddMenuSelect}
465
+ attachments={photos}
466
+ onAddAttachment={(photo) => setPhotos((prev) => [...prev, photo])}
467
+ onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
468
+ attachmentKinds={PHOTOS_ONLY}
469
+ onError={onError}
470
+ />
512
471
  );
513
472
 
514
473
  const hasMessages = messages.length > 0;
@@ -572,16 +531,10 @@ function AliaChatContentBody({
572
531
  }
573
532
 
574
533
  /** 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) {
534
+ export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
580
535
  return (
581
- <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
582
- <AliaChatLabelsProvider labels={labels}>
583
- <AliaChatContentBody {...props} />
584
- </AliaChatLabelsProvider>
585
- </AgentAvatarProvider>
536
+ <AliaChatLabelsProvider labels={labels}>
537
+ <AliaChatContentBody {...props} />
538
+ </AliaChatLabelsProvider>
586
539
  );
587
540
  }
@@ -20,6 +20,8 @@
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';
@@ -80,7 +83,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
80
83
  isDarkMode,
81
84
  voiceSession,
82
85
  labels,
83
- characterRuntimeUrl,
86
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
84
87
  onShare,
85
88
  headerMenu,
86
89
  },
@@ -88,6 +91,9 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
88
91
  ) => {
89
92
  // Bloom's resolved mode, not the system's scheme.
90
93
  const { isDark } = useTheme();
94
+ // How far each sheet is presented (0 closed, 1 up): its chat takes the composer's focus at 1.
95
+ const mainProgress = useSharedValue(0);
96
+ const conversationProgress = useSharedValue(0);
91
97
  const mainRef = useRef<BottomSheetRef>(null);
92
98
  const conversationRef = useRef<BottomSheetRef>(null);
93
99
  // Each sheet's chat registers its own canceller; closing a sheet stops its own turn.
@@ -162,16 +168,18 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
162
168
  voiceSession,
163
169
  bottomInset: detached ? 0 : bottom,
164
170
  labels,
165
- characterRuntimeUrl,
166
171
  };
167
172
 
173
+ // The provider wraps the sheets AND the flight layer: both draw avatars, and an avatar drawn
174
+ // outside it would fall back to the host's own `/bloom-character/runtime.mjs`.
168
175
  return (
169
- <>
176
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
170
177
  <BottomSheet
171
178
  ref={mainRef}
172
179
  detached={detached}
173
180
  scrollable={false}
174
181
  manualActivation
182
+ animatedProgress={mainProgress}
175
183
  onDismissAttempt={onMainDismissAttempt}
176
184
  onDismiss={() => setConversation(null)}
177
185
  style={sheetStyle}
@@ -186,6 +194,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
186
194
  agents={agents}
187
195
  suggestionMatches={suggestionMatches}
188
196
  onSuggestionQuery={onSuggestionQuery}
197
+ presentedProgress={mainProgress}
189
198
  onStopReady={registerMainStop}
190
199
  onStart={startConversation}
191
200
  />
@@ -198,6 +207,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
198
207
  detached={detached}
199
208
  scrollable={false}
200
209
  manualActivation
210
+ animatedProgress={conversationProgress}
201
211
  onDismissAttempt={onConversationDismissAttempt}
202
212
  onDismiss={() => setConversation(null)}
203
213
  style={sheetStyle}
@@ -205,6 +215,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
205
215
  <AliaChatContent
206
216
  {...shared}
207
217
  initialTurn={conversation.turn}
218
+ presentedProgress={conversationProgress}
208
219
  agents={agents}
209
220
  onStopReady={registerConversationStop}
210
221
  // The bar is the conversation's: back to the main screen, the profile, share and
@@ -225,7 +236,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
225
236
  </BottomSheet>
226
237
  ) : null}
227
238
  <AvatarFlightLayer flights={flights} />
228
- </>
239
+ </AgentAvatarProvider>
229
240
  );
230
241
  },
231
242
  );