@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
@@ -1,11 +1,15 @@
1
1
  import React, { useMemo, type ReactNode } from 'react';
2
2
  import { View, Text, Platform, Linking, useColorScheme } from 'react-native';
3
3
  import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
4
+ import { CodeBlock } from '@oxy.so/bloom/code';
4
5
  import type { AliaColors } from '../theme';
5
6
 
6
7
  // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
7
8
  // The main app always passes resolved colors via the `colors` prop.
8
- const FALLBACK_LIGHT: Pick<AliaColors, 'text' | 'muted' | 'border' | 'primary' | 'mutedForeground'> = {
9
+ const FALLBACK_LIGHT: Pick<
10
+ AliaColors,
11
+ 'text' | 'muted' | 'border' | 'primary' | 'mutedForeground'
12
+ > = {
9
13
  text: '#11181C',
10
14
  muted: '#F4F4F5',
11
15
  border: '#E5E5EA',
@@ -28,32 +32,37 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
28
32
  const { text: textColor, muted, border, primary, mutedForeground } = colors;
29
33
 
30
34
  /**
31
- * Fenced and indented code, drawn by the host when it brings a code card of
32
- * its own (the app passes Bloom's `CodeBlock`). This package has no Bloom
33
- * dependency, so it cannot name one itself; without the prop the library's
34
- * default `fence` / `code_block` rendering, styled below, stays.
35
+ * Fenced and indented code: Bloom's `CodeBlock` unless the host brings a
36
+ * code card of its own.
35
37
  */
36
- const codeRules = renderCodeBlock === undefined
37
- ? {}
38
- : {
39
- fence: (node: ASTNode) => (
40
- <View key={node.key} style={{ marginVertical: 8 }}>
41
- {renderCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
42
- </View>
43
- ),
44
- code_block: (node: ASTNode) => (
45
- <View key={node.key} style={{ marginVertical: 8 }}>
46
- {renderCodeBlock(node.content.replace(/\n$/, ''), undefined)}
47
- </View>
48
- ),
49
- };
38
+ const drawCodeBlock: RenderCodeBlock =
39
+ renderCodeBlock ?? ((code, language) => <CodeBlock code={code} language={language} />);
40
+ const codeRules = {
41
+ fence: (node: ASTNode) => (
42
+ <View key={node.key} style={{ marginVertical: 8 }}>
43
+ {drawCodeBlock(node.content.replace(/\n$/, ''), codeLanguage(node))}
44
+ </View>
45
+ ),
46
+ code_block: (node: ASTNode) => (
47
+ <View key={node.key} style={{ marginVertical: 8 }}>
48
+ {drawCodeBlock(node.content.replace(/\n$/, ''), undefined)}
49
+ </View>
50
+ ),
51
+ };
50
52
 
51
53
  return {
52
54
  ...codeRules,
53
55
  heading1: (node: ASTNode, children: ReactNode[]) => (
54
56
  <Text
55
57
  key={node.key}
56
- style={{ marginBottom: 8, marginTop: 12, fontSize: 18, fontWeight: '600', lineHeight: 24, color: textColor }}
58
+ style={{
59
+ marginBottom: 8,
60
+ marginTop: 12,
61
+ fontSize: 18,
62
+ fontWeight: '600',
63
+ lineHeight: 24,
64
+ color: textColor,
65
+ }}
57
66
  >
58
67
  {children}
59
68
  </Text>
@@ -61,7 +70,13 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
61
70
  heading2: (node: ASTNode, children: ReactNode[]) => (
62
71
  <Text
63
72
  key={node.key}
64
- style={{ marginBottom: 8, marginTop: 12, ...HEADING_TEXT, fontWeight: '600', color: textColor }}
73
+ style={{
74
+ marginBottom: 8,
75
+ marginTop: 12,
76
+ ...HEADING_TEXT,
77
+ fontWeight: '600',
78
+ color: textColor,
79
+ }}
65
80
  >
66
81
  {children}
67
82
  </Text>
@@ -69,7 +84,13 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
69
84
  heading3: (node: ASTNode, children: ReactNode[]) => (
70
85
  <Text
71
86
  key={node.key}
72
- style={{ marginBottom: 6, marginTop: 8, ...HEADING_TEXT, fontWeight: '600', color: textColor }}
87
+ style={{
88
+ marginBottom: 6,
89
+ marginTop: 8,
90
+ ...HEADING_TEXT,
91
+ fontWeight: '600',
92
+ color: textColor,
93
+ }}
73
94
  >
74
95
  {children}
75
96
  </Text>
@@ -77,7 +98,13 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
77
98
  heading4: (node: ASTNode, children: ReactNode[]) => (
78
99
  <Text
79
100
  key={node.key}
80
- style={{ marginBottom: 6, marginTop: 8, ...HEADING_TEXT, fontWeight: '500', color: textColor }}
101
+ style={{
102
+ marginBottom: 6,
103
+ marginTop: 8,
104
+ ...HEADING_TEXT,
105
+ fontWeight: '500',
106
+ color: textColor,
107
+ }}
81
108
  >
82
109
  {children}
83
110
  </Text>
@@ -85,7 +112,13 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
85
112
  heading5: (node: ASTNode, children: ReactNode[]) => (
86
113
  <Text
87
114
  key={node.key}
88
- style={{ marginBottom: 4, marginTop: 8, ...HEADING_TEXT, fontWeight: '500', color: textColor }}
115
+ style={{
116
+ marginBottom: 4,
117
+ marginTop: 8,
118
+ ...HEADING_TEXT,
119
+ fontWeight: '500',
120
+ color: textColor,
121
+ }}
89
122
  >
90
123
  {children}
91
124
  </Text>
@@ -93,7 +126,13 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
93
126
  heading6: (node: ASTNode, children: ReactNode[]) => (
94
127
  <Text
95
128
  key={node.key}
96
- style={{ marginBottom: 4, marginTop: 8, ...HEADING_TEXT, fontWeight: '500', color: textColor }}
129
+ style={{
130
+ marginBottom: 4,
131
+ marginTop: 8,
132
+ ...HEADING_TEXT,
133
+ fontWeight: '500',
134
+ color: textColor,
135
+ }}
97
136
  >
98
137
  {children}
99
138
  </Text>
@@ -138,19 +177,27 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
138
177
 
139
178
  return (
140
179
  <View key={node.key} style={{ flexDirection: 'row', paddingVertical: 2, paddingLeft: 16 }}>
141
- <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>{bullet}</Text>
180
+ <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
181
+ {bullet}
182
+ </Text>
142
183
  <Text style={{ flex: 1, ...BODY_TEXT, color: textColor }}>{children}</Text>
143
184
  </View>
144
185
  );
145
186
  },
146
187
  ordered_list: (node: ASTNode, children: ReactNode[]) => (
147
- <View key={node.key} style={{ marginVertical: 8 }}>{children}</View>
188
+ <View key={node.key} style={{ marginVertical: 8 }}>
189
+ {children}
190
+ </View>
148
191
  ),
149
192
  unordered_list: (node: ASTNode, children: ReactNode[]) => (
150
- <View key={node.key} style={{ marginVertical: 8 }}>{children}</View>
193
+ <View key={node.key} style={{ marginVertical: 8 }}>
194
+ {children}
195
+ </View>
151
196
  ),
152
197
  strong: (node: ASTNode, children: ReactNode[]) => (
153
- <Text key={node.key} style={{ fontWeight: '600', ...BODY_TEXT, color: textColor }}>{children}</Text>
198
+ <Text key={node.key} style={{ fontWeight: '600', ...BODY_TEXT, color: textColor }}>
199
+ {children}
200
+ </Text>
154
201
  ),
155
202
  link: (node: ASTNode, children: ReactNode[]) => (
156
203
  <Text
@@ -164,7 +211,9 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
164
211
  </Text>
165
212
  ),
166
213
  paragraph: (node: ASTNode, children: ReactNode[]) => (
167
- <Text key={node.key} style={{ marginBottom: 8, ...BODY_TEXT, color: textColor }}>{children}</Text>
214
+ <Text key={node.key} style={{ marginBottom: 8, ...BODY_TEXT, color: textColor }}>
215
+ {children}
216
+ </Text>
168
217
  ),
169
218
  blockquote: (node: ASTNode, children: ReactNode[]) => (
170
219
  <View
@@ -194,7 +243,11 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
194
243
  />
195
244
  ),
196
245
  body: (node: ASTNode, children: ReactNode[]) => {
197
- return <View key={node.key} style={{ marginBottom: -8 }}>{children}</View>;
246
+ return (
247
+ <View key={node.key} style={{ marginBottom: -8 }}>
248
+ {children}
249
+ </View>
250
+ );
198
251
  },
199
252
  };
200
253
  }
@@ -215,9 +268,8 @@ function createStyles(colors: AliaColors, fontFamily: string | undefined) {
215
268
  // in any Expo consumer, and this renderer cannot inherit a face.
216
269
  //
217
270
  // `fontFamily` therefore comes from the caller, which is the only party
218
- // that both knows the family and is allowed to name it: this package has no
219
- // `@oxy.so/bloom` dependency, and react-native-markdown-display takes RN
220
- // style objects, so there is no className path to NativeWind's token.
271
+ // that both knows the family and is allowed to name it: react-native-markdown-display
272
+ // takes RN style objects, so there is no className path to NativeWind's token.
221
273
  body: { ...BODY_TEXT, color: textColor, fontFamily },
222
274
  text: { ...BODY_TEXT, color: textColor, fontFamily },
223
275
  paragraph: { marginTop: 0, marginBottom: 8 },
@@ -255,7 +307,13 @@ function createStyles(colors: AliaColors, fontFamily: string | undefined) {
255
307
  borderRadius: 6,
256
308
  padding: 12,
257
309
  },
258
- table: { borderWidth: 1, borderColor: border, borderRadius: 12, marginVertical: 8, overflow: 'hidden' as const },
310
+ table: {
311
+ borderWidth: 1,
312
+ borderColor: border,
313
+ borderRadius: 12,
314
+ marginVertical: 8,
315
+ overflow: 'hidden' as const,
316
+ },
259
317
  thead: { backgroundColor: muted },
260
318
  th: { flex: 1, padding: 8, fontWeight: '600' as const, ...BODY_TEXT, color: textColor },
261
319
  td: { flex: 1, padding: 8, ...BODY_TEXT, color: textColor },
@@ -289,8 +347,8 @@ export interface AliaMarkdownProps {
289
347
  * Omitted, body text inherits: correct on web, where Bloom's
290
348
  * `--bloom-font-sans` reaches it through the cascade. On native nothing is
291
349
  * inherited (see `createStyles`), so a native caller that wants Bloom's face
292
- * has to pass it — this package has no `@oxy.so/bloom` dependency and so
293
- * cannot name it itself.
350
+ * has to pass it — this renderer takes RN style objects, so it cannot
351
+ * read Bloom's token itself.
294
352
  */
295
353
  fontFamily?: string;
296
354
  content: string;
@@ -412,13 +470,31 @@ function ParsedMarkdown({ content, rules, styles }: MarkdownBlockProps) {
412
470
 
413
471
  const MarkdownBlock = React.memo(ParsedMarkdown);
414
472
 
415
- export function AliaMarkdown({ content, colors: colorOverrides, fontFamily, renderCodeBlock }: AliaMarkdownProps) {
473
+ export function AliaMarkdown({
474
+ content,
475
+ colors: colorOverrides,
476
+ fontFamily,
477
+ renderCodeBlock,
478
+ }: AliaMarkdownProps) {
416
479
  const scheme = useColorScheme();
417
480
  const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
418
481
  const colors = { ...fallback, ...colorOverrides } as AliaColors;
419
482
 
420
- const customRules = useMemo(() => createRules(colors, renderCodeBlock), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, renderCodeBlock]);
421
- const markdownStyles = useMemo(() => createStyles(colors, fontFamily), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily]);
483
+ const customRules = useMemo(
484
+ () => createRules(colors, renderCodeBlock),
485
+ [
486
+ colors.text,
487
+ colors.muted,
488
+ colors.border,
489
+ colors.primary,
490
+ colors.mutedForeground,
491
+ renderCodeBlock,
492
+ ],
493
+ );
494
+ const markdownStyles = useMemo(
495
+ () => createStyles(colors, fontFamily),
496
+ [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground, fontFamily],
497
+ );
422
498
  const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
423
499
 
424
500
  if (blocks.length === 1) {
@@ -1,11 +1,13 @@
1
- import { View, Pressable } from 'react-native';
2
- import { Text } from './ui/text';
3
- import { Button } from './ui/button';
4
- import Check from 'lucide-react-native/icons/check';
5
- import X from 'lucide-react-native/icons/x';
6
- import ListChecks from 'lucide-react-native/icons/list-checks';
1
+ import { Button } from '@oxy.so/bloom/button';
2
+ import { Card, CardBody, CardFooter, CardHeader, CardTitle } from '@oxy.so/bloom/card';
3
+ import { RiCheckLine, RiCloseLine, RiListCheck3 } from '@oxy.so/bloom/icons';
4
+ import { Item } from '@oxy.so/bloom/item';
5
+ import { useTheme } from '@oxy.so/bloom/theme';
6
+ import { Text } from '@oxy.so/bloom/typography';
7
+ import { View } from 'react-native';
7
8
  import Animated from 'react-native-reanimated';
8
9
  import { planPreviewEnter } from '../animations';
10
+ import { useLabels } from '../lib/labels';
9
11
  import type { PlanStep } from '../types';
10
12
 
11
13
  interface PlanPreviewCardProps {
@@ -16,64 +18,78 @@ interface PlanPreviewCardProps {
16
18
  rejected?: boolean;
17
19
  }
18
20
 
19
- export function PlanPreviewCard({ steps, onApprove, onReject, approved, rejected }: PlanPreviewCardProps) {
20
- if (approved) {
21
- return (
22
- <View className="flex-row items-center gap-1.5 py-1">
23
- <Check size={12} className="text-green-500" />
24
- <Text className="text-xs text-muted-foreground">Plan approved</Text>
25
- </View>
26
- );
27
- }
21
+ /** The plan Alia proposes, until the person approves or cancels it; then one line saying which. */
22
+ export function PlanPreviewCard({
23
+ steps,
24
+ onApprove,
25
+ onReject,
26
+ approved,
27
+ rejected,
28
+ }: PlanPreviewCardProps) {
29
+ const labels = useLabels();
30
+ const { colors } = useTheme();
28
31
 
29
- if (rejected) {
32
+ if (approved || rejected) {
33
+ const Icon = approved ? RiCheckLine : RiCloseLine;
30
34
  return (
31
35
  <View className="flex-row items-center gap-1.5 py-1">
32
- <X size={12} className="text-red-500" />
33
- <Text className="text-xs text-muted-foreground">Plan cancelled</Text>
36
+ <Icon width={14} height={14} fill={approved ? colors.success : colors.error} />
37
+ <Text className="text-xs text-text-secondary">
38
+ {approved ? labels.planApproved : labels.planCancelled}
39
+ </Text>
34
40
  </View>
35
41
  );
36
42
  }
37
43
 
38
44
  return (
39
- <Animated.View entering={planPreviewEnter}>
40
- <View className="border border-border rounded-xl bg-card p-4 gap-3 my-2">
41
- <View className="flex-row items-center gap-2">
42
- <ListChecks size={16} className="text-foreground" />
43
- <Text className="text-sm font-semibold text-foreground">Here's my plan</Text>
44
- </View>
45
-
46
- <View className="gap-2">
47
- {steps.map((step, i) => (
48
- <View key={i} className="flex-row items-start gap-2.5">
49
- <View className="w-5 h-5 rounded-full bg-muted items-center justify-center mt-0.5">
50
- <Text className="text-[10px] font-bold text-muted-foreground">{i + 1}</Text>
51
- </View>
52
- <View className="flex-1">
53
- <Text className="text-[13px] font-medium text-foreground">{step.action}</Text>
54
- {step.description ? (
55
- <Text className="text-xs text-muted-foreground">{step.description}</Text>
56
- ) : null}
57
- </View>
58
- </View>
45
+ <Animated.View entering={planPreviewEnter} className="my-2">
46
+ <Card appearance="outline">
47
+ <CardHeader>
48
+ <View className="flex-row items-center gap-2">
49
+ <RiListCheck3 width={16} height={16} fill={colors.icon} />
50
+ <CardTitle>{labels.planTitle}</CardTitle>
51
+ </View>
52
+ </CardHeader>
53
+ <CardBody>
54
+ {steps.map((step, index) => (
55
+ <Item
56
+ // A plan has no ids and its steps never reorder: the position is the identity.
57
+ // biome-ignore lint/suspicious/noArrayIndexKey: see above
58
+ key={index}
59
+ density="compact"
60
+ leading={
61
+ <Text className="w-5 text-center text-xs font-bold text-text-secondary">
62
+ {index + 1}
63
+ </Text>
64
+ }
65
+ title={step.action}
66
+ subtitle={step.description || undefined}
67
+ />
59
68
  ))}
60
- </View>
61
-
62
- <View className="flex-row gap-2 pt-1">
63
- <Button onPress={onApprove} size="sm" className="flex-1">
64
- <View className="flex-row items-center gap-1.5">
65
- <Check size={14} className="text-primary-foreground" />
66
- <Text className="text-sm font-medium text-primary-foreground">Approve</Text>
67
- </View>
68
- </Button>
69
- <Pressable
70
- onPress={onReject}
71
- className="flex-1 items-center justify-center py-2 rounded-lg active:opacity-70"
72
- >
73
- <Text className="text-sm text-muted-foreground">Cancel</Text>
74
- </Pressable>
75
- </View>
76
- </View>
69
+ </CardBody>
70
+ <CardFooter>
71
+ <View className="flex-1 flex-row gap-2">
72
+ <Button
73
+ tone="action"
74
+ size="sm"
75
+ className="flex-1"
76
+ leadingIcon={RiCheckLine}
77
+ onPress={onApprove}
78
+ >
79
+ {labels.planApprove}
80
+ </Button>
81
+ <Button
82
+ tone="neutral"
83
+ appearance="subtle"
84
+ size="sm"
85
+ className="flex-1"
86
+ onPress={onReject}
87
+ >
88
+ {labels.planCancel}
89
+ </Button>
90
+ </View>
91
+ </CardFooter>
92
+ </Card>
77
93
  </Animated.View>
78
94
  );
79
95
  }
@@ -0,0 +1,47 @@
1
+ import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { View } from 'react-native';
4
+ import { useLabels } from '../lib/labels';
5
+ import { AliaMarkdown } from './Markdown';
6
+
7
+ interface ThinkingBlockProps {
8
+ /** The model's reasoning, as Markdown. */
9
+ text: string;
10
+ /** Still reasoning: the block opens, and closes again when this turns false. */
11
+ streaming: boolean;
12
+ }
13
+
14
+ /**
15
+ * A turn's reasoning: Bloom's `AgentThinking` as a disclosure (live while it
16
+ * streams, "Thought for N seconds" once done), with the text set as Markdown.
17
+ * Open and close, the indicator and the chevron are Bloom's; this only counts
18
+ * how long the stream ran, because the message carries no duration.
19
+ */
20
+ export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
21
+ const labels = useLabels();
22
+ const startedAt = useRef<number | null>(null);
23
+ const [seconds, setSeconds] = useState<number>();
24
+
25
+ useEffect(() => {
26
+ if (streaming) {
27
+ startedAt.current = Date.now();
28
+ } else if (startedAt.current !== null) {
29
+ setSeconds(Math.max(1, Math.round((Date.now() - startedAt.current) / 1000)));
30
+ startedAt.current = null;
31
+ }
32
+ }, [streaming]);
33
+
34
+ const finishedLabel = seconds ? labels.thoughtFor(seconds) : labels.reasoning;
35
+
36
+ return (
37
+ <AgentThinking
38
+ variant="stars"
39
+ thinking={streaming}
40
+ label={streaming ? undefined : finishedLabel}
41
+ >
42
+ <View style={{ opacity: streaming ? 0.8 : 1 }}>
43
+ <AliaMarkdown content={text} />
44
+ </View>
45
+ </AgentThinking>
46
+ );
47
+ }
@@ -0,0 +1,10 @@
1
+ import Entypo from '@expo/vector-icons/Entypo';
2
+ import type { BloomIconComponent } from '@oxy.so/bloom/icons';
3
+
4
+ /**
5
+ * The glyph of Alia's voice button: Entypo's `sound`. Remix has no equivalent, so it is
6
+ * handed Bloom's icon contract (`width` / `fill`) for a Bloom `Button` to size and colour.
7
+ */
8
+ export const VoiceModeIcon: BloomIconComponent = ({ width = 18, fill }) => (
9
+ <Entypo name="sound" size={width} color={fill} />
10
+ );
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Bottom voice control bar that replaces the composer when voice mode is active.
3
- * Contains status text and mute/end buttons.
3
+ * Contains status text and Bloom's mute/end call controls.
4
4
  */
5
5
 
6
- import { View, Text, Pressable, ActivityIndicator, StyleSheet } from 'react-native';
7
- import Mic from 'lucide-react-native/icons/mic';
8
- import MicOff from 'lucide-react-native/icons/mic-off';
9
- import PhoneOff from 'lucide-react-native/icons/phone-off';
6
+ import { CallControls } from '@oxy.so/bloom/call-ui';
7
+ import { Loading } from '@oxy.so/bloom/loading';
8
+ import { Text } from '@oxy.so/bloom/typography';
9
+ import { View } from 'react-native';
10
10
  import type { RoomState, AgentState } from '../../types';
11
11
 
12
12
  /** Every word the bar shows or announces. An app in another language passes its own. */
@@ -40,8 +40,6 @@ export interface VoiceControlsProps {
40
40
  isMuted: boolean;
41
41
  onToggleMute: () => void;
42
42
  onEnd: () => void;
43
- /** Override theme primary color (resolved hex/hsl value, not CSS var) */
44
- primaryColor?: string;
45
43
  /** Overrides for any of {@link VOICE_CONTROLS_LABELS}. */
46
44
  labels?: Partial<VoiceControlsLabels>;
47
45
  }
@@ -68,84 +66,35 @@ export function VoiceControls({
68
66
  isMuted,
69
67
  onToggleMute,
70
68
  onEnd,
71
- primaryColor,
72
69
  labels: labelOverrides,
73
70
  }: VoiceControlsProps) {
74
71
  const labels = { ...VOICE_CONTROLS_LABELS, ...labelOverrides };
75
72
  const statusText = getStatusText(roomState, agentState, isMuted, labels);
76
73
 
77
74
  return (
78
- <View style={styles.container}>
75
+ <View className="items-center gap-4 pb-6 pt-4">
79
76
  {statusText ? (
80
- <Text className="text-lg font-medium mb-4 text-foreground" accessibilityLiveRegion="polite" aria-live="polite">
77
+ <Text
78
+ className="text-lg font-medium text-text-primary"
79
+ accessibilityLiveRegion="polite"
80
+ aria-live="polite"
81
+ >
81
82
  {statusText}
82
83
  </Text>
83
84
  ) : null}
84
85
 
85
- {roomState === 'connected' && (
86
- <View style={styles.buttonRow}>
87
- <View style={styles.buttonWrapper}>
88
- <Pressable
89
- onPress={onToggleMute}
90
- accessibilityRole="button"
91
- accessibilityLabel={labels.mute}
92
- accessibilityState={{ selected: isMuted }}
93
- aria-pressed={isMuted}
94
- className={isMuted ? undefined : 'bg-muted'}
95
- style={[styles.button, isMuted ? { backgroundColor: '#ef4444' } : undefined]}
96
- >
97
- {isMuted ? (
98
- <MicOff size={24} color="white" />
99
- ) : (
100
- <Mic size={24} color="white" />
101
- )}
102
- </Pressable>
103
- <Text className="text-xs text-muted-foreground">
104
- {isMuted ? labels.unmute : labels.mute}
105
- </Text>
106
- </View>
107
-
108
- <View style={styles.buttonWrapper}>
109
- <Pressable
110
- onPress={onEnd}
111
- accessibilityRole="button"
112
- accessibilityLabel={labels.end}
113
- style={[styles.button, { backgroundColor: '#ef4444' }]}
114
- >
115
- <PhoneOff size={24} color="white" />
116
- </Pressable>
117
- <Text className="text-xs text-muted-foreground">{labels.end}</Text>
118
- </View>
119
- </View>
120
- )}
86
+ {roomState === 'connected' ? (
87
+ <CallControls
88
+ muted={isMuted}
89
+ onMutedChange={onToggleMute}
90
+ onEndCall={onEnd}
91
+ // TODO(bloom): `CallControls` draws its captions for a dark call stage, so on the
92
+ // chat's light surface they vanish. The buttons keep their accessible names.
93
+ labels={{ mute: labels.mute, unmute: labels.unmute, endCall: labels.end }}
94
+ />
95
+ ) : null}
121
96
 
122
- {roomState === 'connecting' && (
123
- <ActivityIndicator size="large" className="text-muted-foreground" />
124
- )}
97
+ {roomState === 'connecting' ? <Loading accessibilityLabel={labels.connecting} /> : null}
125
98
  </View>
126
99
  );
127
100
  }
128
-
129
- const styles = StyleSheet.create({
130
- container: {
131
- alignItems: 'center',
132
- paddingBottom: 24,
133
- paddingTop: 16,
134
- },
135
- buttonRow: {
136
- flexDirection: 'row',
137
- alignItems: 'center',
138
- gap: 32,
139
- },
140
- buttonWrapper: {
141
- alignItems: 'center',
142
- gap: 8,
143
- },
144
- button: {
145
- width: 56,
146
- height: 56,
147
- borderRadius: 28,
148
- alignItems: 'center',
149
- justifyContent: 'center',
150
- },
151
- });
@@ -11,6 +11,7 @@
11
11
 
12
12
  import { useEffect, useRef } from 'react';
13
13
  import { useVoiceRoom } from '../../hooks/useVoiceRoom';
14
+ import { VOICE_ERROR_MESSAGES } from '../../lib/speech-messages';
14
15
  import { useAudioLevelMonitor } from '../../hooks/useAudioLevelMonitor';
15
16
  import { useAudioLevels } from '../../hooks/useAudioLevels';
16
17
  import { VoiceControls } from './VoiceControls';
@@ -57,7 +58,7 @@ export function VoiceSession({ apiUrl, onStateChange, onEnd }: VoiceSessionProps
57
58
  useEffect(() => {
58
59
  if (roomState === 'connected') reachedRoomRef.current = true;
59
60
  if (error || roomState === 'error') {
60
- onEnd();
61
+ onEnd(error ?? VOICE_ERROR_MESSAGES['turn-failed']);
61
62
  return;
62
63
  }
63
64
  if (roomState === 'disconnected' && reachedRoomRef.current) onEnd();