@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,125 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 13.1.0
4
+
5
+ The chat works in any app without host setup it did not document in code:
6
+
7
+ - **The avatar loads.** The chat mounts Bloom's `AgentAvatarProvider` itself, pointing at Alia's
8
+ shared runtime (`ALIA_CHARACTER_RUNTIME_URL`, served with CORS open); a host that serves its own
9
+ passes `characterRuntimeUrl`. Before, an app that had not hosted the 214 MB runtime showed
10
+ Bloom's "unavailable" state.
11
+ - **The empty chat greets.** With no `welcomeGreeting` it greets the signed-in person by name
12
+ (`labels.greeting` / `labels.greetingNamed`). Before, it drew nothing.
13
+
14
+ ## 13.0.0
15
+
16
+ Fixes found by driving the sheet end to end (the sheet shows plans, links citations, nests lists):
17
+
18
+ - **A plan is drawn.** `plan_preview` was parsed but the sheet passed no handlers, so the card
19
+ never showed. `useAliaChat` now returns `approvePlan` / `rejectPlan` (approve collapses the
20
+ card to "approved"; reject collapses it and stops the turn) and `AliaChatContent` uses them
21
+ unless the host passes its own.
22
+ - **Citations link.** `[n]` markers in a reply link to their sources (`linkifyCitations`,
23
+ `normalizeCitationMarkers`, `citationLabel` are exported from `@alia.onl/sdk/tools`; the app
24
+ re-exports them instead of keeping a copy).
25
+ - **Nested lists** draw under their parent instead of inline beside it.
26
+ - A finished research step reads "Researched", not "Researching".
27
+ - A turn that ends at a question card with no sentence before it counts as answered.
28
+
29
+ - **BREAKING: an agent is drawn with the look the server stores for it.** `agentCharacter(id)` (a
30
+ look hashed from the id) is removed; `readAgentCharacter(value)` parses a stored character
31
+ (Bloom's `parseAvatarCharacter`) and falls back on `DEFAULT_AGENT_CHARACTER`. The `alia.agent`
32
+ event carries `agentCharacter`, and `agentInfo` has `character`. `AliaChatAgent` has `character`;
33
+ `AliaChatSheet` / `AliaChatScreen` take `agents` (the host's directory) so a delegation's
34
+ thinking avatar is the agent's own before its answer arrives. Needs a server that sends
35
+ `agentCharacter`; release this SDK first, since the stream parser rejects unknown keys.
36
+ - **The one who answers thinks with its own avatar, and it is the same avatar that settles under
37
+ the reply** (Bloom's `AgentReplyIdentity`, as in the multi-agent chat). While Alia works, her
38
+ avatar is the indicator, large with "Thinking…", where the `AgentThinking` line was; when her
39
+ text starts it shrinks under it with her name. A call to an agent (`delegateToAgent` /
40
+ `askAgent`, its `agentId` and `agentName` or `agent` arguments) opens the agent's message at
41
+ once, empty and thinking, and its answer fills that same message, so the avatar never remounts;
42
+ an agent that never answers leaves no message. Alia's reply now carries her identity row, as the
43
+ agents' do.
44
+ The reasoning is part of that same component (`AgentReplyIdentity`'s `reasoning`): while the
45
+ turn thinks, "Thinking… ⌃" folds it under the avatar's row, and once the reply starts it is the
46
+ closed "Thought for N seconds" block above it (`ThinkingBlock` draws nothing while it streams, so
47
+ there is one thinking indicator, not two).
48
+ - **No jumps of height, and no flash of the wrong thinker.** A reply, a new row, the reasoning and
49
+ a thinking agent's avatar open and close with their height eased (Bloom's `Collapsible`, now
50
+ with `appear`) instead of popping. Alia is shown thinking only once she has for a moment, so a
51
+ turn she hands to an agent at once shows the agent thinking, not Alia for an instant first.
52
+ - **Bubbles are equal on every side as the eye reads them.** An agent's bubble sets its top and
53
+ bottom padding from the reply's text metrics (`AgentReplyBubble`'s `text`, 16 on 28 here), so
54
+ the gap from the edge to the text is the same above, below and at the sides. Nothing is cut:
55
+ an open `Collapsible` no longer clips.
56
+ - **The greeting's avatar flies up into the header's profile** as the conversation begins
57
+ (Bloom's `avatar-flight`: `useAvatarFlights`, `AvatarAnchor`, `AvatarFlightLayer`), across the
58
+ main and conversation sheets. `AgentProfile`'s new `avatarHidden` keeps the header's avatar
59
+ waiting for it. Needs a Bloom with `avatar-flight` (after 10.3).
60
+ - **`AliaChatSheet` opens the conversation in a second sheet stacked over the main one.** The
61
+ main sheet stays on the greeting, the suggestions and the composer; the first turn (typed or a
62
+ suggestion) opens a fresh conversation sheet on top, whose back button (and the menu's Clear)
63
+ closes just that sheet, back to the main screen. A phone gets sheets flush with the screen
64
+ edges, with the composer clearing the home indicator; from tablet width (768 px) they float.
65
+ `AliaChatContent` gains `onStart` (hand an empty chat's first turn to the host) and
66
+ `initialTurn` (a turn sent once on mount).
67
+ - **The empty chat looks and behaves like the Alia app's, from the same code.** The avatar sits
68
+ beside the greeting (`AliaWelcomeMessage`, now just the greeting: always side by side, no
69
+ `hideMark`, no suggestions) and the header shows Alia's profile only once there is a
70
+ conversation. The suggestions are one-line rows **over the composer** (`ComposerSuggestions`),
71
+ and from two typed characters they become matches with the typed part highlighted, arrows /
72
+ Enter / Escape through the composer's `onKeyPress`. The app now draws its greeting and its
73
+ suggestions with these same components and only supplies its API's data.
74
+ - **Several agents in one turn.** A delegated agent's answer (`alia.agent`) is now its own
75
+ message with its name and avatar, placed before Alia's reply as the app places it, so a turn
76
+ with Lumi, Atlas and Sol shows three speakers. Before, every answer was merged into one message
77
+ under the last agent's name. Alia's reply is dropped when she said nothing after handing over.
78
+ - **A delegated agent shows its 3D avatar.** The speaker line of an agent's message is Bloom's
79
+ `AgentAvatar`, not an image (an agent's account has none): the character comes from the agent's
80
+ id (`agentCharacter(id)`, exported), so the same agent looks the same everywhere and two agents
81
+ in a turn are told apart. The app draws its agents' messages with the same avatar.
82
+ - **Agents are bubbles, and the header holds the team.** As in Bloom's multi-agent chat, a
83
+ delegated agent's reply is a tinted bubble with its avatar and name beneath it. Once agents have
84
+ answered, the header profile stacks Alia with up to two of them and names them together
85
+ ("Alia + Lumi + Atlas"). `headerLeading` / `actions` receive `agents` in their state
86
+ (`AliaChatState`, `AliaChatAgent`), and `AliaChatHeader` takes `agents`.
87
+ - **The row under every reply is Bloom's multi-agent one**: helpful, unhelpful and copy as 22px
88
+ tiles with 12px icons (read aloud is a fourth tile where the host reads), replacing the
89
+ `AiChatFeedbackRow`. New label keys `helpful` and `unhelpful`.
90
+ - **Built on Bloom's `agent-reply`** (peer `@oxy.so/bloom ^10.1.1`): replies, their identity line and
91
+ action tiles, and the header profile are Bloom's `AgentReply`, `AgentReplyBubble`,
92
+ `AgentReplyIdentity`, `AgentReplyActions` and `AgentProfile`, the pieces `MultiAgentChat` is made
93
+ of, not copies. Pressing the profile makes every avatar in it wave.
94
+ - **Questions are asked above the composer, not in the thread.** What Alia asks in her last turn
95
+ (`askUserQuestion`) docks above the composer as Bloom's `Questionnaire`, with a dismiss; typing a
96
+ reply answers just as well. The thread keeps the questions as plain lines (`AskedQuestions`).
97
+ **Breaking:** `QuestionCard` is now the docked questionnaire (`onSubmit` required, new
98
+ `onDismiss`) and no longer draws a read-only variant; the `onAnswerQuestions` prop of the message
99
+ list is gone. `pendingQuestions` joins `@alia.onl/sdk/tools`.
100
+ - **The voice bar is Bloom's voice controls.** `VoiceControls` draws Bloom's `@oxy.so/bloom/voice`
101
+ `VoiceControls` (microphone and end) under the status text, instead of `CallControls`, which is
102
+ made for a dark call stage and lost its captions on the chat's light surface.
103
+ - **The header is back, the profile, share and a menu.** Bloom's `PageHeader` with a back button at
104
+ its start, and at its end share and a three-dots menu (Bloom's `DropdownMenu`, aligned to its
105
+ button) holding Clear and the host's rows. Over the empty chat there is no bar: nothing to do
106
+ there. In the sheet, back returns to the main screen (a fresh chat) and the sheet stays open.
107
+ **Breaking:** `AliaChatHeader` / `AliaChatSheet` / `AliaChatScreen` lose `onClose`; they take
108
+ `onBack` (screen), `onShare` and `headerMenu`; the labels `close` becomes `back`, `share`,
109
+ `more`.
110
+ - **The chat follows Bloom's theme, not the system's scheme.** `AliaMarkdown` and the sheet read
111
+ the app's resolved mode: a person who chose light on a dark OS got white text on a light surface.
112
+ - **Every icon is Bloom's.** The voice button is `RiVolumeUpLine` (it was Entypo); the app's clock,
113
+ robot and connector glyphs are `RiTimeLine`, `RiRobot2Line`, `RiPlugLine` and the SVG generator
114
+ is gone; `@expo/vector-icons` is no longer a peer of the SDK.
115
+ - **Greeting.** The avatar (88px) above the text, both centred; `max-w-xs`, so it always sets in two lines; more room above than below. The Alia app draws the same.
116
+ - Peer `@oxy.so/bloom ^10.2.1` (scrollbar, pill groups, header scrim, voice controls).
117
+ - **Breaking:** `WelcomeSuggestion { id, title, description }` becomes `AliaSuggestion { id, text }`
118
+ (`text` is what is shown and what is sent). New optional props on the sheet, screen and content:
119
+ `suggestionMatches` and `onSuggestionQuery(query)`, so a host can search as the person types.
120
+ New exports: `ComposerSuggestions`, `useComposerSuggestions`, `useSuggestionQuery`,
121
+ `composerCompletions`, `Completion`, `AliaSuggestion`.
122
+
3
123
  ## 12.0.0
4
124
 
5
125
  Breaking: Bloom 10 peer; the `header` render prop replaces the breadcrumb path (`title`, `actions`)
package/README.md CHANGED
@@ -25,20 +25,18 @@ the default in Expo SDK 53+), because every icon import is a subpath.
25
25
  The chat is built from Bloom (`@oxy.so/bloom`, a peer dependency): mount Bloom's
26
26
  `BloomProvider` above it, with the one `KeyboardProvider` your app already has.
27
27
 
28
- Alia's avatar is Bloom's 3D character (`AgentAvatar`), which draws through a
29
- runtime Bloom ships as static files (about 11 MB). Serve the package's
30
- `assets/character-runtime/` directory unchanged and give the URL of `runtime.mjs`
31
- to Bloom's provider, once, near the root:
32
-
33
- ```tsx
34
- import { AgentAvatarProvider } from '@oxy.so/bloom/agent-avatar';
35
-
36
- <AgentAvatarProvider runtimeUrl="/bloom-character/runtime.mjs">…</AgentAvatarProvider>
37
- ```
38
-
39
- Without it the avatar shows Bloom's "unavailable" state. Native avatars run in a
28
+ Alia's avatar is Bloom's 3D character (`AgentAvatar`). It draws through a runtime of
29
+ static files (about 214 MB), which the chat loads from Alia's own origin
30
+ (`ALIA_CHARACTER_RUNTIME_URL`, served with CORS open), so the app hosts nothing and mounts
31
+ no avatar provider. An app with a strict CSP allows `https://alia.onl` in `script-src` and
32
+ `connect-src` (and `'wasm-unsafe-eval'`). To serve your own copy instead, serve the Bloom
33
+ package's `assets/character-runtime/` directory unchanged and pass the URL of `runtime.mjs`
34
+ as `characterRuntimeUrl` to `AliaChatSheet` or `AliaChatScreen`; native avatars run in a
40
35
  WebView, so there the URL must be absolute and reachable from the device.
41
36
 
37
+ The empty chat greets the signed-in person by name ("Hi Nate, what's on your mind?"); pass
38
+ `welcomeGreeting` to say something else, or `labels.greeting` / `labels.greetingNamed` to translate it.
39
+
42
40
  Failures (a voice call that cannot start, a dictation error) are shown with Bloom's `toast`,
43
41
  so the app needs a `ToastOutlet` (`OxyProvider` mounts one), or pass `onError` to handle them.
44
42
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "12.0.0",
3
+ "version": "13.1.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",
@@ -32,14 +32,14 @@
32
32
  "dependencies": {
33
33
  "clsx": "^2.1.1",
34
34
  "react-native-markdown-display": "^7.0.2",
35
- "tailwind-merge": "^3.6.0",
35
+ "tailwind-merge": "^3.7.0",
36
36
  "zustand": "^5.0.15"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "^19.2.3",
40
40
  "react-native": "^0.86.0",
41
41
  "@expo/vector-icons": "^15.1.1",
42
- "@oxy.so/bloom": ">=10.0.0",
42
+ "@oxy.so/bloom": ">=10.4.0",
43
43
  "@oxy.so/core": ">=3.0.0",
44
44
  "@oxy.so/services": ">=8.0.0",
45
45
  "@react-native-async-storage/async-storage": "^2.2.0",
@@ -9,7 +9,11 @@ export type AliaAvatarState = 'idle' | 'thinking' | 'working' | 'writing';
9
9
  * eyes, nothing on its head. It is `blue_beret` with the hat taken off and the paint changed,
10
10
  * so Bloom retargets that preset's animation onto it; change it here and nowhere else.
11
11
  */
12
- const ALIA_AVATAR: AvatarCharacter = { preset: 'blue_beret', accessory: 'none', color: 'orchid' };
12
+ export const ALIA_AVATAR: AvatarCharacter = {
13
+ preset: 'blue_beret',
14
+ accessory: 'none',
15
+ color: 'orchid',
16
+ };
13
17
 
14
18
  const BUSY: ReadonlySet<AliaAvatarState> = new Set(['thinking', 'working']);
15
19
 
@@ -16,10 +16,24 @@ import { useImagePicker } from '../hooks/useImagePicker';
16
16
  import { imageUriToDataUrl } from '../lib/image-data-url';
17
17
  import { useAmbientWave } from '../hooks/useAmbientWave';
18
18
  import { AmbientField } from './AmbientField';
19
+ import { QuestionCard } from './QuestionCard';
20
+ import { getAccountDisplayName } from '@oxy.so/core';
21
+ 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';
19
25
  import { AliaChatMessageList } from './AliaChatMessageList';
20
- import { AliaWelcomeMessage, type WelcomeSuggestion } from './AliaWelcomeMessage';
26
+ import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
27
+ import { AliaWelcomeMessage } from './AliaWelcomeMessage';
28
+ import { ComposerSuggestions } from './ComposerSuggestions';
29
+ import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
21
30
  import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
22
31
  import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
32
+ import {
33
+ AvatarFlightLayer,
34
+ useAvatarFlights,
35
+ type AvatarFlights,
36
+ } from '@oxy.so/bloom/avatar-flight';
23
37
  import { ComposerPill, type ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
24
38
  import { RiImageLine } from '@oxy.so/bloom/icons';
25
39
  import { toast } from '@oxy.so/bloom/toast';
@@ -27,7 +41,46 @@ import { Button } from '@oxy.so/bloom/button';
27
41
  import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
28
42
  import type { AliaAvatarState } from './AliaAvatar';
29
43
  import { VoiceModeIcon } from './VoiceModeIcon';
30
- import type { ChatMessage, VoiceMessage, VoiceSessionComponent, VoiceSessionState } from '../types';
44
+ import type {
45
+ AliaSuggestion,
46
+ ChatMessage,
47
+ VoiceMessage,
48
+ VoiceSessionComponent,
49
+ VoiceSessionState,
50
+ } from '../types';
51
+
52
+ /** The distinct delegated agents among `messages`, in the order they first answered. */
53
+ function participatingAgents(messages: readonly ChatMessage[]): AliaChatAgent[] {
54
+ const seen = new Map<string, AliaChatAgent>();
55
+ for (const { agentInfo } of messages) {
56
+ if (agentInfo && !seen.has(agentInfo.id)) seen.set(agentInfo.id, agentInfo);
57
+ }
58
+ return [...seen.values()];
59
+ }
60
+
61
+ /** An agent that answered in the conversation, as the header shows it beside Alia. */
62
+ export interface AliaChatAgent {
63
+ id: string;
64
+ name: string;
65
+ /** Its look, as the server stores it (JSON); absent, the default look. */
66
+ character?: unknown;
67
+ }
68
+
69
+ /** What the chat tells its header and actions: whether there is a conversation to clear, and who took part. */
70
+ export interface AliaChatState {
71
+ hasMessages: boolean;
72
+ clear: () => void;
73
+ /** What Alia is doing; the header's avatar shows it. */
74
+ markState: AliaAvatarState;
75
+ /** The delegated agents that have answered, in the order they first did. */
76
+ agents: AliaChatAgent[];
77
+ }
78
+
79
+ /** A turn of the person's: the text, and the photos (`data:` or `https:` URLs) sent with it. */
80
+ export interface AliaTurn {
81
+ text: string;
82
+ images?: ReadonlyArray<string>;
83
+ }
31
84
 
32
85
  export interface AliaChatContentProps {
33
86
  clientContext?: string;
@@ -35,10 +88,15 @@ export interface AliaChatContentProps {
35
88
  apiUrl?: string;
36
89
  /** Error handler (e.g. toast) */
37
90
  onError?: (message: string) => void;
38
- // Welcome message
91
+ /** The empty chat's greeting; omitted, Alia greets the signed-in person by name (`labels`). */
39
92
  welcomeGreeting?: string;
40
93
  welcomeSubtitle?: string;
41
- welcomeSuggestions?: WelcomeSuggestion[];
94
+ /** The lines offered over the composer on an empty chat. */
95
+ welcomeSuggestions?: AliaSuggestion[];
96
+ /** What the host found for the typed text (see `onSuggestionQuery`). */
97
+ suggestionMatches?: AliaSuggestion[];
98
+ /** The typed text, once typing has settled, for the host to search suggestions with. */
99
+ onSuggestionQuery?: (query: string) => void;
42
100
  // Message action callbacks
43
101
  onEditMessage?: (messageId: string, newContent: string) => void;
44
102
  onThumbsUp?: (messageId: string) => void;
@@ -47,12 +105,11 @@ export interface AliaChatContentProps {
47
105
  onRejectPlan?: (planId: string) => void;
48
106
  /** Override markdown renderer (app passes CustomMarkdown) */
49
107
  renderMarkdown?: (content: string) => React.ReactNode;
50
- /** The chat's top bar. Receives what it needs from the chat: whether there is anything to clear, how, and what Alia is doing. */
51
- header?: (state: {
52
- hasMessages: boolean;
53
- clear: () => void;
54
- markState: AliaAvatarState;
55
- }) => React.ReactNode;
108
+ /**
109
+ * The chat's top bar. Receives what it needs from the chat: whether there is anything to clear,
110
+ * how, who took part, and what Alia is doing.
111
+ */
112
+ header?: (state: AliaChatState) => React.ReactNode;
56
113
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
57
114
  isDarkMode?: boolean;
58
115
  /**
@@ -61,10 +118,31 @@ export interface AliaChatContentProps {
61
118
  * from this module. May be a `React.lazy` component.
62
119
  */
63
120
  voiceSession?: VoiceSessionComponent;
121
+ /**
122
+ * Space the composer keeps clear at the bottom edge, in px — the home indicator, when the
123
+ * chat is flush with the screen (a phone sheet). The ambient background still runs behind it.
124
+ */
125
+ bottomInset?: number;
126
+ /**
127
+ * Hands the first turn of an empty chat to the host instead of sending it here — a sheet opens
128
+ * the conversation in a new sheet over this one. Omitted, the chat sends it itself.
129
+ */
130
+ onStart?: (turn: AliaTurn) => void;
131
+ /** The agents the host knows, with their look: a delegation's thinking avatar is drawn from them. */
132
+ agents?: UseAliaChatOptions['agents'];
133
+ /**
134
+ * The avatar flights this chat takes part in, when a sheet shares them across its chats (the
135
+ * greeting's avatar flies into a header in another sheet). Omitted, the chat has its own.
136
+ */
137
+ flights?: AvatarFlights;
138
+ /** A turn sent once as this chat mounts: the conversation a sheet was opened for. */
139
+ initialTurn?: AliaTurn;
64
140
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
65
141
  onStopReady?: (stop: (() => void) | null) => void;
66
142
  /** Overrides for the chat's own copy; anything omitted is English. */
67
143
  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;
68
146
  }
69
147
 
70
148
  /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
@@ -98,6 +176,8 @@ function AliaChatContentBody({
98
176
  welcomeGreeting,
99
177
  welcomeSubtitle,
100
178
  welcomeSuggestions,
179
+ suggestionMatches,
180
+ onSuggestionQuery,
101
181
  onEditMessage,
102
182
  onThumbsUp,
103
183
  onThumbsDown,
@@ -107,8 +187,15 @@ function AliaChatContentBody({
107
187
  header,
108
188
  isDarkMode,
109
189
  voiceSession: VoiceSession,
190
+ bottomInset = 0,
191
+ onStart,
192
+ agents: knownAgents,
193
+ flights: sharedFlights,
194
+ initialTurn,
110
195
  onStopReady,
111
196
  }: AliaChatContentProps) {
197
+ const ownFlights = useAvatarFlights();
198
+ const flights = sharedFlights ?? ownFlights;
112
199
  const labels = useLabels();
113
200
  const addMenuGroups = useMemo(() => addMenu(labels), [labels]);
114
201
 
@@ -121,8 +208,46 @@ function AliaChatContentBody({
121
208
  [onError],
122
209
  );
123
210
  // ── Chat ──
124
- const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext };
125
- const { messages, setMessages, send, isStreaming, stop, clear } = useAliaChat(chatOptions);
211
+ const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext, agents: knownAgents };
212
+ const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
213
+ useAliaChat(chatOptions);
214
+ 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;
224
+
225
+ // The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
226
+ const startsElsewhere = onStart !== undefined && messages.length === 0;
227
+ const firstTurn = messages.length === 0;
228
+ const submitTurn = useCallback(
229
+ async (text: string, images?: ReadonlyArray<string>) => {
230
+ if (startsElsewhere) {
231
+ // The host's conversation sheet opens first so that the flight, drawn when it launches,
232
+ // is above it; this chat stays on the greeting, so the avatar can still be measured.
233
+ onStart({ text, images });
234
+ await flights.launch([ALIA_TO_PROFILE]);
235
+ return;
236
+ }
237
+ // The greeting leaves with the first turn: measure it before it does.
238
+ if (firstTurn) await flights.launch([ALIA_TO_PROFILE]);
239
+ send(text, images ? { images } : undefined);
240
+ },
241
+ [startsElsewhere, onStart, firstTurn, flights, send],
242
+ );
243
+
244
+ // The conversation this chat was opened for, sent once.
245
+ const initialSent = useRef(false);
246
+ useEffect(() => {
247
+ if (!initialTurn || initialSent.current) return;
248
+ initialSent.current = true;
249
+ send(initialTurn.text, initialTurn.images ? { images: initialTurn.images } : undefined);
250
+ }, [initialTurn, send]);
126
251
 
127
252
  useEffect(() => {
128
253
  onStopReady?.(stop);
@@ -156,6 +281,26 @@ function AliaChatContentBody({
156
281
  [pickImage],
157
282
  );
158
283
 
284
+ // Suggestions over the composer on an empty chat, as the Alia app has them.
285
+ const suggestionQuery = useSuggestionQuery(inputValue, messages.length === 0);
286
+ useEffect(() => {
287
+ onSuggestionQuery?.(suggestionQuery);
288
+ }, [suggestionQuery, onSuggestionQuery]);
289
+ const handlePickSuggestion = useCallback(
290
+ (suggestion: AliaSuggestion) => {
291
+ setInputValue('');
292
+ submitTurn(suggestion.text);
293
+ },
294
+ [submitTurn],
295
+ );
296
+ const suggestions = useComposerSuggestions({
297
+ draft: inputValue,
298
+ enabled: messages.length === 0,
299
+ welcome: welcomeSuggestions,
300
+ matches: suggestionMatches,
301
+ onPick: handlePickSuggestion,
302
+ });
303
+
159
304
  const handleSubmit = useCallback(
160
305
  async (value: string) => {
161
306
  const text = value.trim();
@@ -167,7 +312,7 @@ function AliaChatContentBody({
167
312
  const images = await Promise.all(
168
313
  pending.map((photo) => imageUriToDataUrl(photo.uri, photo.mimeType)),
169
314
  );
170
- send(text, { images });
315
+ submitTurn(text, images);
171
316
  } catch (err) {
172
317
  // Keep the draft: the photos could not be read, nothing was sent.
173
318
  setInputValue(value);
@@ -175,7 +320,7 @@ function AliaChatContentBody({
175
320
  reportError(err instanceof Error ? err.message : 'Could not attach the photos');
176
321
  }
177
322
  },
178
- [send, photos, reportError],
323
+ [submitTurn, photos, reportError],
179
324
  );
180
325
 
181
326
  // ── Dictation: the pill's mic toggles it, the transcript lands in the field ──
@@ -260,18 +405,17 @@ function AliaChatContentBody({
260
405
  return 'writing';
261
406
  }, [messages, isStreaming, isVoiceActive, voiceAgentState]);
262
407
 
408
+ const { user } = useOxy();
409
+ const greeting =
410
+ welcomeGreeting ?? (user ? labels.greetingNamed(getAccountDisplayName(user)) : labels.greeting);
263
411
  const welcomeComponent = useMemo(() => {
264
- if (!welcomeGreeting || messages.length > 0) return undefined;
412
+ if (messages.length > 0) return undefined;
265
413
  return (
266
- <AliaWelcomeMessage
267
- greeting={welcomeGreeting}
268
- subtitle={welcomeSubtitle}
269
- suggestions={welcomeSuggestions}
270
- onSuggestionPress={send}
271
- hideMark
272
- />
414
+ <View className="flex-1 items-center justify-center px-4 py-10">
415
+ <AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} />
416
+ </View>
273
417
  );
274
- }, [welcomeGreeting, welcomeSubtitle, welcomeSuggestions, send, messages.length]);
418
+ }, [greeting, welcomeSubtitle, messages.length]);
275
419
 
276
420
  // ── Ambient wave — one persistent overlay across idle/voice/TTS/STT ──
277
421
  const wave = useAmbientWave({
@@ -327,8 +471,24 @@ function AliaChatContentBody({
327
471
  </Suspense>
328
472
  ) : (
329
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}
330
489
  <ComposerLoader active={isStreaming}>
331
490
  <ComposerPill
491
+ onKeyPress={suggestions.onKeyPress}
332
492
  surface={false}
333
493
  frosted={isStreaming}
334
494
  value={inputValue}
@@ -353,8 +513,8 @@ function AliaChatContentBody({
353
513
 
354
514
  const hasMessages = messages.length > 0;
355
515
  const headerNode = useMemo(
356
- () => header?.({ hasMessages, clear, markState }),
357
- [header, hasMessages, clear, markState],
516
+ () => header?.({ hasMessages, clear, markState, agents }),
517
+ [header, hasMessages, clear, markState, agents],
358
518
  );
359
519
  const background = useMemo(
360
520
  () => (
@@ -370,42 +530,58 @@ function AliaChatContentBody({
370
530
  );
371
531
 
372
532
  return (
373
- <AiChatContainer
374
- header={headerNode}
375
- composer={composer}
376
- // The thread scrolls behind the header and the composer, which fade in over it — as
377
- // in the app's conversation. The wave is the container's background layer, between
378
- // its surface and the turns.
379
- floatingChrome
380
- background={background}
381
- >
382
- <View className="flex-1">
383
- {/* Welcome or Messages */}
384
- <AliaChatMessageList
385
- messages={messages}
386
- isStreaming={isStreaming}
387
- onReadAloud={handleReadAloud}
388
- ttsActiveMessageId={tts.activeMessageId}
389
- ttsPlaybackState={tts.playbackState}
390
- onEditMessage={onEditMessage}
391
- onThumbsUp={onThumbsUp}
392
- onThumbsDown={onThumbsDown}
393
- onApprovePlan={onApprovePlan}
394
- onRejectPlan={onRejectPlan}
395
- onAnswerQuestions={(message) => send(message)}
396
- renderMarkdown={renderMarkdown}
397
- welcomeComponent={welcomeComponent}
398
- />
399
- </View>
400
- </AiChatContainer>
533
+ <AliaFlightsProvider value={flights}>
534
+ <AiChatContainer
535
+ header={headerNode}
536
+ composer={
537
+ bottomInset > 0 ? (
538
+ <View style={{ paddingBottom: bottomInset }}>{composer}</View>
539
+ ) : (
540
+ composer
541
+ )
542
+ }
543
+ // The thread scrolls behind the header and the composer, which fade in over it — as
544
+ // in the app's conversation. The wave is the container's background layer, between
545
+ // its surface and the turns.
546
+ floatingChrome
547
+ // The sheet or screen around it owns the fill and the shape; the container adds neither.
548
+ surface={false}
549
+ background={background}
550
+ >
551
+ <View className="flex-1">
552
+ {/* Welcome or Messages */}
553
+ <AliaChatMessageList
554
+ messages={messages}
555
+ isStreaming={isStreaming}
556
+ onReadAloud={handleReadAloud}
557
+ ttsActiveMessageId={tts.activeMessageId}
558
+ ttsPlaybackState={tts.playbackState}
559
+ onEditMessage={onEditMessage}
560
+ onThumbsUp={onThumbsUp}
561
+ onThumbsDown={onThumbsDown}
562
+ onApprovePlan={onApprovePlan ?? approvePlan}
563
+ onRejectPlan={onRejectPlan ?? rejectPlan}
564
+ renderMarkdown={renderMarkdown}
565
+ welcomeComponent={welcomeComponent}
566
+ />
567
+ </View>
568
+ </AiChatContainer>
569
+ {sharedFlights ? null : <AvatarFlightLayer flights={ownFlights} />}
570
+ </AliaFlightsProvider>
401
571
  );
402
572
  }
403
573
 
404
574
  /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
405
- export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
575
+ export function AliaChatContent({
576
+ labels,
577
+ characterRuntimeUrl = ALIA_CHARACTER_RUNTIME_URL,
578
+ ...props
579
+ }: AliaChatContentProps) {
406
580
  return (
407
- <AliaChatLabelsProvider labels={labels}>
408
- <AliaChatContentBody {...props} />
409
- </AliaChatLabelsProvider>
581
+ <AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
582
+ <AliaChatLabelsProvider labels={labels}>
583
+ <AliaChatContentBody {...props} />
584
+ </AliaChatLabelsProvider>
585
+ </AgentAvatarProvider>
410
586
  );
411
587
  }