@alia.onl/sdk 10.1.1 → 11.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +24 -0
  3. package/package.json +4 -6
  4. package/src/components/AliaAvatar.tsx +66 -0
  5. package/src/components/AliaChatContent.tsx +236 -112
  6. package/src/components/AliaChatHeader.tsx +118 -0
  7. package/src/components/AliaChatMessageList.tsx +197 -260
  8. package/src/components/AliaChatScreen.tsx +23 -24
  9. package/src/components/AliaChatSheet.tsx +70 -316
  10. package/src/components/AliaWelcomeMessage.tsx +47 -50
  11. package/src/components/AmbientField.tsx +577 -0
  12. package/src/components/IdentityMark.tsx +10 -2
  13. package/src/components/Markdown.tsx +116 -40
  14. package/src/components/PlanPreviewCard.tsx +71 -55
  15. package/src/components/QuestionCard.tsx +62 -0
  16. package/src/components/ThinkingBlock.tsx +47 -0
  17. package/src/components/VoiceModeIcon.tsx +10 -0
  18. package/src/components/voice/VoiceControls.tsx +22 -73
  19. package/src/components/voice/VoiceSession.tsx +2 -1
  20. package/src/hooks/useAliaChat.ts +22 -15
  21. package/src/hooks/useAmbientWave.ts +35 -5
  22. package/src/hooks/useSoundEffects.ts +14 -11
  23. package/src/hooks/useSpeechToText.ts +9 -2
  24. package/src/hooks/useTTS.ts +163 -156
  25. package/src/hooks/useVoiceRoom.ts +228 -170
  26. package/src/index.ts +26 -6
  27. package/src/lib/catalogue.ts +17 -4
  28. package/src/lib/chat-stream.ts +25 -7
  29. package/src/lib/chat-transport.ts +2 -1
  30. package/src/lib/citations.ts +63 -0
  31. package/src/lib/content-utils.ts +3 -2
  32. package/src/lib/image-data-url.ts +15 -0
  33. package/src/lib/keyboard.tsx +1 -1
  34. package/src/lib/labels.tsx +81 -0
  35. package/src/lib/questions.ts +78 -0
  36. package/src/lib/speech-recognition.native.ts +15 -4
  37. package/src/lib/speech-recognition.ts +27 -7
  38. package/src/lib/task-labels.ts +67 -0
  39. package/src/lib/tool-call-status.ts +28 -0
  40. package/src/lib/tool-registry.ts +14 -14
  41. package/src/lib/voice-text.ts +12 -6
  42. package/src/lib/voice-turn.ts +4 -1
  43. package/src/lib/work-log-copy.ts +61 -0
  44. package/src/lib/work-log.ts +378 -0
  45. package/src/tools.ts +18 -0
  46. package/src/types.ts +5 -2
  47. package/src/voice.ts +12 -4
  48. package/src/components/Reasoning.tsx +0 -152
  49. package/src/components/ResearchProgressCard.tsx +0 -159
  50. package/src/components/ThinkingIndicator.tsx +0 -98
  51. package/src/components/ui/button.tsx +0 -104
  52. package/src/components/ui/chat-text-input.tsx +0 -184
  53. package/src/components/ui/dropdown-menu.tsx +0 -2
  54. package/src/components/ui/dropdown-menu.web.tsx +0 -383
  55. package/src/components/ui/prompt-input/actions.tsx +0 -28
  56. package/src/components/ui/prompt-input/add-menu.tsx +0 -83
  57. package/src/components/ui/prompt-input/attachments.tsx +0 -181
  58. package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
  59. package/src/components/ui/prompt-input/context.tsx +0 -71
  60. package/src/components/ui/prompt-input/mic-button.tsx +0 -53
  61. package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
  62. package/src/components/ui/prompt-input/submit-button.tsx +0 -50
  63. package/src/components/ui/prompt-input/textarea.tsx +0 -80
  64. package/src/components/ui/text.tsx +0 -29
  65. package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
  66. package/src/components/voice/VoiceOverlay.tsx +0 -75
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 11.1.0
4
+
5
+ Alia can ask clarifying questions, as ChatGPT and Claude do.
6
+
7
+ - **New:** `QuestionCard` (Bloom's `Questionnaire`: single or multiple choice, a free-text
8
+ "Other", one question per step), drawn under a reply that asked. `AliaChatMessageList`
9
+ renders it from the turn's `askUserQuestion` tool call; `AliaChatContent` sends the
10
+ answers as the person's next message (`onAnswerQuestions` on the list, for hosts that
11
+ compose their own).
12
+ - **New, pure** (root entry and `@alia.onl/sdk/tools`): `askedQuestions`, `formatAnswers`,
13
+ `isQuestionInvocation`, `ASK_USER_QUESTION_TOOL`. The question is the stored tool call, so
14
+ it survives a reload; it is answered once any later message exists, typed or picked.
15
+ - The turn **ends** at the card: the server stops after the call and writes no answer under it.
16
+
17
+ ## 11.0.0
18
+
19
+ The chat UI is Bloom's, not a copy of it. Breaking:
20
+
21
+ - **Bloom is a peer dependency** (`@oxy.so/bloom ^9.0.0`; with `@oxy.so/services`
22
+ 11.2.0-oxy-one.5 and `@oxy.so/core` 4.5.0-oxy-one.1 it is the newest set that
23
+ satisfies every peer range). The host must mount `BloomProvider`.
24
+ - **Alia's avatar is Bloom's 3D character.** The host mounts `AgentAvatarProvider`
25
+ with the URL of the hosted `character-runtime` (see the README). New:
26
+ `AliaAvatar`. `IdentityMark` stays only for an agent's likeness.
27
+ - **Removed exports:** `Button`, `buttonVariants`, `buttonTextVariants`, `Text`,
28
+ `TextClassContext` (use `@oxy.so/bloom/button` and `typography`), and
29
+ `ThinkingIndicator` (`AgentThinking`) and `Reasoning`, `ReasoningTrigger`,
30
+ `ReasoningContent` (the reasoning block is `AgentThinking` with children). `zeego`, `class-variance-authority` and
31
+ both `@radix-ui` packages are no longer dependencies.
32
+ - **Composer:** Bloom's `ComposerPill` with a Photos entry in its add menu; the picked photos are its own `attachments`, not a strip mounted above it. Photos are
33
+ now actually sent, as `image_url` parts: `useAliaChat().send(text, { images })`
34
+ (the old composer collected attachments and never sent them). The autocomplete
35
+ props (`useSuggestions`, `onSuggestionUsed`) are gone; nobody passed them.
36
+ - **Sheet:** `AliaChatSheet` is Bloom's `BottomSheet`, detached, radius 40. The
37
+ header is Bloom's `PageHeader` (avatar over a name capsule; press it for a wave).
38
+ `AliaChatScreen` uses the same header; `headerLeft` and `headerRight` still work.
39
+ - **Chat parts:** the thread uses `AiChatUserMessage` and `AiChatFeedbackRow`, replies
40
+ render code through Bloom's `CodeBlock`, research through `WebSearch`, and
41
+ reasoning through `AgentThinking`. The welcome is the app's: the greeting at
42
+ display size and the suggestions as compact rows.
43
+ - **Voice controls:** `VoiceControls` is Bloom's `CallControls`; its `primaryColor` prop
44
+ is gone (Bloom paints from the theme). The plan card is `Card`, `Item` and `Button`;
45
+ editing a message uses Bloom's `Textarea`; the agent line shows a Bloom `Avatar`.
46
+ Colours come from Bloom's theme and tokens, not hard-coded values or the host's
47
+ shadcn classes. `lucide-react-native` and `expo-blur` are no longer used here.
48
+ - **The turn's work is drawn as the app draws it.** Tool calls are Bloom's `TaskList` and
49
+ `WebSearch`, driven by `revealed` from the calls that have really arrived, through one
50
+ shared mapping: `webSearchLog`, `taskListLog` and `isWebInvocation` (root entry), and the
51
+ pure `@alia.onl/sdk/tools` entry (`toolCallStatus`, `extractCitationSources`,
52
+ `getToolDoneLabel`, `getToolPillLabel`), which the app now imports instead of keeping
53
+ copies. `ResearchProgressCard` and the `onToolResultPress` prop are gone: research is a
54
+ step of the `WebSearch` trail, and a tool row is not a button. The words of the log are
55
+ English unless the host passes `labels.workLog` (a translator keyed like the app's).
56
+ - **One animated background.** The ambient gradient (three blurred, drifting colour fields
57
+ that answer the live audio level) is the app's `AmbientField`, now exported by the SDK
58
+ with `PARALLAX_DURATION` and `PARALLAX_EASE`; the chat draws it as its container's
59
+ background and the app imports it from here. `VoiceOverlay`, `AudioWaveVisualizer` and the
60
+ `primaryColor` props that fed them are gone.
61
+ - **Errors:** a failed voice call or dictation goes to `onError` when the host gives one and
62
+ to Bloom's `toast` otherwise (the host needs a `ToastOutlet`; `OxyProvider` mounts one). A
63
+ voice session reports why it ended: `onEnd(error?)`.
64
+ - **Thread:** the messages are a Bloom `AiChatThread` inside `AiChatContainer` with
65
+ floating chrome, so the conversation scrolls behind the header and the composer with
66
+ Bloom's edge fades. The `scrollOffsetY` plumbing is gone.
67
+ - **New:** `labels` on `AliaChatSheet` and `AliaChatScreen` to translate the chat's
68
+ own copy; `AliaChatLabels` and `DEFAULT_LABELS` are exported.
69
+
3
70
  ## 10.1.1
4
71
 
5
72
  `ChatTextInput` no longer passes `placeholderClassName`. No styling runtime ever
package/README.md CHANGED
@@ -22,6 +22,30 @@ listed under `peerDependencies` in `package.json` are yours to install. Metro
22
22
  must resolve package `exports` (`resolver.unstable_enablePackageExports = true`,
23
23
  the default in Expo SDK 53+), because every icon import is a subpath.
24
24
 
25
+ The chat is built from Bloom (`@oxy.so/bloom`, a peer dependency): mount Bloom's
26
+ `BloomProvider` above it, with the one `KeyboardProvider` your app already has.
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
40
+ WebView, so there the URL must be absolute and reachable from the device.
41
+
42
+ Failures (a voice call that cannot start, a dictation error) are shown with Bloom's `toast`,
43
+ so the app needs a `ToastOutlet` (`OxyProvider` mounts one), or pass `onError` to handle them.
44
+
45
+ The chat's own copy is English. Pass `labels` to `AliaChatSheet` or
46
+ `AliaChatScreen` to translate it (`AliaChatLabels`; anything omitted stays
47
+ English). Bloom's own strings follow `BloomProvider`'s `locale`.
48
+
25
49
  Two entry points:
26
50
 
27
51
  - `@alia.onl/sdk` — text chat, dictation, read-aloud, catalogue, primitives.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "10.1.1",
3
+ "version": "11.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",
@@ -9,6 +9,7 @@
9
9
  "exports": {
10
10
  ".": "./src/index.ts",
11
11
  "./content": "./src/lib/content-utils.ts",
12
+ "./tools": "./src/tools.ts",
12
13
  "./voice": "./src/voice.ts",
13
14
  "./package.json": "./package.json"
14
15
  },
@@ -29,19 +30,16 @@
29
30
  "check:entries": "node scripts/check-entry-isolation.mjs"
30
31
  },
