@alia.onl/sdk 15.8.0 → 19.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 (141) hide show
  1. package/CHANGELOG.md +242 -13
  2. package/README.md +223 -151
  3. package/package.json +15 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/hooks/useAliaActivity.ts +2 -2
  43. package/src/hooks/useAtBottom.ts +51 -0
  44. package/src/hooks/useAttachMenu.ts +94 -0
  45. package/src/hooks/useChatComposer.ts +299 -0
  46. package/src/hooks/useChatVoice.ts +129 -0
  47. package/src/hooks/useDocumentPicker.ts +3 -29
  48. package/src/hooks/useFailedTurnToast.ts +43 -0
  49. package/src/hooks/useMessageVote.ts +50 -0
  50. package/src/hooks/useOpenThought.ts +79 -0
  51. package/src/hooks/usePendingQuestions.ts +2 -2
  52. package/src/hooks/useReadAloud.ts +92 -0
  53. package/src/hooks/useShareHandler.ts +24 -0
  54. package/src/hooks/useSoundEffects.ts +1 -1
  55. package/src/hooks/useThoughtScope.ts +59 -0
  56. package/src/hooks/useThreadRows.ts +145 -0
  57. package/src/hooks/useThreadView.ts +68 -0
  58. package/src/hooks/useTimelineWindow.ts +256 -0
  59. package/src/hooks/useVoiceLauncher.ts +180 -0
  60. package/src/hooks/useVoiceRoom.ts +0 -3
  61. package/src/index.ts +43 -29
  62. package/src/lib/activity-store.ts +1 -1
  63. package/src/lib/add-menu.ts +7 -0
  64. package/src/lib/call-thread.ts +33 -0
  65. package/src/lib/citations.ts +79 -2
  66. package/src/lib/clipboard.ts +23 -0
  67. package/src/lib/content-utils.ts +10 -0
  68. package/src/lib/copy-en.ts +284 -0
  69. package/src/lib/copy.ts +32 -0
  70. package/src/lib/document-picker.ts +31 -0
  71. package/src/lib/flights.ts +22 -13
  72. package/src/lib/focus-return.ts +66 -0
  73. package/src/lib/keyboard.native.tsx +19 -0
  74. package/src/lib/keyboard.tsx +9 -0
  75. package/src/lib/labels.tsx +69 -17
  76. package/src/lib/participants.ts +86 -0
  77. package/src/lib/power-level-options.ts +66 -0
  78. package/src/lib/questions.ts +2 -2
  79. package/src/lib/task-labels.ts +4 -4
  80. package/src/lib/tool-call-status.ts +1 -1
  81. package/src/lib/tool-icons.ts +36 -0
  82. package/src/lib/tool-renderers.tsx +48 -0
  83. package/src/lib/turn-state.ts +2 -2
  84. package/src/lib/voice-call-request.ts +49 -0
  85. package/src/lib/voice-call.ts +76 -34
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +32 -14
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +123 -0
  91. package/src/runtime/attachment-utils.ts +96 -0
  92. package/src/runtime/composer-draft-store.ts +270 -0
  93. package/src/runtime/config.tsx +186 -0
  94. package/src/runtime/conversation-store.ts +49 -0
  95. package/src/runtime/conversations.ts +557 -0
  96. package/src/runtime/error-utils.ts +104 -0
  97. package/src/runtime/host-tools.ts +140 -0
  98. package/src/runtime/index.ts +113 -0
  99. package/src/runtime/message-history.ts +124 -0
  100. package/src/runtime/model.ts +39 -0
  101. package/src/runtime/oxy-files.ts +37 -0
  102. package/src/runtime/power-levels.ts +66 -0
  103. package/src/runtime/query-keys.ts +34 -0
  104. package/src/runtime/random-uuid.ts +25 -0
  105. package/src/runtime/routes.ts +51 -0
  106. package/src/runtime/sharing.ts +165 -0
  107. package/src/runtime/sse-frame-reader.ts +88 -0
  108. package/src/runtime/stream-events.ts +146 -0
  109. package/src/runtime/stream-outcome.ts +21 -0
  110. package/src/runtime/thought-store.ts +103 -0
  111. package/src/runtime/thread/agent-activity.ts +261 -0
  112. package/src/runtime/thread/automation-labels.ts +79 -0
  113. package/src/runtime/thread/conversation-export.ts +135 -0
  114. package/src/runtime/thread/message-days.ts +120 -0
  115. package/src/runtime/thread/message-processor.ts +93 -0
  116. package/src/runtime/thread/task-utils.ts +11 -0
  117. package/src/runtime/thread/thought-utils.ts +752 -0
  118. package/src/runtime/thread/thread-history.ts +139 -0
  119. package/src/runtime/thread/timeline.ts +202 -0
  120. package/src/runtime/thread/title-tags.ts +8 -0
  121. package/src/runtime/thread/tool-cards.ts +383 -0
  122. package/src/runtime/turn-failure.ts +108 -0
  123. package/src/runtime/types.ts +148 -0
  124. package/src/runtime/usage-limit-error.ts +44 -0
  125. package/src/runtime/use-agent-activity.ts +80 -0
  126. package/src/runtime/use-conversation-stream.ts +1427 -0
  127. package/src/runtime/use-conversation-thread.ts +556 -0
  128. package/src/runtime/use-start-conversation.ts +107 -0
  129. package/src/runtime/use-thread-history.ts +64 -0
  130. package/src/runtime/use-thread-search.ts +95 -0
  131. package/src/runtime/use-turn-edit.ts +99 -0
  132. package/src/tools.ts +2 -2
  133. package/src/types.ts +18 -47
  134. package/src/components/AliaChatMessageList.tsx +0 -532
  135. package/src/components/ThinkingBlock.tsx +0 -43
  136. package/src/hooks/useAliaChat.ts +0 -573
  137. package/src/lib/catalogue.ts +0 -236
  138. package/src/lib/chat-stream.ts +0 -655
  139. package/src/lib/chat-transport.ts +0 -98
  140. package/src/lib/delegation.ts +0 -34
  141. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,254 @@
