@alia.onl/sdk 15.8.0 → 19.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 (142) hide show
  1. package/CHANGELOG.md +262 -13
  2. package/README.md +223 -151
  3. package/package.json +16 -6
  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 +4 -4
  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 +98 -35
  86. package/src/lib/voice-controls-labels.ts +31 -0
  87. package/src/lib/voice-protocol.ts +34 -15
  88. package/src/lib/work-log.ts +6 -6
  89. package/src/runtime/agents.ts +151 -0
  90. package/src/runtime/api.ts +141 -0
  91. package/src/runtime/app-assertion.ts +53 -0
  92. package/src/runtime/attachment-utils.ts +96 -0
  93. package/src/runtime/composer-draft-store.ts +270 -0
  94. package/src/runtime/config.tsx +186 -0
  95. package/src/runtime/conversation-store.ts +49 -0
  96. package/src/runtime/conversations.ts +557 -0
  97. package/src/runtime/error-utils.ts +104 -0
  98. package/src/runtime/host-tools.ts +140 -0
  99. package/src/runtime/index.ts +113 -0
  100. package/src/runtime/message-history.ts +124 -0
  101. package/src/runtime/model.ts +39 -0
  102. package/src/runtime/oxy-files.ts +37 -0
  103. package/src/runtime/power-levels.ts +66 -0
  104. package/src/runtime/query-keys.ts +34 -0
  105. package/src/runtime/random-uuid.ts +25 -0
  106. package/src/runtime/routes.ts +51 -0
  107. package/src/runtime/sharing.ts +165 -0
  108. package/src/runtime/sse-frame-reader.ts +88 -0
  109. package/src/runtime/stream-events.ts +146 -0
  110. package/src/runtime/stream-outcome.ts +21 -0
  111. package/src/runtime/thought-store.ts +103 -0
  112. package/src/runtime/thread/agent-activity.ts +261 -0
  113. package/src/runtime/thread/automation-labels.ts +79 -0
  114. package/src/runtime/thread/conversation-export.ts +135 -0
  115. package/src/runtime/thread/message-days.ts +120 -0
  116. package/src/runtime/thread/message-processor.ts +93 -0
  117. package/src/runtime/thread/task-utils.ts +11 -0
  118. package/src/runtime/thread/thought-utils.ts +752 -0
  119. package/src/runtime/thread/thread-history.ts +139 -0
  120. package/src/runtime/thread/timeline.ts +202 -0
  121. package/src/runtime/thread/title-tags.ts +8 -0
  122. package/src/runtime/thread/tool-cards.ts +383 -0
  123. package/src/runtime/turn-failure.ts +108 -0
  124. package/src/runtime/types.ts +148 -0
  125. package/src/runtime/usage-limit-error.ts +44 -0
  126. package/src/runtime/use-agent-activity.ts +80 -0
  127. package/src/runtime/use-conversation-stream.ts +1433 -0
  128. package/src/runtime/use-conversation-thread.ts +556 -0
  129. package/src/runtime/use-start-conversation.ts +107 -0
  130. package/src/runtime/use-thread-history.ts +64 -0
  131. package/src/runtime/use-thread-search.ts +95 -0
  132. package/src/runtime/use-turn-edit.ts +99 -0
  133. package/src/tools.ts +2 -2
  134. package/src/types.ts +18 -47
  135. package/src/components/AliaChatMessageList.tsx +0 -532
  136. package/src/components/ThinkingBlock.tsx +0 -43
  137. package/src/hooks/useAliaChat.ts +0 -573
  138. package/src/lib/catalogue.ts +0 -236
  139. package/src/lib/chat-stream.ts +0 -655
  140. package/src/lib/chat-transport.ts +0 -98
  141. package/src/lib/delegation.ts +0 -34
  142. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,299 @@