31
32
  "dependencies": {
32
- "@radix-ui/react-dropdown-menu": "^2.1.20",
33
- "@radix-ui/react-icons": "^1.3.2",
34
- "class-variance-authority": "^0.7.1",
35
33
  "clsx": "^2.1.1",
36
- "lucide-react-native": "^1.24.0",
37
34
  "react-native-markdown-display": "^7.0.2",
38
35
  "tailwind-merge": "^3.6.0",
39
- "zeego": "^3.0.6",
40
36
  "zustand": "^5.0.15"
41
37
  },
42
38
  "peerDependencies": {
43
39
  "react": "^19.2.3",
44
40
  "react-native": "^0.86.0",
41
+ "@expo/vector-icons": "^15.1.1",
42
+ "@oxy.so/bloom": "^9.0.0",
45
43
  "@oxy.so/core": "^3.0.0 || ^4.0.0",
46
44
  "@oxy.so/services": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
47
45
  "@react-native-async-storage/async-storage": "^2.2.0",
@@ -0,0 +1,66 @@
1
+ import { AgentAvatar, type AvatarConfig, DEFAULT_CONFIG } from '@oxy.so/bloom/agent-avatar';
2
+ import { useEffect, useState } from 'react';
3
+
4
+ /** What Alia is doing; the avatar plays a working turn each time it starts to think or work. */
5
+ export type AliaAvatarState = 'idle' | 'thinking' | 'working' | 'writing';
6
+
7
+ /**
8
+ * Alia's likeness in Bloom's 3D character engine: the six-lobed flower in orchid,
9
+ * plain oval eyes, nothing on its head. The engine ids come from Bloom's catalog
10
+ * (`character-shapes.ts`, `character-colors.ts`); change them here and nowhere else.
11
+ */
12
+ const ALIA_AVATAR: AvatarConfig = {
13
+ ...DEFAULT_CONFIG,
14
+ character: {
15
+ preset: 'blue_beret',
16
+ selections: {
17
+ shape: 'six_lobed_flower',
18
+ color: 'orchid',
19
+ eyes: 'oval',
20
+ eyewear: 'none',
21
+ accessory: 'none',
22
+ },
23
+ },
24
+ };
25
+
26
+ const BUSY: ReadonlySet<AliaAvatarState> = new Set(['thinking', 'working']);
27
+
28
+ export interface AliaAvatarProps {
29
+ /** Width and height in px. */
30
+ size: number;
31
+ /** What Alia is doing. Default `idle`. */
32
+ state?: AliaAvatarState;
33
+ /** Follows the pointer and reacts to touch, as Bloom's engine does. Default `false`. */
34
+ interactive?: boolean;
35
+ /** Change it to replay the avatar's reaction (a wave) — for a button that wraps it. */
36
+ reactionKey?: number;
37
+ }
38
+
39
+ /**
40
+ * Bloom's `AgentAvatar` dressed as Alia. It draws through the 3D runtime, so the host
41
+ * app must mount `AgentAvatarProvider` with the URL of the hosted `character-runtime`;
42
+ * without it Bloom shows its own "unavailable" state.
43
+ */
44
+ export function AliaAvatar({
45
+ size,
46
+ state = 'idle',
47
+ interactive = false,
48
+ reactionKey,
49
+ }: AliaAvatarProps) {
50
+ const [workingKey, setWorkingKey] = useState(0);
51
+ const busy = BUSY.has(state);
52
+ useEffect(() => {
53
+ if (busy) setWorkingKey((key) => key + 1);
54
+ }, [busy]);
55
+
56
+ return (
57
+ <AgentAvatar
58
+ config={ALIA_AVATAR}
59
+ size={size}
60
+ label="Alia"
61
+ workingKey={workingKey}
62
+ interactive={interactive}
63
+ reactionKey={reactionKey}
64
+ />
65
+ );
66
+ }
@@ -3,12 +3,14 @@
3
3
  *
4
4
  * Owns the text chat; voice is an injected capability (`voiceSession`) so that
5
5
  * nothing here reaches `livekit-client` at module-evaluation time. Containers
6
- * only handle layout chrome (sheet modal, full-screen SafeAreaView) and pass a
7
- * `header` render prop that receives the live mark state.
6
+ * only handle layout chrome (sheet modal, full-screen SafeAreaView). The
7
+ * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
8
8
  */
9
9
 
10
10
  import React, {
11
+ createContext,
11
12
  Suspense,
13
+ useContext,
12
14
  useState,
13
15
  useCallback,
14
16
  useEffect,
@@ -16,36 +18,30 @@ import React, {
16
18
  useRef,
17
19
  } from 'react';
18
20
  import { View, StyleSheet } from 'react-native';
19
- import { useSharedValue } from 'react-native-reanimated';
20
- import type { SharedValue } from 'react-native-reanimated';
21
- import Volume2 from 'lucide-react-native/icons/volume-2';
22
21
  import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
23
22
  import { useTTS } from '../hooks/useTTS';
23
+ import { useSpeechToText } from '../hooks/useSpeechToText';
24
+ import { useImagePicker } from '../hooks/useImagePicker';
25
+ import { imageUriToDataUrl } from '../lib/image-data-url';
24
26
  import { useAmbientWave } from '../hooks/useAmbientWave';
25
- import { VoiceOverlay } from './voice/VoiceOverlay';
27
+ import { AmbientField } from './AmbientField';
26
28
  import { AliaChatMessageList } from './AliaChatMessageList';
27
29
  import { AliaWelcomeMessage, type WelcomeSuggestion } from './AliaWelcomeMessage';
28
- import { PromptInput } from './ui/prompt-input/prompt-input';
29
- import { Button } from './ui/button';
30
- import type { IdentityMarkState } from './IdentityMark';
31
- import type {
32
- ChatMessage,
33
- VoiceMessage,
34
- VoiceSessionComponent,
35
- VoiceSessionState,
36
- } from '../types';
37
- import type { Completion } from './ui/prompt-input/context';
30
+ import { ComposerLoader } from '@oxy.so/bloom/composer-loader';
31
+ import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
32
+ import { ComposerPill, type ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
33
+ import { RiImageLine } from '@oxy.so/bloom/icons';
34
+ import { toast } from '@oxy.so/bloom/toast';
35
+ import { Button } from '@oxy.so/bloom/button';
36
+ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
37
+ import { AliaAvatar, type AliaAvatarState } from './AliaAvatar';
38
+ import { VoiceModeIcon } from './VoiceModeIcon';
39
+ import type { ChatMessage, VoiceMessage, VoiceSessionComponent, VoiceSessionState } from '../types';
38
40
 
39
41
  export interface AliaChatContentProps {
40
42
  clientContext?: string;
41
43
  model?: string;
42
44
  apiUrl?: string;
43
- /** Shared value for scroll offset (used by sheet for pan-to-dismiss) */
44
- scrollOffsetY?: SharedValue<number>;
45
- /** Injectable autocomplete hook */
46
- useSuggestions?: (query: string) => { data: Completion[] | undefined; isLoading: boolean };
47
- /** Called when a suggestion is selected from autocomplete */
48
- onSuggestionUsed?: (suggestionId: string) => void;
49
45
  /** Error handler (e.g. toast) */
50
46
  onError?: (message: string) => void;
51
47
  // Welcome message
@@ -58,13 +54,19 @@ export interface AliaChatContentProps {
58
54
  onThumbsDown?: (messageId: string) => void;
59
55
  onApprovePlan?: (planId: string) => void;
60
56
  onRejectPlan?: (planId: string) => void;
61
- onToolResultPress?: (messageId: string) => void;
62
57
  /** Override markdown renderer (app passes CustomMarkdown) */
63
58
  renderMarkdown?: (content: string) => React.ReactNode;
64
- /** Header bar render prop — receives live mark state, message presence, and the clear handler. */
65
- header?: (state: { markState: IdentityMarkState; hasMessages: boolean; clear: () => void }) => React.ReactNode;
66
- /** Theme primary color hex — forwarded to the ambient wave overlay palette. */
67
- primaryColor?: string;
59
+ /** Breadcrumb title beside the brand mark. Default `Alia`; `null` drops the breadcrumb. */
60
+ title?: string | null;
61
+ /**
62
+ * Above the breadcrumb row — a back button, a menu, or a whole custom header
63
+ * (given `title={null}`). A function receives the clear handler.
64
+ */
65
+ headerLeading?:
66
+ | React.ReactNode
67
+ | ((state: { hasMessages: boolean; clear: () => void }) => React.ReactNode);
68
+ /** Right end of the breadcrumb row; receives the clear handler. Replaces Bloom's share/more. */
69
+ actions?: (state: { hasMessages: boolean; clear: () => void }) => React.ReactNode;
68
70
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
69
71
  isDarkMode?: boolean;
70
72
  /**
@@ -75,6 +77,31 @@ export interface AliaChatContentProps {
75
77
  voiceSession?: VoiceSessionComponent;
76
78
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
77
79
  onStopReady?: (stop: (() => void) | null) => void;
80
+ /** Overrides for the chat's own copy; anything omitted is English. */
81
+ labels?: Partial<AliaChatLabels>;
82
+ }
83
+
84
+ /** The live state of the chat, read by the avatar wherever the header or welcome draws it. */
85
+ const MarkStateContext = createContext<AliaAvatarState>('idle');
86
+
87
+ /** Alia's avatar at any size, following the chat's live state. */
88
+ export function AliaChatMark({ size, reactionKey }: { size: number; reactionKey?: number }) {
89
+ return <AliaAvatar size={size} state={useContext(MarkStateContext)} reactionKey={reactionKey} />;
90
+ }
91
+
92
+ /** `AiChatContainer`'s `projectIcon` takes a component that is handed its size as `width`. */
93
+ function BreadcrumbMark({ width }: { width?: number }) {
94
+ return <AliaChatMark size={width ?? 16} />;
95
+ }
96
+
97
+ /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
98
+ function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
99
+ return [
100
+ {
101
+ label: labels.addMenuGroup,
102
+ rows: [{ id: 'photos', label: labels.addPhotos, icon: RiImageLine }],
103
+ },
104
+ ];
78
105
  }
79
106
 
80
107
  /** Adapt a voice message into the chat message format. */
@@ -90,13 +117,10 @@ function adaptVoiceMessage(vm: VoiceMessage): ChatMessage {
90
117
  };
91
118
  }
92
119
 
93
- export function AliaChatContent({
120
+ function AliaChatContentBody({
94
121
  clientContext,
95
122
  model,
96
123
  apiUrl,
97
- scrollOffsetY: externalScrollOffsetY,
98
- useSuggestions,
99
- onSuggestionUsed,
100
124
  onError,
101
125
  welcomeGreeting,
102
126
  welcomeSubtitle,
@@ -106,14 +130,25 @@ export function AliaChatContent({
106
130
  onThumbsDown,
107
131
  onApprovePlan,
108
132
  onRejectPlan,
109
- onToolResultPress,
110
133
  renderMarkdown,
111
- header,
112
- primaryColor,
134
+ title = 'Alia',
135
+ headerLeading,
136
+ actions,
113
137
  isDarkMode,
114
138
  voiceSession: VoiceSession,
115
139
  onStopReady,
116
140
  }: AliaChatContentProps) {
141
+ const labels = useLabels();
142
+ const addMenuGroups = useMemo(() => addMenu(labels), [labels]);
143
+
144
+ // A failure reaches the host's handler when it has one, and Bloom's toast otherwise.
145
+ const reportError = useCallback(
146
+ (message: string, title?: string) => {
147
+ if (onError) onError(title ? `${title}: ${message}` : message);
148
+ else toast.error(title ?? message, title ? { description: message } : undefined);
149
+ },
150
+ [onError],
151
+ );
117
152
  // ── Chat ──
118
153
  const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext };
119
154
  const { messages, setMessages, send, isStreaming, stop, clear } = useAliaChat(chatOptions);
@@ -126,16 +161,64 @@ export function AliaChatContent({
126
161
  // ── Input state ──
127
162
  const [inputValue, setInputValue] = useState('');
128
163
 
129
- const handleSubmit = useCallback(() => {
130
- const text = inputValue.trim();
131
- if (!text) return;
132
- send(text);
133
- setInputValue('');
134
- }, [inputValue, send]);
164
+ // Picked photos wait here, as tiles above the field, until the turn is sent.
165
+ const [photos, setPhotos] = useState<Array<{ id: string; name: string; uri: string }>>([]);
166
+ const { pickImage } = useImagePicker();
167
+
168
+ const handleAddMenuSelect = useCallback(
169
+ async (rowId: string) => {
170
+ if (rowId !== 'photos') return;
171
+ const assets = await pickImage();
172
+ if (!assets?.length) return;
173
+ setPhotos((prev) => [
174
+ ...prev,
175
+ ...assets.map((asset) => ({
176
+ id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
177
+ name: asset.name,
178
+ uri: asset.uri,
179
+ })),
180
+ ]);
181
+ },
182
+ [pickImage],
183
+ );
135
184
 
136
- // ── Scroll offset (use external if provided, else internal) ──
137
- const internalScrollOffsetY = useSharedValue(0);
138
- const scrollOffsetY = externalScrollOffsetY ?? internalScrollOffsetY;
185
+ const handleSubmit = useCallback(
186
+ async (value: string) => {
187
+ const text = value.trim();
188
+ if (!text && photos.length === 0) return;
189
+ const pending = photos;
190
+ setInputValue('');
191
+ setPhotos([]);
192
+ try {
193
+ const images = await Promise.all(pending.map((photo) => imageUriToDataUrl(photo.uri)));
194
+ send(text, { images });
195
+ } catch (err) {
196
+ // Keep the draft: the photos could not be read, nothing was sent.
197
+ setInputValue(value);
198
+ setPhotos(pending);
199
+ reportError(err instanceof Error ? err.message : 'Could not attach the photos');
200
+ }
201
+ },
202
+ [send, photos, reportError],
203
+ );
204
+
205
+ // ── Dictation: the pill's mic toggles it, the transcript lands in the field ──
206
+ const stt = useSpeechToText();
207
+ useEffect(() => {
208
+ if (stt.error) reportError(stt.error);
209
+ }, [stt.error, reportError]);
210
+ const sttRef = useRef(stt);
211
+ sttRef.current = stt;
212
+ const handleListeningChange = useCallback(async (listening: boolean) => {
213
+ const current = sttRef.current;
214
+ if (listening) {
215
+ if (!current.isRecording && !current.isTranscribing) current.startRecording();
216
+ return;
217
+ }
218
+ if (!current.isRecording) return;
219
+ const text = await current.stopAndTranscribe();
220
+ if (text) setInputValue((prev) => (prev ? `${prev} ${text}` : text));
221
+ }, []);
139
222
 
140
223
  // ── TTS ──
141
224
  const tts = useTTS({ apiUrl });
@@ -162,11 +245,17 @@ export function AliaChatContent({
162
245
 
163
246
  // Unmounting the session disconnects the room; the transcript merged above
164
247
  // stays in the chat.
165
- const deactivateVoice = useCallback(() => {
166
- setIsVoiceActive(false);
167
- setVoiceState(null);
168
- textSnapshotRef.current = [];
169
- }, []);
248
+ // A call that fails ends the same way a hang-up does, but the person is told why: a
249
+ // button that silently does nothing reads as broken.
250
+ const deactivateVoice = useCallback(
251
+ (error?: string) => {
252
+ setIsVoiceActive(false);
253
+ setVoiceState(null);
254
+ textSnapshotRef.current = [];
255
+ if (error) reportError(error, labels.voiceFailed);
256
+ },
257
+ [reportError, labels.voiceFailed],
258
+ );
170
259
 
171
260
  // ── TTS read aloud handler ──
172
261
  const handleReadAloud = useCallback(
@@ -178,7 +267,7 @@ export function AliaChatContent({
178
267
 
179
268
  // ── Mark state (drives the header brand mark) ──
180
269
  const voiceAgentState = voiceState?.agentState;
181
- const markState = useMemo<IdentityMarkState>(() => {
270
+ const markState = useMemo<AliaAvatarState>(() => {
182
271
  if (isVoiceActive) {
183
272
  if (voiceAgentState === 'speaking') return 'writing';
184
273
  if (voiceAgentState === 'thinking') return 'thinking';
@@ -200,6 +289,7 @@ export function AliaChatContent({
200
289
  subtitle={welcomeSubtitle}
201
290
  suggestions={welcomeSuggestions}
202
291
  onSuggestionPress={send}
292
+ hideMark
203
293
  />
204
294
  );
205
295
  }, [welcomeGreeting, welcomeSubtitle, welcomeSuggestions, send, messages.length]);
@@ -219,81 +309,108 @@ export function AliaChatContent({
219
309
  isGenerating: isStreaming,
220
310
  });
221
311
 
312
+ // The welcome and a call are the field's moments, so they get the intro's full strength; a
313
+ // conversation being read keeps the hook's subtle one, so the text stays easy to read.
314
+ const ambientIntensity = isVoiceActive || messages.length === 0 ? 1 : wave.intensity;
315
+
222
316
  // ── Voice activate button for empty submit (only if voice was provided) ──
223
317
  const voiceActivateButton = useMemo(
224
318
  () =>
225
319
  VoiceSession ? (
226
320
  <Button
227
- size="icon"
321
+ iconOnly
322
+ tone="action"
323
+ size="md"
324
+ accessibilityLabel={labels.startVoice}
228
325
  onPress={activateVoice}
229
- className="h-8 w-8 rounded-full"
230
- >
231
- <Volume2 size={16} color="white" />
232
- </Button>
326
+ leadingIcon={VoiceModeIcon}
327
+ />
233
328
  ) : undefined,
234
- [VoiceSession, activateVoice],
329
+ [VoiceSession, activateVoice, labels.startVoice],
235
330
  );
236
331
 
237
- return (
238
- <View style={styles.container}>
239
- {/* Header (render prop) */}
240
- {header?.({ markState, hasMessages: messages.length > 0, clear })}
241
-
242
- {/* Persistent ambient wave overlay */}
243
- <VoiceOverlay
244
- waveAmplitude={wave.waveAmplitude}
245
- agentState={wave.agentState}
246
- intensity={wave.intensity}
247
- primaryColor={primaryColor}
248
- isDarkMode={isDarkMode}
249
- />
250
-
251
- {/* Welcome or Messages */}
252
- <AliaChatMessageList
253
- messages={messages}
254
- isStreaming={isStreaming}
255
- scrollOffsetY={scrollOffsetY}
256
- onReadAloud={handleReadAloud}
257
- ttsActiveMessageId={tts.activeMessageId}
258
- ttsPlaybackState={tts.playbackState}
259
- onEditMessage={onEditMessage}
260
- onThumbsUp={onThumbsUp}
261
- onThumbsDown={onThumbsDown}
262
- onApprovePlan={onApprovePlan}
263
- onRejectPlan={onRejectPlan}
264
- onToolResultPress={onToolResultPress}
265
- renderMarkdown={renderMarkdown}
266
- welcomeComponent={welcomeComponent}
267
- />
268
-
269
- {/* Input or Voice Controls */}
270
- {VoiceSession && isVoiceActive ? (
271
- /* Suspense so the session may be handed to us as a React.lazy import. */
272
- <Suspense fallback={null}>
273
- <VoiceSession
274
- apiUrl={apiUrl}
275
- onStateChange={setVoiceState}
276
- onEnd={deactivateVoice}
277
- />
278
- </Suspense>
279
- ) : (
280
- <View style={styles.inputContainer}>
281
- <PromptInput
332
+ const composer =
333
+ VoiceSession && isVoiceActive ? (
334
+ /* Suspense so the session may be handed to us as a React.lazy import. */
335
+ <Suspense fallback={null}>
336
+ <VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
337
+ </Suspense>
338
+ ) : (
339
+ <>
340
+ <ComposerLoader active={isStreaming}>
341
+ <ComposerPill
342
+ surface={false}
343
+ frosted={isStreaming}
282
344
  value={inputValue}
283
345
  onValueChange={setInputValue}
284
346
  onSubmit={handleSubmit}
285
- isLoading={isStreaming}
347
+ busy={isStreaming}
286
348
  onStop={stop}
287
349
  emptyAction={voiceActivateButton}
288
- placeholder="Ask Alia..."
289
- autocomplete={!!useSuggestions}
290
- useSuggestions={useSuggestions}
291
- onSuggestionUsed={onSuggestionUsed}
292
- onError={onError}
350
+ placeholder={labels.placeholder}
351
+ addMenu={addMenuGroups}
352
+ onAddMenuSelect={handleAddMenuSelect}
353
+ attachments={photos.map((photo) => ({
354
+ id: photo.id,
355
+ name: photo.name,
356
+ kind: 'image' as const,
357
+ src: photo.uri,
358
+ }))}
359
+ onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
360
+ voiceState={
361
+ stt.isTranscribing ? 'transcribing' : stt.isRecording ? 'recording' : 'idle'
362
+ }
363
+ onVoiceStateChange={(next) => void handleListeningChange(next === 'recording')}
364
+ />
365
+ </ComposerLoader>
366
+ </>
367
+ );
368
+
369
+ return (
370
+ <MarkStateContext.Provider value={markState}>
371
+ <AiChatContainer
372
+ project={title ?? undefined}
373
+ projectIcon={BreadcrumbMark}
374
+ header={
375
+ typeof headerLeading === 'function'
376
+ ? headerLeading({ hasMessages: messages.length > 0, clear })
377
+ : headerLeading
378
+ }
379
+ actions={actions?.({ hasMessages: messages.length > 0, clear })}
380
+ composer={composer}
381
+ // The thread scrolls behind the header and the composer, which fade in over it — as
382
+ // in the app's conversation. The wave is the container's background layer, between
383
+ // its surface and the turns.
384
+ floatingChrome
385
+ background={
386
+ <AmbientField
387
+ waveAmplitude={wave.waveAmplitude}
388
+ agentState={wave.agentState}
389
+ intensity={ambientIntensity}
390
+ isDarkMode={isDarkMode}
391
+ />
392
+ }
393
+ >
394
+ <View style={styles.body}>
395
+ {/* Welcome or Messages */}
396
+ <AliaChatMessageList
397
+ messages={messages}
398
+ isStreaming={isStreaming}
399
+ onReadAloud={handleReadAloud}
400
+ ttsActiveMessageId={tts.activeMessageId}
401
+ ttsPlaybackState={tts.playbackState}
402
+ onEditMessage={onEditMessage}
403
+ onThumbsUp={onThumbsUp}
404
+ onThumbsDown={onThumbsDown}
405
+ onApprovePlan={onApprovePlan}
406
+ onRejectPlan={onRejectPlan}
407
+ onAnswerQuestions={(message) => send(message)}
408
+ renderMarkdown={renderMarkdown}
409
+ welcomeComponent={welcomeComponent}
293
410
  />
294
411
  </View>
295
- )}
296
- </View>
412
+ </AiChatContainer>
413
+ </MarkStateContext.Provider>
297
414
  );
298
415
  }
299
416
 
@@ -302,9 +419,16 @@ const styles = StyleSheet.create({
302
419
  flex: 1,
303
420
  position: 'relative',
304
421
  },
305
- inputContainer: {
306
- paddingHorizontal: 12,
307
- paddingTop: 8,
308
- paddingBottom: 8,
422
+ body: {
423
+ flex: 1,
309
424
  },
310
425
  });
426
+
427
+ /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
428
+ export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
429
+ return (
430
+ <AliaChatLabelsProvider labels={labels}>
431
+ <AliaChatContentBody {...props} />
432
+ </AliaChatLabelsProvider>
433
+ );
434
+ }