1
+ import {
2
+ type FairCoinCardData,
3
+ type MarketCardData,
4
+ type PriceChart,
5
+ type ScheduledTaskCardData,
6
+ type TemperatureUnit,
7
+ type ToolCard,
8
+ type WeatherCardData,
9
+ degrees,
10
+ faircoinChart,
11
+ forecastWeekday,
12
+ formatPrice,
13
+ marketChart,
14
+ triggerLabel,
15
+ } from '../../runtime/model';
16
+ import { useLabels } from '../../lib/labels';
17
+ import { useAliaRuntime } from '../../runtime/config';
18
+ import { AiChatMessageLine } from '@oxy.so/bloom/ai-chat';
19
+ import { Badge } from '@oxy.so/bloom/badge';
20
+ import { Card, CardBody, CardHeader } from '@oxy.so/bloom/card';
21
+ import { LineChartCard } from '@oxy.so/bloom/chart-cards';
22
+ import { IconCircle } from '@oxy.so/bloom/icon-circle';
23
+ import { RiArrowRightSLine } from '@oxy.so/bloom/icons/RiArrowRightSLine';
24
+ import { RiCalendarScheduleLine } from '@oxy.so/bloom/icons/RiCalendarScheduleLine';
25
+ import { RiCloudLine } from '@oxy.so/bloom/icons/RiCloudLine';
26
+ import { RiDropLine } from '@oxy.so/bloom/icons/RiDropLine';
27
+ import { RiSnowflakeLine } from '@oxy.so/bloom/icons/RiSnowflakeLine';
28
+ import { RiSunFoggyLine } from '@oxy.so/bloom/icons/RiSunFoggyLine';
29
+ import { RiSunLine } from '@oxy.so/bloom/icons/RiSunLine';
30
+ import { Item } from '@oxy.so/bloom/item';
31
+ import {
32
+ SegmentedControl,
33
+ SegmentedControlItem,
34
+ SegmentedControlItemText,
35
+ } from '@oxy.so/bloom/segmented-control';
36
+ import { Muted, Text } from '@oxy.so/bloom/typography';
37
+ import { useState } from 'react';
38
+ import { View } from 'react-native';
39
+
40
+ /**
41
+ * A tool result drawn inside the assistant turn, each card one block line of
42
+ * the reply (`AiChatMessageLine block`) so it reveals with the turn.
43
+ *
44
+ * - market, FairCoin → Bloom `LineChartCard`, one range per stored period
45
+ * - weather → `Card` with an `IconCircle` and the forecast as `Item`s
46
+ * - scheduled task → `Card` with an `Item` that opens the automation
47
+ */
48
+ export function ToolResultCard({ card }: { card: ToolCard }) {
49
+ switch (card.type) {
50
+ case 'market':
51
+ return <MarketResult data={card.data} />;
52
+ case 'faircoin':
53
+ return <FairCoinResult data={card.data} />;
54
+ case 'weather':
55
+ return <WeatherResult data={card.data} />;
56
+ case 'scheduled-task':
57
+ return <ScheduledTaskResult data={card.data} />;
58
+ }
59
+ }
60
+
61
+ function PriceLineChart({ chart }: { chart: PriceChart }) {
62
+ return (
63
+ <LineChartCard
64
+ title={chart.title}
65
+ ranges={chart.ranges}
66
+ defaultRange={chart.defaultRange}
67
+ rangesLabel={chart.title}
68
+ format={chart.format}
69
+ formatAxisValue={chart.formatAxisValue}
70
+ accessibilityLabel={chart.title}
71
+ />
72
+ );
73
+ }
74
+
75
+ function Notes({ lines }: { lines: readonly string[] }) {
76
+ return (
77
+ <>
78
+ {lines.map((line) => (
79
+ <AiChatMessageLine key={line} tone="secondary">
80
+ {line}
81
+ </AiChatMessageLine>
82
+ ))}
83
+ </>
84
+ );
85
+ }
86
+
87
+ /** A quote with no line to draw still says its price, in words. */
88
+ function PriceInWords({ title, price, hint }: { title: string; price: string; hint?: string }) {
89
+ return (
90
+ <Card>
91
+ <CardBody>
92
+ <Muted>{title}</Muted>
93
+ <Text variant="title-1-medium">{price}</Text>
94
+ {hint === undefined ? null : <Muted>{hint}</Muted>}
95
+ </CardBody>
96
+ </Card>
97
+ );
98
+ }
99
+
100
+ function MarketResult({ data }: { data: MarketCardData }) {
101
+ const { t, locale } = useLabels();
102
+ const chart = marketChart(data, t, locale);
103
+ return (
104
+ <>
105
+ <AiChatMessageLine block>
106
+ {chart !== null ? (
107
+ <PriceLineChart chart={chart} />
108
+ ) : (
109
+ <PriceInWords
110
+ title={`${data.name} · ${data.symbol}`}
111
+ price={
112
+ typeof data.price === 'number'
113
+ ? formatPrice(data.price, data.currency, locale)
114
+ : t('market.noPrice')
115
+ }
116
+ hint={t('market.noChart')}
117
+ />
118
+ )}
119
+ </AiChatMessageLine>
120
+ {chart === null ? null : <Notes lines={chart.notes} />}
121
+ </>
122
+ );
123
+ }
124
+
125
+ function FairCoinResult({ data }: { data: FairCoinCardData }) {
126
+ const { t, locale } = useLabels();
127
+ const { chart, notes } = faircoinChart(data, t, locale);
128
+ return (
129
+ <>
130
+ <AiChatMessageLine block>
131
+ {chart !== null ? (
132
+ <PriceLineChart chart={chart} />
133
+ ) : (
134
+ <PriceInWords
135
+ title={`${t('faircoin.name')} · ${t('faircoin.symbol')}`}
136
+ price={
137
+ typeof data.price === 'number' && Number.isFinite(data.price)
138
+ ? formatPrice(data.price, 'USD', locale)
139
+ : t('faircoin.noPrice')
140
+ }
141
+ />
142
+ )}
143
+ </AiChatMessageLine>
144
+ <Notes lines={notes} />
145
+ </>
146
+ );
147
+ }
148
+
149
+ const CONDITION_ICON: Record<string, typeof RiSunLine> = {
150
+ clear: RiSunLine,
151
+ 'partly-cloudy': RiSunFoggyLine,
152
+ cloudy: RiCloudLine,
153
+ fog: RiSunFoggyLine,
154
+ drizzle: RiDropLine,
155
+ rain: RiDropLine,
156
+ showers: RiDropLine,
157
+ snow: RiSnowflakeLine,
158
+ 'snow-showers': RiSnowflakeLine,
159
+ thunderstorm: RiCloudLine,
160
+ };
161
+
162
+ const conditionIcon = (condition: string) => CONDITION_ICON[condition] ?? RiCloudLine;
163
+
164
+ function WeatherResult({ data }: { data: WeatherCardData }) {
165
+ const { t, locale } = useLabels();
166
+ /** View state, deliberately not persisted: nobody reopens a chat for its unit. */
167
+ const [unit, setUnit] = useState<TemperatureUnit>('C');
168
+
169
+ return (
170
+ <AiChatMessageLine block>
171
+ <Card>
172
+ <CardHeader>
173
+ <View className="flex-row items-center gap-3">
174
+ <IconCircle icon={conditionIcon(data.current.condition)} size="lg" />
175
+ <View className="min-w-0 flex-1">
176
+ <Muted>{data.place}</Muted>
177
+ <Text variant="title-1-medium">{degrees(data.current.temperature, unit)}</Text>
178
+ <Muted>{t(`weather.condition.${data.current.condition}`)}</Muted>
179
+ </View>
180
+ <SegmentedControl
181
+ label={t('weather.toggleUnit')}
182
+ type="radio"
183
+ value={unit}
184
+ onValueChange={(next: string) => setUnit(next === 'F' ? 'F' : 'C')}
185
+ >
186
+ <SegmentedControlItem value="C">
187
+ <SegmentedControlItemText>°C</SegmentedControlItemText>
188
+ </SegmentedControlItem>
189
+ <SegmentedControlItem value="F">
190
+ <SegmentedControlItemText>°F</SegmentedControlItemText>
191
+ </SegmentedControlItem>
192
+ </SegmentedControl>
193
+ </View>
194
+ </CardHeader>
195
+ {data.daily.length === 0 ? null : (
196
+ <CardBody>
197
+ {data.daily.map((day) => {
198
+ const Icon = conditionIcon(day.condition);
199
+ return (
200
+ <Item
201
+ key={day.date}
202
+ density="compact"
203
+ role="listitem"
204
+ leading={<Icon size="sm" />}
205
+ title={forecastWeekday(day.date, locale)}
206
+ subtitle={t(`weather.condition.${day.condition}`)}
207
+ trailing={
208
+ <Text variant="body-medium">
209
+ {`${degrees(day.high, unit)} / ${degrees(day.low, unit)}`}
210
+ </Text>
211
+ }
212
+ />
213
+ );
214
+ })}
215
+ </CardBody>
216
+ )}
217
+ </Card>
218
+ </AiChatMessageLine>
219
+ );
220
+ }
221
+
222
+ function ScheduledTaskResult({ data }: { data: ScheduledTaskCardData }) {
223
+ const { t } = useLabels();
224
+ const { openAutomation } = useAliaRuntime().host;
225
+ return (
226
+ <AiChatMessageLine block>
227
+ <Card>
228
+ <Item
229
+ leading={<RiCalendarScheduleLine size="md" />}
230
+ title={data.objective}
231
+ subtitle={triggerLabel(data.trigger, t)}
232
+ trailing={
233
+ <View className="flex-row items-center gap-2">
234
+ <Badge
235
+ variant="subtle"
236
+ color={data.enabled ? 'success' : 'default'}
237
+ content={t(data.enabled ? 'chat.toolCard.scheduled' : 'chat.toolCard.paused')}
238
+ />
239
+ {openAutomation === undefined ? null : <RiArrowRightSLine size="sm" />}
240
+ </View>
241
+ }
242
+ // Pressable only where the host has a place to show the task.
243
+ {...(openAutomation === undefined
244
+ ? {}
245
+ : {
246
+ accessibilityRole: 'link' as const,
247
+ accessibilityLabel: t('chat.toolCard.openTask', { objective: data.objective }),
248
+ onPress: () => openAutomation(data.id),
249
+ })}
250
+ />
251
+ </Card>
252
+ </AiChatMessageLine>
253
+ );
254
+ }
@@ -0,0 +1,54 @@
1
+ import type { AiChatTurnAction } from '@oxy.so/bloom/ai-chat';
2
+ import {
3
+ ContextMenu,
4
+ ContextMenuContent,
5
+ ContextMenuItem,
6
+ ContextMenuTrigger,
7
+ } from '@oxy.so/bloom/context-menu';
8
+ import type { ReactNode } from 'react';
9
+ import { Platform } from 'react-native';
10
+
11
+ /**
12
+ * The turn's actions as a long-press menu, on native.
13
+ *
14
+ * What a turn can do besides being read — copy, read aloud, regenerate, edit — is drawn by the
15
+ * row with Bloom's turn actions: the feedback row under a reply, the action row under a question.
16
+ * A phone has no hover, and those rows are small targets under a long reply; a long press anywhere
17
+ * on the turn is where people look for them. Web keeps the rows alone: there a right-click on a
18
+ * reply is the browser's own menu, with the text selection in it, and taking that away to show
19
+ * the same buttons again would be a loss.
20
+ */
21
+ const STRETCH = { alignSelf: 'stretch' } as const;
22
+
23
+ export function TurnMenu({
24
+ actions,
25
+ label,
26
+ children,
27
+ }: {
28
+ actions: ReadonlyArray<AiChatTurnAction>;
29
+ label: string;
30
+ children: ReactNode;
31
+ }) {
32
+ if (Platform.OS === 'web' || actions.length === 0) return <>{children}</>;
33
+ return (
34
+ <ContextMenu>
35
+ {/* Stretched: Bloom's trigger hugs its content by default, and a turn measures the column
36
+ it is in (a question is half of it). */}
37
+ <ContextMenuTrigger label={label} style={STRETCH}>
38
+ {children}
39
+ </ContextMenuTrigger>
40
+ <ContextMenuContent>
41
+ {actions.map(({ key, label: rowLabel, icon: Icon, onPress, disabled }) => (
42
+ <ContextMenuItem
43
+ key={key}
44
+ onPress={onPress}
45
+ disabled={disabled}
46
+ leading={<Icon size="sm" />}
47
+ >
48
+ {rowLabel}
49
+ </ContextMenuItem>
50
+ ))}
51
+ </ContextMenuContent>
52
+ </ContextMenu>
53
+ );
54
+ }
@@ -0,0 +1,34 @@
1
+ import { AiChatMessageLine } from '@oxy.so/bloom/ai-chat';
2
+ import { Text } from 'react-native';
3
+
4
+ /**
5
+ * A turn's status line — "Worked for 5m 32s", "Reasoning" — in the template's
6
+ * secondary tone, and the way into that turn's thought panel.
7
+ *
8
+ * The press target is a nested `Text`, so the line keeps Bloom's paragraph
9
+ * (its reveal, its tone) and only the words become the control. It works for
10
+ * any turn, not only the one in flight: the panel is opened on this message,
11
+ * in the conversation it belongs to.
12
+ */
13
+ export function TurnStatusLine({
14
+ label,
15
+ hint,
16
+ onPress,
17
+ }: {
18
+ label: string;
19
+ /** What pressing it does, for assistive technology. */
20
+ hint?: string;
21
+ onPress?: () => void;
22
+ }) {
23
+ return (
24
+ <AiChatMessageLine tone="secondary" selectable={false}>
25
+ {onPress === undefined ? (
26
+ label
27
+ ) : (
28
+ <Text accessibilityRole="button" accessibilityHint={hint} onPress={onPress}>
29
+ {label}
30
+ </Text>
31
+ )}
32
+ </AiChatMessageLine>
33
+ );
34
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The call of a conversation screen: its bar, while a call is open.
3
+ *
4
+ * The call and its audio engine load on the first call, not with the chat, so a screen that
5
+ * never calls never evaluates `react-native-audio-api`.
6
+ */
7
+
8
+ import { lazy, Suspense } from 'react';
9
+ import type { VoiceSessionProps } from '../../types';
10
+
11
+ const VoiceSession = lazy(() =>
12
+ import('./VoiceSession').then((module) => ({ default: module.VoiceSession })),
13
+ );
14
+
15
+ export function AliaVoiceCall(props: VoiceSessionProps) {
16
+ return (
17
+ <Suspense fallback={null}>
18
+ <VoiceSession {...props} />
19
+ </Suspense>
20
+ );
21
+ }
@@ -8,36 +8,10 @@ import { Loading } from '@oxy.so/bloom/loading';
8
8
  import { Text } from '@oxy.so/bloom/typography';
9
9
  import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
10
10
  import { View } from 'react-native';
11
+ import { VOICE_CONTROLS_LABELS, type VoiceControlsLabels } from '../../lib/voice-controls-labels';
11
12
  import type { RoomState, AgentState, VoiceCallState } from '../../types';
12
13
 
13
- /** Every word the bar shows or announces. An app in another language passes its own. */
14
- export interface VoiceControlsLabels {
15
- connecting: string;
16
- reconnecting: string;
17
- connected: string;
18
- listening: string;
19
- muted: string;
20
- thinking: string;
21
- usingTool: string;
22
- speaking: string;
23
- mute: string;
24
- unmute: string;
25
- end: string;
26
- }
27
-
28
- export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
29
- connecting: 'Connecting...',
30
- reconnecting: 'Reconnecting...',
31
- connected: 'Connected',
32
- listening: 'Listening...',
33
- muted: 'Muted',
34
- thinking: 'Thinking...',
35
- usingTool: 'Working on it...',
36
- speaking: 'Speaking...',
37
- mute: 'Mute',
38
- unmute: 'Unmute',
39
- end: 'End',
40
- };
14
+ export { VOICE_CONTROLS_LABELS, type VoiceControlsLabels };
41
15
 