1
+ import type { AvatarFlights } from '@oxy.so/bloom/avatar-flight';
2
+ import { useOxy } from '@oxy.so/services/ui/client';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
+ import type { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
5
+ import type { SharedValue } from 'react-native-reanimated';
6
+ import { leadToProfile } from '../lib/flights';
7
+ import type { AliaLead } from '../lib/participants';
8
+ import { imageUriToDataUrl } from '../lib/image-data-url';
9
+ import {
10
+ useComposerDraft,
11
+ useComposerDraftStore,
12
+ useDraftAddress,
13
+ } from '../runtime/composer-draft-store';
14
+ import type { SendOptions } from '../runtime/types';
15
+ import type { ConversationThread } from '../runtime/use-conversation-thread';
16
+ import { useTurnEdit } from '../runtime/use-turn-edit';
17
+ import type { AliaAttachment, AliaSuggestion } from '../types';
18
+ import { useComposerSuggestions, useSuggestionQuery } from './useComposerSuggestions';
19
+ import { useFocusWhenPresented } from './useFocusWhenPresented';
20
+
21
+ /** A turn of the person's: the text, and the photos (`data:` or `https:` URLs) sent with it. */
22
+ export interface AliaTurn {
23
+ text: string;
24
+ images?: ReadonlyArray<string>;
25
+ }
26
+
27
+ export interface UseChatComposerOptions {
28
+ conversationId: string | undefined;
29
+ thread: ConversationThread;
30
+ /** Nothing is stored: the first turn is sent without creating a conversation (ghost mode). */
31
+ ephemeral: boolean;
32
+ /** The composer is closed (the usage limit): nothing may be sent. */
33
+ closed: boolean;
34
+ /** A write is done in the present, so it ends a jump into the thread's past. */
35
+ onWrite: () => void;
36
+ /**
37
+ * Hands the first turn of an empty chat to the host instead of sending it here (a sheet opens
38
+ * the conversation in a new sheet over this one).
39
+ */
40
+ onStart: ((turn: AliaTurn) => void) | undefined;
41
+ /** The avatar flights: the greeting leaves with the first turn. */
42
+ flights: AvatarFlights;
43
+ /** Whose greeting it is: the lead's avatar is the one that flies. */
44
+ lead: AliaLead;
45
+ /** A turn sent once as the chat mounts: the conversation a sheet was opened for. */
46
+ initialTurn: AliaTurn | undefined;
47
+ /** The signed-out person sent a turn: the host asks them to sign in. */
48
+ onSignInRequired: (() => void) | undefined;
49
+ /** A refusal to say, with its title. */
50
+ reportError: (message: string, title?: string) => void;
51
+ /** The suggestions over the composer on an empty chat, and the host's side of finding them. */
52
+ welcomeSuggestions: AliaSuggestion[] | undefined;
53
+ suggestionMatches: AliaSuggestion[] | undefined;
54
+ onSuggestionQuery: ((query: string) => void) | undefined;
55
+ onSuggestionPicked: ((suggestion: AliaSuggestion) => void) | undefined;
56
+ /** The sheet this chat is in: the composer takes focus once it is up. */
57
+ presentedProgress: SharedValue<number> | undefined;
58
+ }
59
+
60
+ /** A photo handed over as a `data:` URL, as the composer would hold it. */
61
+ function dataUrlAttachment(url: string, index: number): AliaAttachment {
62
+ const mimeType = /^data:([^;,]+)/.exec(url)?.[1] ?? 'image/jpeg';
63
+ return {
64
+ id: `turn-photo-${index}`,
65
+ type: 'image',
66
+ name: `photo-${index + 1}`,
67
+ uri: url,
68
+ size: 0,
69
+ mimeType,
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Everything the person does with the composer: the draft they are writing (kept per conversation,
75
+ * shared with every other surface), sending it, rewriting a question already sent, answering
76
+ * Alia's questions, picking a suggestion, asking for a reply again.
77
+ *
78
+ * Sending is the one path every turn takes. The draft is cleared at once and handed back if the
79
+ * request fails; an edit replaces the question it rewrites; an empty chat may hand its first turn
80
+ * to the host; a signed-out person is sent to sign in with their draft kept.
81
+ */
82
+ export function useChatComposer({
83
+ conversationId,
84
+ thread,
85
+ ephemeral,
86
+ closed,
87
+ onWrite,
88
+ onStart,
89
+ flights,
90
+ lead,
91
+ initialTurn,
92
+ onSignInRequired,
93
+ reportError,
94
+ welcomeSuggestions,
95
+ suggestionMatches,
96
+ onSuggestionQuery,
97
+ onSuggestionPicked,
98
+ presentedProgress,
99
+ }: UseChatComposerOptions) {
100
+ const { isAuthenticated } = useOxy();
101
+ const {
102
+ messages,
103
+ isLoading,
104
+ sendMessage,
105
+ createNewConversation,
106
+ editMessage,
107
+ regenerateMessage,
108
+ turnOptionsOf,
109
+ } = thread;
110
+
111
+ // ── The draft ──
112
+ const target = conversationId ?? null;
113
+ const draft = useComposerDraft(target);
114
+ const address = useDraftAddress(target);
115
+ const photos = draft.attachments;
116
+ /** What a send carries beyond the text: the connector and skills chosen for this turn. */
117
+ const turnOptions = useMemo<SendOptions>(
118
+ () => ({ mcpServerId: draft.mcpServerId, skillNames: draft.skillNames }),
119
+ [draft.mcpServerId, draft.skillNames],
120
+ );
121
+ const setText = useCallback(
122
+ (text: string) => useComposerDraftStore.getState().setText(address, text),
123
+ [address],
124
+ );
125
+ const addAttachment = useCallback(
126
+ (attachment: AliaAttachment) =>
127
+ useComposerDraftStore.getState().addAttachment(address, attachment),
128
+ [address],
129
+ );
130
+ const removeAttachment = useCallback(
131
+ (id: string) => useComposerDraftStore.getState().removeAttachment(address, id),
132
+ [address],
133
+ );
134
+ const edit = useTurnEdit({ target, messages, turnOptionsOf, onEditMessage: editMessage });
135
+ const { editing } = edit;
136
+
137
+ // ── Sending ──
138
+ // A turn goes into the open conversation; with none open, it creates one and is sent from it —
139
+ // unless nothing is to be stored.
140
+ const sendTurn = useCallback(
141
+ (text: string, attachments?: AliaAttachment[], options?: SendOptions) =>
142
+ conversationId || ephemeral
143
+ ? sendMessage(text, attachments, options)
144
+ : createNewConversation(text, attachments, options),
145
+ [conversationId, ephemeral, sendMessage, createNewConversation],
146
+ );
147
+
148
+ // The first turn of an empty chat goes to the host when it asked for it; every other turn is
149
+ // sent here.
150
+ const firstTurn = messages.length === 0;
151
+ const startsElsewhere = onStart !== undefined && firstTurn;
152
+ const submitTurn = useCallback(
153
+ async (text: string, attachments: AliaAttachment[] = [], options?: SendOptions) => {
154
+ onWrite();
155
+ if (startsElsewhere) {
156
+ const images = await Promise.all(
157
+ attachments.map((photo) => imageUriToDataUrl(photo.uri, photo.mimeType)),
158
+ );
159
+ // The host's conversation sheet opens first so that the flight, drawn when it launches, is
160
+ // above it; this chat stays on the greeting, so the avatar can still be measured.
161
+ onStart({ text, ...(images.length > 0 ? { images } : {}) });
162
+ await flights.launch([leadToProfile(lead)]);
163
+ return;
164
+ }
165
+ // The greeting leaves with the first turn: measure it before it does.
166
+ if (firstTurn) await flights.launch([leadToProfile(lead)]);
167
+ await sendTurn(text, attachments, options);
168
+ },
169
+ [startsElsewhere, onStart, firstTurn, flights, lead, sendTurn, onWrite],
170
+ );
171
+
172
+ // The conversation this chat was opened for, sent once.
173
+ const initialSent = useRef(false);
174
+ useEffect(() => {
175
+ if (!initialTurn || initialSent.current) return;
176
+ initialSent.current = true;
177
+ void sendTurn(initialTurn.text, initialTurn.images?.map(dataUrlAttachment));
178
+ }, [initialTurn, sendTurn]);
179
+
180
+ const submit = useCallback(
181
+ async (value: string) => {
182
+ const text = value.trim();
183
+ if ((!text && photos.length === 0) || isLoading || closed) return;
184
+ // An edit rewrites the question's words; an attachment alone does not make a replacement.
185
+ if (editing !== undefined && !text) return;
186
+ // Signed out: the host asks for the sign-in instead of firing a request that would 401. The
187
+ // draft stays in the composer for after.
188
+ if (!isAuthenticated && onSignInRequired) {
189
+ onSignInRequired();
190
+ return;
191
+ }
192
+ const attachments = photos.length > 0 ? photos : undefined;
193
+ // Cleared optimistically: the send path restores text, attachments and the turn's connector
194
+ // and skills to this draft if the request fails.
195
+ useComposerDraftStore.getState().clear(address);
196
+ try {
197
+ if (editing !== undefined) await edit.submit(text, turnOptions, attachments);
198
+ else await submitTurn(text, attachments, turnOptions);
199
+ } catch (err) {
200
+ // Nothing was sent (the photos could not be read): keep the draft.
201
+ useComposerDraftStore.getState().restore(address, { text: value, attachments: photos });
202
+ reportError(err instanceof Error ? err.message : 'Could not attach the photos');
203
+ }
204
+ },
205
+ [
206
+ photos,
207
+ isLoading,
208
+ closed,
209
+ editing,
210
+ isAuthenticated,
211
+ onSignInRequired,
212
+ address,
213
+ edit,
214
+ turnOptions,
215
+ submitTurn,
216
+ reportError,
217
+ ],
218
+ );
219
+
220
+ // A reply again, sent the way its question was (the composer's choice when it is unknown).
221
+ const latest = useRef({ regenerateMessage, turnOptions });
222
+ latest.current = { regenerateMessage, turnOptions };
223
+ const regenerate = useCallback((assistantMessageId: string) => {
224
+ const { regenerateMessage: again, turnOptions: fallback } = latest.current;
225
+ void again(assistantMessageId, fallback);
226
+ }, []);
227
+
228
+ // ── Suggestions over the composer on an empty chat ──
229
+ const query = useSuggestionQuery(draft.text, firstTurn && !closed);
230
+ useEffect(() => {
231
+ onSuggestionQuery?.(query);
232
+ }, [query, onSuggestionQuery]);
233
+ // Read through a ref: it is a prop of the suggestions' rows and the send path changes per token.
234
+ const submitRef = useRef(submit);
235
+ useEffect(() => {
236
+ submitRef.current = submit;
237
+ });
238
+ const pickSuggestion = useCallback(
239
+ (suggestion: AliaSuggestion) => {
240
+ onSuggestionPicked?.(suggestion);
241
+ // A template has blanks to fill, so it goes into the composer for the person to finish
242
+ // instead of being sent with its placeholders.
243
+ if (suggestion.template) setText(suggestion.text);
244
+ else void submitRef.current(suggestion.text);
245
+ },
246
+ [onSuggestionPicked, setText],
247
+ );
248
+ const suggestions = useComposerSuggestions({
249
+ draft: draft.text,
250
+ enabled: firstTurn && !closed,
251
+ welcome: welcomeSuggestions,
252
+ matches: suggestionMatches,
253
+ onPick: pickSuggestion,
254
+ });
255
+
256
+ // ── The field: focus, and its keys ──
257
+ const inputRef = useRef<TextInput | null>(null);
258
+ useFocusWhenPresented(presentedProgress, () => inputRef.current?.focus());
259
+ // Starting an edit puts the caret in the composer that now holds the question: the Edit press
260
+ // left focus on the row's button, so typing went nowhere and Escape could not reach the edit.
261
+ const editingId = editing?.messageId;
262
+ useEffect(() => {
263
+ if (editingId !== undefined) inputRef.current?.focus();
264
+ }, [editingId]);
265
+ const { onKeyPress: onSuggestionKeyPress } = suggestions;
266
+ const cancelEdit = edit.cancel;
267
+ const onKeyPress = useCallback(
268
+ (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
269
+ // Escape leaves an edit, as its Cancel does: it is the top-most thing open while the
270
+ // question is being rewritten.
271
+ if (editing !== undefined && event.nativeEvent.key === 'Escape') {
272
+ event.preventDefault();
273
+ cancelEdit();
274
+ return;
275
+ }
276
+ onSuggestionKeyPress(event);
277
+ },
278
+ [editing, cancelEdit, onSuggestionKeyPress],
279
+ );
280
+
281
+ return {
282
+ text: draft.text,
283
+ setText,
284
+ photos,
285
+ addAttachment,
286
+ removeAttachment,
287
+ address,
288
+ turnOptions,
289
+ edit,
290
+ editing,
291
+ firstTurn,
292
+ sendTurn,
293
+ submit,
294
+ regenerate,
295
+ suggestions: { ...suggestions, onPick: pickSuggestion },
296
+ inputRef,
297
+ onKeyPress,
298
+ };
299
+ }
@@ -0,0 +1,129 @@
1
+ import { useOxy } from '@oxy.so/services/ui/client';
2
+ import { useQueryClient } from '@tanstack/react-query';
3
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
4
+ import type { AliaAvatarState } from '../components/AliaAvatar';
5
+ import { callThread } from '../lib/call-thread';
6
+ import { turnActivityState } from '../lib/turn-state';
7
+ import { useAliaRuntime } from '../runtime/config';
8
+ import { aliaQueryKeys } from '../runtime/query-keys';
9
+ import type { Message } from '../runtime/types';
10
+ import { type SoundSources, useVoiceSoundEffects } from './useSoundEffects';
11
+ import { type UseVoiceLauncherOptions, useVoiceLauncher } from './useVoiceLauncher';
12
+
13
+ /** What a host tells the chat about its voice call (see {@link UseVoiceLauncherOptions}). */
14
+ export type AliaVoiceOptions = Omit<
15
+ UseVoiceLauncherOptions,
16
+ 'apiUrl' | 'conversationId' | 'agentId' | 'accountId' | 'resolveConversation' | 'isFocused'
17
+ > & {
18
+ /** The cues played as the call thinks, uses tools, connects and ends. Omitted, the call is silent. */
19
+ sounds?: SoundSources;
20
+ };
21
+
22
+ export interface UseChatVoiceOptions {
23
+ /** The host's say: on by default, `false` for a text-only chat, an object for its callbacks. */
24
+ options: false | AliaVoiceOptions | undefined;
25
+ conversationId: string | undefined;
26
+ agentId: string | undefined;
27
+ /** Whether the screen holding the chat is the one on show. A live call ends when it is not. */
28
+ isFocused: boolean;
29
+ /** Makes the conversation a call lives in when none is open (after the gate). */
30
+ ensureConversation: () => Promise<string | undefined>;
31
+ messages: Message[];
32
+ setMessages: (messages: Message[]) => void;
33
+ /** The conversation's stored messages: a call message shows as stored once it is. */
34
+ storedMessages: readonly Message[] | undefined;
35
+ /** A text turn is in flight. */
36
+ isLoading: boolean;
37
+ }
38
+
39
+ /**
40
+ * The chat's voice call.
41
+ *
42
+ * The launcher owns whether a call is open, the gate before one opens and what it reports back
43
+ * (for the ambient wave, the cues and the transcript); the call itself loads on the first one.
44
+ * The transcript is merged into the thread: the text messages the chat held when the call
45
+ * started, then the call's, so the conversation reads as one. And what Alia is doing — the
46
+ * header's mark — follows the call while one is open and the text turn otherwise.
47
+ */
48
+ export function useChatVoice({
49
+ options,
50
+ conversationId,
51
+ agentId,
52
+ isFocused,
53
+ ensureConversation,
54
+ messages,
55
+ setMessages,
56
+ storedMessages,
57
+ isLoading,
58
+ }: UseChatVoiceOptions) {
59
+ const runtime = useAliaRuntime();
60
+ const { user } = useOxy();
61
+ const queryClient = useQueryClient();
62
+ const enabled = options !== false;
63
+ const { sounds, ...launcherOptions }: AliaVoiceOptions =
64
+ options === false || options === undefined ? {} : options;
65
+ const { onCallEnded } = launcherOptions;
66
+
67
+ const refreshConversation = useCallback(
68
+ (id: string) => {
69
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.detail(id) });
70
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
71
+ },
72
+ [queryClient],
73
+ );
74
+
75
+ const launcher = useVoiceLauncher({
76
+ apiUrl: runtime.apiUrl,
77
+ conversationId,
78
+ agentId,
79
+ accountId: user?.id ?? null,
80
+ locale: runtime.speech.language,
81
+ voice: runtime.speech.voice,
82
+ resolveConversation: ensureConversation,
83
+ onMessagesSaved: refreshConversation,
84
+ isFocused,
85
+ ...launcherOptions,
86
+ // The call's transcript is in the thread, whatever else the host refreshes.
87
+ onCallEnded: () => {
88
+ if (conversationId !== undefined) refreshConversation(conversationId);
89
+ onCallEnded?.();
90
+ },
91
+ });
92
+ const { isActive, state, start } = launcher;
93
+ const agentState = state?.agentState;
94
+
95
+ useVoiceSoundEffects({
96
+ isVoiceActive: isActive,
97
+ agentState: agentState ?? 'idle',
98
+ isConnected: state?.isConnected ?? false,
99
+ sounds: sounds ?? {},
100
+ });
101
+
102
+ // The transcript, after the text messages the chat held when the call started.
103
+ const textBeforeCall = useRef<Message[]>([]);
104
+ const voiceMessages = state?.messages;
105
+ useEffect(() => {
106
+ if (!voiceMessages) return;
107
+ setMessages(callThread(textBeforeCall.current, voiceMessages, storedMessages));
108
+ }, [voiceMessages, storedMessages, setMessages]);
109
+
110
+ const latestMessages = useRef(messages);
111
+ latestMessages.current = messages;
112
+ const activate = useCallback(() => {
113
+ if (isActive) return;
114
+ textBeforeCall.current = [...latestMessages.current];
115
+ void start();
116
+ }, [isActive, start]);
117
+
118
+ // What Alia is doing, which the header's mark shows.
119
+ const markState = useMemo<AliaAvatarState>(() => {
120
+ if (isActive) {
121
+ if (agentState === 'speaking') return 'writing';
122
+ if (agentState === 'thinking') return 'thinking';
123
+ return 'idle';
124
+ }
125
+ return turnActivityState(messages, isLoading);
126
+ }, [messages, isLoading, isActive, agentState]);
127
+
128
+ return { enabled, launcher, isActive, state, agentState, activate, markState };
129
+ }
@@ -1,37 +1,11 @@
1
- import * as DocumentPicker from 'expo-document-picker';
1
+ import { type DocumentPickerResult, pickDocuments } from '../lib/document-picker';
2
2
 
3
- export type DocumentPickerResult = {
4
- uri: string;
5
- name: string;
6
- size: number;
7
- mimeType: string;
8
- };
3
+ export type { DocumentPickerResult };
9
4
 
10
5
  type UseDocumentPickerResult = {
11
6
  pickDocument: () => Promise<DocumentPickerResult[] | undefined>;
12
7
  };
13
8
 
14
9
  export function useDocumentPicker(): UseDocumentPickerResult {
15
- const pickDocument = async (): Promise<DocumentPickerResult[] | undefined> => {
16
- try {
17
- const result = await DocumentPicker.getDocumentAsync({
18
- type: '*/*',
19
- multiple: true,
20
- copyToCacheDirectory: true,
21
- });
22
-
23
- if (result.canceled === false && result.assets && result.assets.length > 0) {
24
- return result.assets.map((asset) => ({
25
- uri: asset.uri,
26
- name: asset.name,
27
- size: asset.size || 0,
28
- mimeType: asset.mimeType || 'application/octet-stream',
29
- }));
30
- }
31
- } catch (error) {
32
- console.warn('Failed to pick document:', error);
33
- }
34
- };
35
-
36
- return { pickDocument };
10
+ return { pickDocument: pickDocuments };
37
11
  }
@@ -0,0 +1,43 @@
1
+ import { useLabels } from '../lib/labels';
2
+ import type { FailedTurn } from '../runtime/model';
3
+ import { toast } from '@oxy.so/bloom/toast';
4
+ import { useEffect, useRef } from 'react';
5
+
6
+ /** One toast for the failed turn, replaced by the next failure and dismissed with the turn. */
7
+ export const FAILED_TURN_TOAST_ID = 'failed-turn';
8
+ /** Long enough to read the line and press Retry; a toast that is gone before that is no error. */
9
+ export const FAILED_TURN_TOAST_MS = 10_000;
10
+
11
+ /**
12
+ * A failed turn is told with a toast, with Retry as its action.
13
+ *
14
+ * The person's message is still in the thread, and the toast says why it has no answer in the
15
+ * app's own words, in the reader's language (the server's stand-in text, "all models are busy…",
16
+ * is never shown as Alia's own). Real output before the failure reads "interrupted", not "couldn't
17
+ * answer". The server's words, if any, go under the line. It leaves with the turn: when the failure
18
+ * is cleared (a retry, a new message, another chat), so does the toast.
19
+ */
20
+ export function useFailedTurnToast(
21
+ failedTurn: FailedTurn | null | undefined,
22
+ onRetry?: () => void,
23
+ ) {
24
+ const { t } = useLabels();
25
+ // The latest retry, without making a new toast of every render.
26
+ const retry = useRef(onRetry);
27
+ retry.current = onRetry;
28
+
29
+ useEffect(() => {
30
+ if (!failedTurn) return;
31
+ const line = failedTurn.partial ? t('chat.turnInterrupted') : t('chat.turnFailed');
32
+ const canRetry = failedTurn.retryable && retry.current !== undefined;
33
+ toast.error(canRetry ? `${line} ${t('chat.turnFailedRetryHint')}` : line, {
34
+ id: FAILED_TURN_TOAST_ID,
35
+ description: failedTurn.detail || undefined,
36
+ duration: FAILED_TURN_TOAST_MS,
37
+ ...(canRetry ? { action: { label: t('chat.retry'), onClick: () => retry.current?.() } } : {}),
38
+ });
39
+ return () => {
40
+ toast.dismiss(FAILED_TURN_TOAST_ID);
41
+ };
42
+ }, [failedTurn, t]);
43
+ }
@@ -0,0 +1,50 @@
1
+ import { toast } from '@oxy.so/bloom/toast';
2
+ import { useCallback, useRef } from 'react';
3
+ import { useLabels } from '../lib/labels';
4
+ import { useAliaApi } from '../runtime/api';
5
+ import { ALIA_API_ROUTES } from '../runtime/model';
6
+
7
+ export type Vote = 'up' | 'down';
8
+
9
+ /**
10
+ * Like or dislike a reply; pressing the same vote again retracts it.
11
+ *
12
+ * `conversationId` is the conversation that holds the MESSAGE, which is not always the one on
13
+ * screen — a thread shows several, and an old message's vote sent to the stretch being streamed
14
+ * into would fail quietly. It is `undefined` for a message no conversation holds yet
15
+ * (`Message.unsaved`): a turn that failed or was stopped. There is nothing to record the vote on,
16
+ * and the button cannot be hidden on its own, so the press says so rather than thanking the person
17
+ * for nothing.
18
+ */
19
+ export function useMessageVote() {
20
+ const { t } = useLabels();
21
+ const api = useAliaApi();
22
+ /** This screen's votes, read to decide whether a press casts or retracts one. */
23
+ const votes = useRef<Record<string, Vote>>({});
24
+ const inFlight = useRef<Set<string>>(new Set());
25
+
26
+ return useCallback(
27
+ (messageId: string, vote: Vote, conversationId: string | undefined) => {
28
+ if (conversationId === undefined) {
29
+ toast.error(t('chat.feedbackFailed'));
30
+ return;
31
+ }
32
+ if (inFlight.current.has(messageId)) return;
33
+ const next = votes.current[messageId] === vote ? null : vote;
34
+ if (next) votes.current[messageId] = next;
35
+ else delete votes.current[messageId];
36
+ inFlight.current.add(messageId);
37
+ api
38
+ .patch(ALIA_API_ROUTES.conversations.vote(conversationId, messageId), { vote: next })
39
+ .then(() => toast.success(t('chat.thanksFeedback')))
40
+ .catch(() => {
41
+ // Back to what the server holds, so the next press casts it again.
42
+ if (next) delete votes.current[messageId];
43
+ else votes.current[messageId] = vote;
44
+ toast.error(t('chat.feedbackFailed'));
45
+ })
46
+ .finally(() => inFlight.current.delete(messageId));
47
+ },
48
+ [api, t],
49
+ );
50
+ }
@@ -0,0 +1,79 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import { rememberOpener } from '../lib/focus-return';
3
+ import { useAliaRuntime } from '../runtime/config';
4
+ import { type ThoughtScope, type ThoughtTab, useThoughtStore } from '../runtime/thought-store';
5
+ import type { ThreadMessage } from '../runtime/thread/thread-history';
6
+
7
+ /** Open the thought panel on a turn; `opener` is the control pressed, to give focus back to. */
8
+ export type OpenThought = (messageId: string, tab?: ThoughtTab, opener?: unknown) => void;
9
+
10
+ /**
11
+ * The scope a press on a row hands the thought panel: the conversation the row belongs to, which
12
+ * is not always the one being streamed into.
13
+ *
14
+ * A live row belongs to the conversation on screen. A history row belongs to an earlier stretch
15
+ * of the thread — persisted and complete — so its scope is that stretch's messages and nothing
16
+ * about the live turn. Keyed by conversation and message: the panel never shows one
17
+ * conversation's turn under another's.
18
+ */
19
+ export function thoughtScopeFor(
20
+ messageId: string,
21
+ live: ThoughtScope,
22
+ history: readonly ThreadMessage[],
23
+ ): ThoughtScope {
24
+ if (live.messages.some((m) => m.id === messageId)) return live;
25
+ const past = history.find((m) => m.id === messageId);
26
+ if (past === undefined) return live;
27
+ return {
28
+ conversationId: past.conversationId,
29
+ messages: history.filter((m) => m.conversationId === past.conversationId),
30
+ status: 'ready',
31
+ isLoading: false,
32
+ failedTurn: null,
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Open the thought panel on any turn — the one in flight or one long past — or `undefined` when
38
+ * the host has no panel to show it in (`AliaRuntimeHost.showThought`): the row then draws its
39
+ * "worked for" line as plain text.
40
+ *
41
+ * `runningTurnId` is the turn in flight once it has begun using tools. The first tool of a turn
42
+ * opens the panel on it unless the host declines (something else is open), once per turn: a
43
+ * reader who closes it keeps it closed.
44
+ *
45
+ * The returned function is stable: it is a prop of every memoised row, while the live scope
46
+ * changes per streamed token, so the scopes are read at press time through a ref.
47
+ */
48
+ export function useOpenThought(
49
+ live: ThoughtScope,
50
+ history: readonly ThreadMessage[],
51
+ runningTurnId: string | null,
52
+ ): OpenThought | undefined {
53
+ const { showThought } = useAliaRuntime().host;
54
+ const select = useThoughtStore((s) => s.open);
55
+ const latest = useRef({ live, history, showThought });
56
+ useEffect(() => {
57
+ latest.current = { live, history, showThought };
58
+ });
59
+
60
+ const open = useCallback<OpenThought>(
61
+ (messageId, tab, opener) => {
62
+ const { live: scope, history: past, showThought: show } = latest.current;
63
+ rememberOpener(opener);
64
+ select(messageId, thoughtScopeFor(messageId, scope, past), tab);
65
+ show?.('pressed');
66
+ },
67
+ [select],
68
+ );
69
+
70
+ const autoOpened = useRef<string | null>(null);
71
+ useEffect(() => {
72
+ if (runningTurnId === null || autoOpened.current === runningTurnId) return;
73
+ autoOpened.current = runningTurnId;
74
+ const { live: scope, showThought: show } = latest.current;
75
+ if (show?.('automatic') === true) select(runningTurnId, scope);
76
+ }, [runningTurnId, select]);
77
+
78
+ return showThought === undefined ? undefined : open;
79
+ }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo, useState } from 'react';
2
2
  import { type AskedQuestion, pendingQuestions } from '../lib/questions';
3
- import type { ChatMessage } from '../types';
3
+ import type { Message } from '../runtime/types';
4
4
 
5
5
  /**
6
6
  * What Alia asked in her last turn and the person has not answered: asked above the composer,
@@ -8,7 +8,7 @@ import type { ChatMessage } from '../types';
8
8
  * puts the card away until she asks again.
9
9
  */
10
10
  export function usePendingQuestions(
11
- messages: readonly Pick<ChatMessage, 'id' | 'role' | 'toolInvocations'>[],
11
+ messages: readonly Pick<Message, 'id' | 'role' | 'toolInvocations'>[],
12
12
  isStreaming: boolean,
13
13
  ): { questions: AskedQuestion[]; dismiss: () => void } {
14
14
  const asked = useMemo(() => pendingQuestions(messages, isStreaming), [messages, isStreaming]);