@alia.onl/sdk 10.1.1 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +53 -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 +235 -112
  6. package/src/components/AliaChatHeader.tsx +118 -0
  7. package/src/components/AliaChatMessageList.tsx +171 -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/ThinkingBlock.tsx +47 -0
  16. package/src/components/VoiceModeIcon.tsx +10 -0
  17. package/src/components/voice/VoiceControls.tsx +22 -73
  18. package/src/components/voice/VoiceSession.tsx +2 -1
  19. package/src/hooks/useAliaChat.ts +22 -15
  20. package/src/hooks/useAmbientWave.ts +35 -5
  21. package/src/hooks/useSoundEffects.ts +14 -11
  22. package/src/hooks/useSpeechToText.ts +9 -2
  23. package/src/hooks/useTTS.ts +163 -156
  24. package/src/hooks/useVoiceRoom.ts +228 -170
  25. package/src/index.ts +18 -6
  26. package/src/lib/catalogue.ts +17 -4
  27. package/src/lib/chat-stream.ts +25 -7
  28. package/src/lib/chat-transport.ts +2 -1
  29. package/src/lib/citations.ts +63 -0
  30. package/src/lib/content-utils.ts +3 -2
  31. package/src/lib/image-data-url.ts +15 -0
  32. package/src/lib/keyboard.tsx +1 -1
  33. package/src/lib/labels.tsx +81 -0
  34. package/src/lib/speech-recognition.native.ts +15 -4
  35. package/src/lib/speech-recognition.ts +27 -7
  36. package/src/lib/task-labels.ts +67 -0
  37. package/src/lib/tool-call-status.ts +28 -0
  38. package/src/lib/tool-registry.ts +14 -14
  39. package/src/lib/voice-text.ts +12 -6
  40. package/src/lib/voice-turn.ts +4 -1
  41. package/src/lib/work-log-copy.ts +61 -0
  42. package/src/lib/work-log.ts +378 -0
  43. package/src/tools.ts +11 -0
  44. package/src/types.ts +5 -2
  45. package/src/voice.ts +12 -4
  46. package/src/components/Reasoning.tsx +0 -152
  47. package/src/components/ResearchProgressCard.tsx +0 -159
  48. package/src/components/ThinkingIndicator.tsx +0 -98
  49. package/src/components/ui/button.tsx +0 -104
  50. package/src/components/ui/chat-text-input.tsx +0 -184
  51. package/src/components/ui/dropdown-menu.tsx +0 -2
  52. package/src/components/ui/dropdown-menu.web.tsx +0 -383
  53. package/src/components/ui/prompt-input/actions.tsx +0 -28
  54. package/src/components/ui/prompt-input/add-menu.tsx +0 -83
  55. package/src/components/ui/prompt-input/attachments.tsx +0 -181
  56. package/src/components/ui/prompt-input/autocomplete.tsx +0 -168
  57. package/src/components/ui/prompt-input/context.tsx +0 -71
  58. package/src/components/ui/prompt-input/mic-button.tsx +0 -53
  59. package/src/components/ui/prompt-input/prompt-input.tsx +0 -279
  60. package/src/components/ui/prompt-input/submit-button.tsx +0 -50
  61. package/src/components/ui/prompt-input/textarea.tsx +0 -80
  62. package/src/components/ui/text.tsx +0 -29
  63. package/src/components/voice/AudioWaveVisualizer.tsx +0 -444
  64. package/src/components/voice/VoiceOverlay.tsx +0 -75
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 11.0.0
4
+
5
+ The chat UI is Bloom's, not a copy of it. Breaking:
6
+
7
+ - **Bloom is a peer dependency** (`@oxy.so/bloom ^9.0.0`; with `@oxy.so/services`
8
+ 11.2.0-oxy-one.5 and `@oxy.so/core` 4.5.0-oxy-one.1 it is the newest set that
9
+ satisfies every peer range). The host must mount `BloomProvider`.
10
+ - **Alia's avatar is Bloom's 3D character.** The host mounts `AgentAvatarProvider`
11
+ with the URL of the hosted `character-runtime` (see the README). New:
12
+ `AliaAvatar`. `IdentityMark` stays only for an agent's likeness.
13
+ - **Removed exports:** `Button`, `buttonVariants`, `buttonTextVariants`, `Text`,
14
+ `TextClassContext` (use `@oxy.so/bloom/button` and `typography`), and
15
+ `ThinkingIndicator` (`AgentThinking`) and `Reasoning`, `ReasoningTrigger`,
16
+ `ReasoningContent` (the reasoning block is `AgentThinking` with children). `zeego`, `class-variance-authority` and
17
+ both `@radix-ui` packages are no longer dependencies.
18
+ - **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
19
+ now actually sent, as `image_url` parts: `useAliaChat().send(text, { images })`
20
+ (the old composer collected attachments and never sent them). The autocomplete
21
+ props (`useSuggestions`, `onSuggestionUsed`) are gone; nobody passed them.
22
+ - **Sheet:** `AliaChatSheet` is Bloom's `BottomSheet`, detached, radius 40. The
23
+ header is Bloom's `PageHeader` (avatar over a name capsule; press it for a wave).
24
+ `AliaChatScreen` uses the same header; `headerLeft` and `headerRight` still work.
25
+ - **Chat parts:** the thread uses `AiChatUserMessage` and `AiChatFeedbackRow`, replies
26
+ render code through Bloom's `CodeBlock`, research through `WebSearch`, and
27
+ reasoning through `AgentThinking`. The welcome is the app's: the greeting at
28
+ display size and the suggestions as compact rows.
29
+ - **Voice controls:** `VoiceControls` is Bloom's `CallControls`; its `primaryColor` prop
30
+ is gone (Bloom paints from the theme). The plan card is `Card`, `Item` and `Button`;
31
+ editing a message uses Bloom's `Textarea`; the agent line shows a Bloom `Avatar`.
32
+ Colours come from Bloom's theme and tokens, not hard-coded values or the host's
33
+ shadcn classes. `lucide-react-native` and `expo-blur` are no longer used here.
34
+ - **The turn's work is drawn as the app draws it.** Tool calls are Bloom's `TaskList` and
35
+ `WebSearch`, driven by `revealed` from the calls that have really arrived, through one
36
+ shared mapping: `webSearchLog`, `taskListLog` and `isWebInvocation` (root entry), and the
37
+ pure `@alia.onl/sdk/tools` entry (`toolCallStatus`, `extractCitationSources`,
38
+ `getToolDoneLabel`, `getToolPillLabel`), which the app now imports instead of keeping
39
+ copies. `ResearchProgressCard` and the `onToolResultPress` prop are gone: research is a
40
+ step of the `WebSearch` trail, and a tool row is not a button. The words of the log are
41
+ English unless the host passes `labels.workLog` (a translator keyed like the app's).
42
+ - **One animated background.** The ambient gradient (three blurred, drifting colour fields
43
+ that answer the live audio level) is the app's `AmbientField`, now exported by the SDK
44
+ with `PARALLAX_DURATION` and `PARALLAX_EASE`; the chat draws it as its container's
45
+ background and the app imports it from here. `VoiceOverlay`, `AudioWaveVisualizer` and the
46
+ `primaryColor` props that fed them are gone.
47
+ - **Errors:** a failed voice call or dictation goes to `onError` when the host gives one and
48
+ to Bloom's `toast` otherwise (the host needs a `ToastOutlet`; `OxyProvider` mounts one). A
49
+ voice session reports why it ended: `onEnd(error?)`.
50
+ - **Thread:** the messages are a Bloom `AiChatThread` inside `AiChatContainer` with
51
+ floating chrome, so the conversation scrolls behind the header and the composer with
52
+ Bloom's edge fades. The `scrollOffsetY` plumbing is gone.
53
+ - **New:** `labels` on `AliaChatSheet` and `AliaChatScreen` to translate the chat's
54
+ own copy; `AliaChatLabels` and `DEFAULT_LABELS` are exported.
55
+
3
56
  ## 10.1.1
4
57
 
5
58
  `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.0.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,107 @@ 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
+ renderMarkdown={renderMarkdown}
408
+ welcomeComponent={welcomeComponent}
293
409
  />
294
410
  </View>
295
- )}
296
- </View>
411
+ </AiChatContainer>
412
+ </MarkStateContext.Provider>
297
413
  );
298
414
  }
299
415
 
@@ -302,9 +418,16 @@ const styles = StyleSheet.create({
302
418
  flex: 1,
303
419
  position: 'relative',
304
420
  },
305
- inputContainer: {
306
- paddingHorizontal: 12,
307
- paddingTop: 8,
308
- paddingBottom: 8,
421
+ body: {
422
+ flex: 1,
309
423
  },
310
424
  });
425
+
426
+ /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
427
+ export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
428
+ return (
429
+ <AliaChatLabelsProvider labels={labels}>
430
+ <AliaChatContentBody {...props} />
431
+ </AliaChatLabelsProvider>
432
+ );
433
+ }