42
16
  export interface VoiceControlsProps {
43
17
  roomState: RoomState;
@@ -1,19 +1,20 @@
1
1
  /**
2
2
  * A live voice call, mounted only while voice mode is on.
3
3
  *
4
- * The chat shell receives it as the `voiceSession` prop rather than importing
5
- * it, so a text-only consumer never compiles the call, its audio engine or
6
- * the call UI — which is the reason the `@alia.onl/sdk/voice` entry exists.
7
- *
8
4
  * Mounting starts the call; unmounting ends it (`useVoiceRoom` stops listening
9
- * and speaking on unmount), so the shell only has to decide whether to render it.
5
+ * and speaking on unmount), so the screen only has to decide whether to render it.
6
+ *
7
+ * It also says what happened, in the chat's language (`labels`): a failure, a call moved to
8
+ * another device or cut by the server, the time limit near. A hang-up says nothing. Every
9
+ * surface that offers a call gets this one, so they cannot disagree.
10
10
  */
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
+ import { toastNotice } from '../../hooks/useVoiceLauncher';
15
15
  import { useAudioLevelMonitor } from '../../hooks/useAudioLevelMonitor';
16
16
  import { useAudioLevels } from '../../hooks/useAudioLevels';
17
+ import { useLabels } from '../../lib/labels';
17
18
  import { VoiceControls } from './VoiceControls';
18
19
  import type { VoiceSessionProps } from '../../types';
19
20
 
@@ -23,16 +24,21 @@ export function VoiceSession({
23
24
  agentId,
24
25
  locale,
25
26
  voice,
27
+ onMessagesSaved,
28
+ onNotice = toastNotice,
26
29
  onStateChange,
27
30
  onEnd,
28
31
  }: VoiceSessionProps) {
32
+ const labels = useLabels();
29
33
  const {
30
34
  room,
31
35
  callState,
32
36
  roomState,
33
37
  agentState,
34
38
  isMuted,
35
- error,
39
+ errorCode,
40
+ endedReason,
41
+ endingSoonMs,
36
42
  messages,
37
43
  isConnected,
38
44
  connect,
@@ -43,6 +49,7 @@ export function VoiceSession({
43
49
  ...(agentId === undefined ? {} : { agentId }),
44
50
  ...(locale === undefined ? {} : { lang: locale }),
45
51
  ...(voice === undefined ? {} : { voicePreference: voice }),
52
+ ...(onMessagesSaved === undefined ? {} : { onMessagesSaved }),
46
53
  });
47
54
 
48
55
  const { captureLevel, playbackLevel } = useAudioLevelMonitor(room, isConnected);
@@ -66,18 +73,37 @@ export function VoiceSession({
66
73
  onStateChange({ isConnected, agentState, waveAmplitude, messages });
67
74
  }, [isConnected, agentState, waveAmplitude, messages, onStateChange]);
68
75
 
76
+ // The call is about to reach its time limit: said once, and shown in the bar.
77
+ const notice = endingSoonMs === null ? null : labels.voiceEndingSoon;
78
+ const warnedRef = useRef(false);
79
+ useEffect(() => {
80
+ if (notice === null || warnedRef.current) return;
81
+ warnedRef.current = true;
82
+ onNotice({ kind: 'warning', text: notice });
83
+ }, [notice, onNotice]);
84
+
69
85
  // End the call on failure, or once a call we actually reached ends. The
70
86
  // "reached" flag is what keeps the pre-connect `disconnected` state — which
71
87
  // every session starts in — from ending the call before it begins.
72
88
  const reachedRoomRef = useRef(false);
89
+ const finishedRef = useRef(false);
73
90
  useEffect(() => {
91
+ if (finishedRef.current) return;
74
92
  if (roomState !== 'disconnected') reachedRoomRef.current = true;
75
- if (error || roomState === 'error') {
76
- onEnd(error ?? VOICE_ERROR_MESSAGES['realtime-failed']);
93
+ if (errorCode !== null || roomState === 'error') {
94
+ finishedRef.current = true;
95
+ onNotice({ kind: 'error', text: labels.voiceError(errorCode ?? 'realtime-failed') });
96
+ onEnd();
77
97
  return;
78
98
  }
79
- if (roomState === 'disconnected' && reachedRoomRef.current) onEnd();
80
- }, [error, roomState, onEnd]);
99
+ if (roomState === 'disconnected' && reachedRoomRef.current) {
100
+ finishedRef.current = true;
101
+ // Moved to another device, the time limit, the credits: information. A hang-up: nothing.
102
+ const text = endedReason === null ? null : labels.voiceEnded(endedReason);
103
+ if (text !== null) onNotice({ kind: 'info', text });
104
+ onEnd();
105
+ }
106
+ }, [errorCode, endedReason, roomState, onEnd, onNotice, labels]);
81
107
 
82
108
  return (
83
109
  <VoiceControls
@@ -86,7 +112,9 @@ export function VoiceSession({
86
112
  isMuted={isMuted}
87
113
  onToggleMute={toggleMute}
88
114
  onEnd={onEnd}
115
+ notice={notice}
89
116
  callState={callState}
117
+ labels={labels.voiceControls}
90
118
  />
91
119
  );
92
120
  }
@@ -6,7 +6,7 @@ import { acquireActivitySocket } from '../lib/activity-socket';
6
6
  import { ALIA_API_URL } from '../lib/origins';
7
7
 
8
8
  export interface UseAliaActivityOptions {
9
- /** The API the activity socket connects to. Defaults like `useAliaChat`'s `apiUrl`. */
9
+ /** The API the activity socket connects to. Defaults to the runtime's `apiUrl`. */
10
10
  apiUrl?: string;
11
11
  }
12
12
 
@@ -14,7 +14,7 @@ export interface UseAliaActivityOptions {
14
14
  * What Alia is doing right now, for any surface that wants to show it — an "Ask Alia" button's
15
15
  * avatar, a status dot. It is the busiest of:
16
16
  *
17
- * - a turn running in this app (`useAliaChat`, so the sheet drives every button), and
17
+ * - a turn running in this app (`useConversationThread`, so every chat drives every button), and
18
18
  * - her runs for the signed-in person elsewhere, reported by the server over one shared socket
19
19
  * (background work, another device), connected only while someone is signed in AND using this.
20
20
  */
@@ -0,0 +1,51 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
3
+
4
+ /** How near the end still counts as being at the bottom, px. */
5
+ export const AT_BOTTOM_THRESHOLD = 50;
6
+
7
+ /**
8
+ * Whether the reader is at the end of the thread, for the jump-to-present
9
+ * button.
10
+ *
11
+ * ## What this replaced
12
+ *
13
+ * `use-scroll-to-bottom.ts`, 183 lines, which owned four things at once: the
14
+ * sticky follow, the upward pagination trigger, the anchor that held the
15
+ * reader's position while a page landed above them, and this. Bloom's
16
+ * `AiChatThread` owns the first three now — `followThreshold`,
17
+ * `onStartReached` and `maintainStartPosition`, added in 3.3.0 for exactly
18
+ * this adoption — and it implements them the same way, measuring the threshold
19
+ * against the height BEFORE the growth and adding a landed page's delta to the
20
+ * offset.
21
+ *
22
+ * What Bloom does NOT answer is this question, because it is the host's: a
23
+ * thread has no opinion about whether something should float over it offering
24
+ * to jump. So the one piece that did not move is the one that was never
25
+ * Bloom's.
26
+ *
27
+ * ## Why it is a ref plus a coarse boolean
28
+ *
29
+ * `onScroll` fires at 16ms while an answer streams, and this feeds a button
30
+ * that has exactly two appearances. Setting state on every event would
31
+ * re-render the whole screen sixty times a second to change nothing; the ref
32
+ * holds the live number and the state changes only when the ANSWER changes.
33
+ */
34
+ export function useAtBottom(threshold: number = AT_BOTTOM_THRESHOLD) {
35
+ const [isAtBottom, setIsAtBottom] = useState(true);
36
+ const atBottom = useRef(true);
37
+
38
+ const onScroll = useCallback(
39
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
40
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
41
+ const distance = contentSize.height - contentOffset.y - layoutMeasurement.height;
42
+ const next = distance <= threshold;
43
+ if (next === atBottom.current) return;
44
+ atBottom.current = next;
45
+ setIsAtBottom(next);
46
+ },
47
+ [threshold],
48
+ );
49
+
50
+ return { isAtBottom, onScroll };
51
+ }
@@ -0,0 +1,94 @@
1
+ import { RiFileTextLine } from '@oxy.so/bloom/icons/RiFileTextLine';
2
+ import { RiImageLine } from '@oxy.so/bloom/icons/RiImageLine';
3
+ import { useCallback, useMemo } from 'react';
4
+ import type { AliaAddMenu } from '../lib/add-menu';
5
+ import type { IntakeKind } from '../lib/attachment-intake';
6
+ import { useLabels } from '../lib/labels';
7
+ import { ALIA_FEATURES, type FeatureOffer } from '../runtime/agents';
8
+ import { type DraftAddress, useComposerDraftStore } from '../runtime/composer-draft-store';
9
+ import type { AliaAttachment } from '../types';
10
+ import { useImagePicker } from './useImagePicker';
11
+
12
+ /** What the chat offers to attach: each attachment feature's offer for this lead and person. */
13
+ export interface AttachOffers {
14
+ images: FeatureOffer;
15
+ documents: FeatureOffer;
16
+ }
17
+
18
+ const attachmentId = (prefix: string) =>
19
+ `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
20
+
21
+ /**
22
+ * The add menu a host that brings none gets: photos and files, each only where the lead offers it
23
+ * (`featureOffer`). A row the lead's feature gate keeps locked is still shown, and pressing it
24
+ * hands the feature to the host's upsell (`onFeatureLocked`) instead of opening a picker.
25
+ */
26
+ export function useAttachMenu(
27
+ address: DraftAddress,
28
+ offers: AttachOffers,
29
+ onFeatureLocked: ((key: string) => void) | undefined,
30
+ ): AliaAddMenu & { kinds: readonly IntakeKind[] } {
31
+ const labels = useLabels();
32
+ const { pickImage } = useImagePicker();
33
+
34
+ const groups = useMemo(() => {
35
+ const rows = [
36
+ ...(offers.images === 'absent'
37
+ ? []
38
+ : [{ id: 'photos', label: labels.addPhotos, icon: RiImageLine }]),
39
+ ...(offers.documents === 'absent'
40
+ ? []
41
+ : [{ id: 'documents', label: labels.addDocuments, icon: RiFileTextLine }]),
42
+ ];
43
+ return rows.length === 0 ? [] : [{ label: labels.addMenuGroup, rows }];
44
+ }, [offers.images, offers.documents, labels.addMenuGroup, labels.addPhotos, labels.addDocuments]);
45
+
46
+ /** Dropped and pasted files are held to the same offer: only an available kind is taken. */
47
+ const kinds = useMemo<IntakeKind[]>(
48
+ () => [
49
+ ...(offers.images === 'available' ? (['image'] as const) : []),
50
+ ...(offers.documents === 'available' ? (['document'] as const) : []),
51
+ ],
52
+ [offers.images, offers.documents],
53
+ );
54
+
55
+ const onSelect = useCallback(
56
+ async (rowId: string) => {
57
+ const kind = rowId === 'photos' ? 'images' : rowId === 'documents' ? 'documents' : null;
58
+ if (kind === null) return;
59
+ if (offers[kind] === 'locked') {
60
+ onFeatureLocked?.(ALIA_FEATURES[kind]);
61
+ return;
62
+ }
63
+ const store = useComposerDraftStore.getState();
64
+ const picked: AliaAttachment[] =
65
+ kind === 'images'
66
+ ? ((await pickImage()) ?? []).map((asset) => ({
67
+ id: attachmentId('img'),
68
+ type: 'image' as const,
69
+ name: asset.name,
70
+ uri: asset.uri,
71
+ size: asset.size,
72
+ mimeType: asset.mimeType,
73
+ }))
74
+ : // The picker loads with the first document: a chat that never attaches one never does.
75
+ ((await (await import('../lib/document-picker')).pickDocuments()) ?? []).map(
76
+ (asset) => ({
77
+ id: attachmentId('doc'),
78
+ type: 'document' as const,
79
+ name: asset.name,
80
+ uri: asset.uri,
81
+ size: asset.size,
82
+ mimeType: asset.mimeType,
83
+ }),
84
+ );
85
+ for (const attachment of picked) store.addAttachment(address, attachment);
86
+ },
87
+ [offers, onFeatureLocked, pickImage, address],
88
+ );
89
+
90
+ return useMemo(
91
+ () => ({ groups, kinds, onSelect: (rowId: string) => void onSelect(rowId) }),
92
+ [groups, kinds, onSelect],
93
+ );
94
+ }