@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,556 @@
1
+ import { toast } from '@oxy.so/bloom/toast';
2
+ import { useOxy } from '@oxy.so/services/ui/client';
3
+ import { useQueryClient } from '@tanstack/react-query';
4
+ import { useCallback, useEffect, useId, useRef } from 'react';
5
+ import { useAliaActivityStore } from '../lib/activity-store';
6
+ import { turnActivityState } from '../lib/turn-state';
7
+ import { useLabels } from '../lib/labels';
8
+ import { getTextFromContent } from '../lib/content-utils';
9
+ import type { AliaAttachment } from '../types';
10
+ import { buildMessageContent } from './attachment-utils';
11
+ import type { AliaHostTools } from './host-tools';
12
+ import { useDocumentUpload } from './oxy-files';
13
+ import { useAliaRuntime } from './config';
14
+ import { releaseAttachments, useComposerDraftStore } from './composer-draft-store';
15
+ import { useConversationStore } from './conversation-store';
16
+ import {
17
+ ConversationNotFoundError,
18
+ useClearConversation,
19
+ useConversation,
20
+ useCreateConversation,
21
+ useDeleteConversation,
22
+ } from './conversations';
23
+ import { aliaQueryKeys } from './query-keys';
24
+ import { reportDroppedAttachments, useStartConversation } from './use-start-conversation';
25
+ import { useConversationStream } from './use-conversation-stream';
26
+ import type { Message, SendOptions } from './types';
27
+
28
+ export interface UseConversationThreadOptions {
29
+ conversationId?: string;
30
+ /** The power level (or device model) every turn is sent with. */
31
+ model?: string;
32
+ agentId?: string;
33
+ /** What the host app is; see `useConversationStream`. */
34
+ clientContext?: string;
35
+ /** The UI effects the host performs; see `useConversationStream`. */
36
+ hostTools?: AliaHostTools;
37
+ }
38
+
39
+ type MessageContent = Message['content'];
40
+
41
+ /**
42
+ * What an edit sends in place of the original user turn.
43
+ *
44
+ * A STRING is the composer's edit: it replaces the text of the turn and keeps
45
+ * every non-text part (`image_url`, files) the turn already carried, because
46
+ * the composer only ever shows the text for editing and a person rewording a
47
+ * question about a picture has not asked for the picture to go. A full parts
48
+ * ARRAY is sent verbatim — that is how regenerate replays a turn unchanged, and
49
+ * how a caller that has deliberately removed an attachment says so.
50
+ */
51
+ export type EditedContent = string | MessageContent;
52
+
53
+ /**
54
+ * Merge an edit into the original turn's content, per the `EditedContent` rule.
55
+ * The replacement text lands where the original's first text part was, so a
56
+ * turn built as `[text, image]` stays `[text, image]` rather than reordering
57
+ * the picture ahead of the question; an original with no text part (image-only)
58
+ * gets the new text appended after its attachments.
59
+ */
60
+ export function mergeEditedContent(
61
+ original: MessageContent | undefined,
62
+ edit: EditedContent,
63
+ ): MessageContent {
64
+ if (typeof edit !== 'string') return edit;
65
+ if (!Array.isArray(original)) return edit;
66
+ const attachments = original.filter((part) => part.type !== 'text');
67
+ if (attachments.length === 0) return edit;
68
+ if (!edit.trim()) return attachments;
69
+ const textPart = { type: 'text', text: edit };
70
+ const firstTextIndex = original.findIndex((part) => part.type === 'text');
71
+ if (firstTextIndex < 0) return [...attachments, textPart];
72
+ const before = original.slice(0, firstTextIndex).filter((part) => part.type !== 'text');
73
+ const after = original.slice(firstTextIndex + 1).filter((part) => part.type !== 'text');
74
+ return [...before, textPart, ...after];
75
+ }
76
+
77
+ /** True when there is nothing to send: no text and no attachment part. */
78
+ function isEmptyContent(content: MessageContent | undefined): boolean {
79
+ if (content === undefined || content === null) return true;
80
+ if (typeof content === 'string') return !content.trim();
81
+ return content.length === 0;
82
+ }
83
+
84
+ export function useConversationThread({
85
+ conversationId,
86
+ model,
87
+ agentId,
88
+ clientContext,
89
+ hostTools,
90
+ }: UseConversationThreadOptions = {}) {
91
+ const queryClient = useQueryClient();
92
+ const labels = useLabels();
93
+ const { host } = useAliaRuntime();
94
+ const { user } = useOxy();
95
+ const bindDraftAccount = useComposerDraftStore((state) => state.bindAccount);
96
+ // The drafts are this account's: a different person starts with none.
97
+ useEffect(() => bindDraftAccount(user?.id ?? null), [bindDraftAccount, user?.id]);
98
+ const hasSentPendingMessage = useRef(false);
99
+ const lastConversationId = useRef<string | null>(null);
100
+ const wasLoadingRef = useRef(false);
101
+
102
+ const pendingInitialMessage = useConversationStore((state) => state.pendingInitialMessage);
103
+ const {
104
+ data: conversation,
105
+ error: conversationQueryError,
106
+ isLoading: conversationQueryLoading,
107
+ isFetching: conversationFetching,
108
+ } = useConversation(conversationId || '');
109
+ const createConversationMutation = useCreateConversation();
110
+ const { mutateAsync: deleteConversation } = useDeleteConversation();
111
+ const { mutateAsync: clearMessages } = useClearConversation();
112
+
113
+ // A missing row is not a transient loading failure and the URL cannot become
114
+ // useful by staying open. Remove its cached detail/list state and return to a
115
+ // valid composer; the query itself has retries disabled, so this happens once.
116
+ useEffect(() => {
117
+ if (!(conversationQueryError instanceof ConversationNotFoundError)) return;
118
+ queryClient.removeQueries({
119
+ queryKey: aliaQueryKeys.conversations.detail(conversationQueryError.conversationId),
120
+ });
121
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
122
+ host.closeConversation?.();
123
+ }, [conversationQueryError, queryClient, host]);
124
+
125
+ /**
126
+ * Turns go to `/v1/chat/completions`, Alia's product API: the same handler `/alia/chat` mounts,
127
+ * on the one surface that answers a browser from any Oxy app's origin (see `routes.ts`).
128
+ */
129
+ const {
130
+ messages,
131
+ append,
132
+ isLoading,
133
+ error,
134
+ clearError,
135
+ setMessages,
136
+ stop,
137
+ suggestedNewConversation,
138
+ dismissSuggestedNewConversation,
139
+ agentRun,
140
+ failedTurn,
141
+ retryFailedTurn: retry,
142
+ clearFailedTurn,
143
+ turnOptionsOf,
144
+ } = useConversationStream({
145
+ conversationId,
146
+ model,
147
+ agentId,
148
+ clientContext,
149
+ hostTools,
150
+ });
151
+ const upload = useDocumentUpload();
152
+
153
+ // The turn's lifecycle, published for every "Ask Alia" button (`useAliaActivity`).
154
+ const activitySource = useId();
155
+ const activity = turnActivityState(messages, isLoading);
156
+ useEffect(() => {
157
+ const { setLocal } = useAliaActivityStore.getState();
158
+ setLocal(activitySource, activity);
159
+ return () => setLocal(activitySource, 'idle');
160
+ }, [activitySource, activity]);
161
+
162
+ // Expose streaming state globally so sidebar can show a spinner
163
+ const setStreamingConversationId = useConversationStore((s) => s.setStreamingConversationId);
164
+ useEffect(() => {
165
+ setStreamingConversationId(isLoading && conversationId ? conversationId : null);
166
+ return () => {
167
+ if (useConversationStore.getState().streamingConversationId === conversationId) {
168
+ setStreamingConversationId(null);
169
+ }
170
+ };
171
+ }, [isLoading, conversationId, setStreamingConversationId]);
172
+
173
+ // Refresh sidebar when streaming finishes (backend auto-saves with AI-generated title)
174
+ useEffect(() => {
175
+ if (wasLoadingRef.current && !isLoading && conversationId) {
176
+ // Immediate refetch (gets saved conversation data)
177
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
178
+ void queryClient.invalidateQueries({
179
+ queryKey: aliaQueryKeys.conversations.detail(conversationId),
180
+ });
181
+ // Delayed fallback for async title generation. Only the list needs it —
182
+ // re-fetching the whole message detail 5s after every exchange just for
183
+ // a possible title was a second full refetch per message.
184
+ const timer = setTimeout(() => {
185
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.conversations.all });
186
+ }, 5000);
187
+ wasLoadingRef.current = isLoading;
188
+ return () => clearTimeout(timer);
189
+ }
190
+ wasLoadingRef.current = isLoading;
191
+ }, [isLoading, conversationId, queryClient]);
192
+
193
+ // Sync chatId and load messages when conversation changes or when
194
+ // seeded cache data upgrades to full data (messages go from empty to populated).
195
+ useEffect(() => {
196
+ if (!conversationId || conversationQueryLoading) return;
197
+
198
+ const incomingMessages = conversation?.messages || [];
199
+ const isNewConversation = lastConversationId.current !== conversationId;
200
+ const isDataUpgrade =
201
+ !isNewConversation && incomingMessages.length > 0 && messages.length === 0;
202
+
203
+ if (!isNewConversation && !isDataUpgrade) return;
204
+
205
+ if (isNewConversation) {
206
+ lastConversationId.current = conversationId;
207
+ hasSentPendingMessage.current = false;
208
+ }
209
+
210
+ const validMessages = incomingMessages
211
+ .filter((msg) => msg?.role && msg?.content !== undefined)
212
+ .map((msg, index) => ({
213
+ ...msg,
214
+ id: msg.id || `db-${conversationId}-${index}`,
215
+ }));
216
+ setMessages(validMessages);
217
+ }, [conversationId, conversation, conversationQueryLoading, setMessages, messages.length]);
218
+
219
+ // An agent wrote into THIS conversation on its own (a result, a check-in).
220
+ // Appended to what is on screen: the detail refetch alone would not reach an
221
+ // open chat, whose local messages are only replaced when the id changes. The
222
+ // server keeps it even if the next turn is sent before this arrives.
223
+ useEffect(() => {
224
+ if (!conversationId) return;
225
+ return host.subscribeConversationMessages?.(conversationId, (incoming) => {
226
+ if (!incoming?.id) return;
227
+ setMessages((prev) => (prev.some((m) => m.id === incoming.id) ? prev : [...prev, incoming]));
228
+ });
229
+ }, [conversationId, setMessages, host]);
230
+
231
+ // Send pending initial message for new conversations
232
+ useEffect(() => {
233
+ if (!conversationId || !pendingInitialMessage || isLoading) return;
234
+ // Addressed to the conversation it was made for: a second open thread must not take it.
235
+ if (pendingInitialMessage.conversationId !== conversationId) return;
236
+ if (hasSentPendingMessage.current) return;
237
+ if (messages.length > 0) return; // Only send if no messages yet
238
+
239
+ const pending = pendingInitialMessage;
240
+ // Taken now: a failure is handed back to the account that sent it, never
241
+ // to one signed in while the request was out.
242
+ const newChatDraft = useComposerDraftStore.getState().address(null);
243
+ hasSentPendingMessage.current = true;
244
+ host.onTurnStart?.();
245
+ useConversationStore.getState().clearPendingInitialMessage();
246
+
247
+ void append(
248
+ { role: 'user', content: pending.content },
249
+ { mcpServerId: pending.mcpServerId, skillNames: pending.skillNames },
250
+ ).then(async (outcome) => {
251
+ // The request carries the bytes inline now; the temporary URLs behind
252
+ // the tiles have nothing left to show.
253
+ if (outcome !== 'failed') {
254
+ releaseAttachments(pending.attachments);
255
+ return;
256
+ }
257
+
258
+ // The conversation was created by POST /conversations/new before the model
259
+ // was ever reached, and this guard ran on messages.length === 0, so the row
260
+ // is provably empty. Undo it rather than leave a blank chat in the sidebar.
261
+ // A failed delete still leaves the user whole — it only leaves the row.
262
+ await deleteConversation(conversationId).catch(() => {});
263
+ useComposerDraftStore.getState().restore(newChatDraft, {
264
+ text: pending.text,
265
+ attachments: pending.attachments,
266
+ mcpServerId: pending.mcpServerId,
267
+ skillNames: pending.skillNames,
268
+ });
269
+ host.closeConversation?.();
270
+ toast.error(labels.sendFailed);
271
+ });
272
+ }, [
273
+ conversationId,
274
+ pendingInitialMessage,
275
+ isLoading,
276
+ messages.length,
277
+ append,
278
+ deleteConversation,
279
+ host,
280
+ labels,
281
+ ]);
282
+
283
+ // Actions
284
+ const sendMessage = useCallback(
285
+ async (
286
+ content: string,
287
+ attachments?: AliaAttachment[],
288
+ options?: SendOptions,
289
+ ): Promise<boolean> => {
290
+ if ((!content.trim() && !attachments?.length) || isLoading) return false;
291
+ const draft = useComposerDraftStore.getState().address(conversationId ?? null);
292
+
293
+ host.onTurnStart?.();
294
+
295
+ const built = attachments?.length
296
+ ? await buildMessageContent(content, attachments, upload)
297
+ : null;
298
+ const messageContent = built ? built.content : content;
299
+ reportDroppedAttachments(built?.dropped, labels);
300
+
301
+ const outcome = await append({ role: 'user', content: messageContent }, options);
302
+
303
+ // Nothing was persisted server-side (a turn without an assistant response is
304
+ // never saved), so returning the composer to its pre-send state is the whole
305
+ // rollback. Only the usage-limit errors end here now; every other failure
306
+ // is `errored` — the turn stays in the thread with its error and a retry,
307
+ // and the composer stays clear because the text is already on screen.
308
+ if (outcome === 'failed') {
309
+ useComposerDraftStore.getState().restore(draft, {
310
+ text: content,
311
+ attachments: attachments ?? [],
312
+ mcpServerId: options?.mcpServerId ?? null,
313
+ skillNames: options?.skillNames ?? [],
314
+ });
315
+ toast.error(labels.sendFailed);
316
+ return false;
317
+ }
318
+ // A retry re-sends the built content, whose bytes are inline.
319
+ releaseAttachments(attachments ?? []);
320
+ return true;
321
+ },
322
+ [isLoading, append, conversationId, host, labels, upload],
323
+ );
324
+
325
+ const startConversation = useStartConversation({ agentId });
326
+ const createNewConversation = useCallback(
327
+ (text: string, attachments?: AliaAttachment[], options?: SendOptions) =>
328
+ startConversation(text, { attachments, options }),
329
+ [startConversation],
330
+ );
331
+
332
+ /**
333
+ * The conversation a voice call lives in: the open one, or — on a new-chat
334
+ * screen — a new one, created now and shown, so the transcript has a home
335
+ * the moment the call starts. `undefined` when it could not be created.
336
+ */
337
+ const ensureConversation = useCallback(async (): Promise<string | undefined> => {
338
+ if (conversationId) return conversationId;
339
+ try {
340
+ const created = await createConversationMutation.mutateAsync({ agentId });
341
+ host.openConversation?.(created.id);
342
+ return created.id;
343
+ } catch {
344
+ // useCreateConversation.onError already told the person.
345
+ return undefined;
346
+ }
347
+ }, [conversationId, createConversationMutation, agentId, host]);
348
+
349
+ /** Leave this thread for an empty one. Nothing is deleted: the conversation stays in the list. */
350
+ const startOver = useCallback(() => {
351
+ stop();
352
+ setMessages([]);
353
+ clearFailedTurn();
354
+ host.closeConversation?.();
355
+ }, [stop, setMessages, clearFailedTurn, host]);
356
+
357
+ /**
358
+ * Rewrite a user turn: cut the thread back to it and send the new version.
359
+ *
360
+ * `attachments` are the ones added in the composer while editing. They go
361
+ * AFTER whatever the turn already carried — a string edit keeps those, see
362
+ * `EditedContent` — so rewording a question and adding a second picture is
363
+ * one edit, not a lost picture.
364
+ */
365
+ const editMessage = useCallback(
366
+ async (
367
+ messageId: string,
368
+ newContent: EditedContent,
369
+ options?: SendOptions,
370
+ attachments?: AliaAttachment[],
371
+ ): Promise<boolean> => {
372
+ // Truncate to messages before the edited one, then re-send.
373
+ // setMessages eagerly syncs messagesRef so append reads truncated history.
374
+ const beforeEdit = messages;
375
+ const draft = useComposerDraftStore.getState().address(conversationId ?? null);
376
+ const original = messages.find((msg) => msg.id === messageId);
377
+ if (original === undefined) return false;
378
+ // A string edit keeps the original turn's attachments; only a full parts
379
+ // array replaces them. See `EditedContent`.
380
+ let content = mergeEditedContent(original.content, newContent);
381
+ if (attachments?.length && typeof newContent === 'string') {
382
+ const built = await buildMessageContent(newContent, attachments, upload);
383
+ reportDroppedAttachments(built.dropped, labels);
384
+ const added =
385
+ typeof built.content === 'string'
386
+ ? []
387
+ : built.content.filter((part) => part.type !== 'text');
388
+ if (added.length > 0) {
389
+ content = [
390
+ ...(typeof content === 'string' ? [{ type: 'text', text: content }] : content),
391
+ ...added,
392
+ ];
393
+ }
394
+ }
395
+ if (isEmptyContent(content)) return false;
396
+
397
+ setMessages((prev) => {
398
+ const idx = prev.findIndex((msg) => msg.id === messageId);
399
+ return idx < 0 ? prev : prev.slice(0, idx);
400
+ });
401
+
402
+ const outcome = await append({ role: 'user', content }, options);
403
+
404
+ // append rolls back to the truncated list; only this scope still knows what
405
+ // was cut, so it restores the rest and returns the edit to the composer.
406
+ // The original turn — attachments included — comes back with the rest, so
407
+ // nothing the person attached is orphaned by a send that never happened;
408
+ // the composer gets the text, and the attachments added while editing.
409
+ if (outcome === 'failed') {
410
+ setMessages(beforeEdit);
411
+ useComposerDraftStore.getState().restore(draft, {
412
+ text: typeof newContent === 'string' ? newContent : getTextFromContent(content),
413
+ attachments: attachments ?? [],
414
+ mcpServerId: options?.mcpServerId ?? null,
415
+ skillNames: options?.skillNames ?? [],
416
+ });
417
+ toast.error(labels.sendFailed);
418
+ return false;
419
+ }
420
+ releaseAttachments(attachments ?? []);
421
+ return true;
422
+ },
423
+ [setMessages, append, messages, conversationId, labels, upload],
424
+ );
425
+
426
+ /** The user turn a reply answers: the nearest one before it, or `undefined`. */
427
+ const promptOf = useCallback(
428
+ (assistantMessageId: string): Message | undefined => {
429
+ const idx = messages.findIndex((msg) => msg.id === assistantMessageId);
430
+ for (let i = idx - 1; i >= 0; i--) {
431
+ if (messages[i].role === 'user') return messages[i];
432
+ }
433
+ return undefined;
434
+ },
435
+ [messages],
436
+ );
437
+
438
+ /**
439
+ * Ask for this reply again, with the options its prompt was sent with.
440
+ *
441
+ * `fallback` is for a prompt this screen did not send (the conversation was
442
+ * reloaded since): nothing records what it carried, so the caller's current
443
+ * choice — the composer's — is the best there is.
444
+ */
445
+ const regenerateMessage = useCallback(
446
+ async (assistantMessageId: string, fallback?: SendOptions): Promise<boolean> => {
447
+ // Regenerating IS re-sending the prompt that produced this answer. Walk back
448
+ // to the user turn before it and replay that — editMessage already truncates
449
+ // the history and re-appends, so there is no second path to keep in step.
450
+ //
451
+ // The turn goes through WHOLE: a string stays a string and a parts array
452
+ // keeps its `image_url`/file parts. Reducing it to its text first is what
453
+ // used to regenerate "what is in this picture" without the picture, and
454
+ // refuse outright on a prompt that was only a picture.
455
+ const prompt = promptOf(assistantMessageId);
456
+ if (prompt === undefined || isEmptyContent(prompt.content)) return false;
457
+ return editMessage(prompt.id, prompt.content, turnOptionsOf(prompt.id) ?? fallback);
458
+ },
459
+ [promptOf, editMessage, turnOptionsOf],
460
+ );
461
+
462
+ const stopGeneration = useCallback(() => {
463
+ stop();
464
+ }, [stop]);
465
+
466
+ /**
467
+ * Send the failed turn again. Guarded on `isLoading` like `sendMessage`:
468
+ * the card is gone the moment a send starts, but a double tap can land
469
+ * before that render.
470
+ */
471
+ const retryFailedTurn = useCallback(async (): Promise<boolean> => {
472
+ if (isLoading) return false;
473
+ host.onTurnStart?.();
474
+ const outcome = await retry();
475
+ return outcome !== 'failed';
476
+ }, [isLoading, retry, host]);
477
+
478
+ /**
479
+ * Empty the thread — on the server first, then on screen.
480
+ *
481
+ * The order is the fix for #553. `setMessages([])` alone cleared the screen
482
+ * and nothing else, and the sync effect above, seeing a cached history and
483
+ * no messages, read that as a data upgrade and hydrated it straight back
484
+ * under a dialog that had just promised the action could not be undone.
485
+ * `useClearConversation` empties the cache in its `onSuccess`, which has run
486
+ * by the time `mutateAsync` resolves, so the local reset that follows has
487
+ * nothing left to be undone by.
488
+ *
489
+ * A turn still streaming is stopped first rather than refused: the person
490
+ * has just confirmed they want the thread empty, and an answer that kept
491
+ * arriving into it would be the next thing to clear. Stopping settles the
492
+ * assistant placeholder as `cancelled` and lets the server close the turn,
493
+ * so the clear that follows is of a thread nothing is writing to.
494
+ *
495
+ * A refusal keeps everything: the screen is not reset, the cache is not
496
+ * touched, and the error is surfaced — a cleared view whose history returns
497
+ * on the next fetch is exactly the state this exists to end.
498
+ *
499
+ * With no conversation id there is nothing persisted to clear, so the
500
+ * screen's own list is all there is and the local reset is the whole job.
501
+ */
502
+ const clearConversation = useCallback(async (): Promise<boolean> => {
503
+ if (isLoading) stop();
504
+
505
+ if (conversationId) {
506
+ try {
507
+ await clearMessages(conversationId);
508
+ } catch {
509
+ toast.error(labels.conversationClearFailed);
510
+ return false;
511
+ }
512
+ }
513
+
514
+ setMessages([]);
515
+ // The message the error hung under is gone with the rest.
516
+ clearFailedTurn();
517
+ return true;
518
+ }, [isLoading, stop, conversationId, clearMessages, setMessages, clearFailedTurn, labels]);
519
+
520
+ // True while loading conversation messages (initial fetch or seeded→full upgrade)
521
+ const conversationLoading =
522
+ conversationQueryLoading ||
523
+ (conversationFetching && (!conversation?.messages || conversation.messages.length === 0));
524
+
525
+ return {
526
+ // State
527
+ conversationId,
528
+ conversation,
529
+ messages,
530
+ isLoading,
531
+ conversationLoading,
532
+ error,
533
+
534
+ // Actions
535
+ sendMessage,
536
+ createNewConversation,
537
+ ensureConversation,
538
+ startOver,
539
+ editMessage,
540
+ regenerateMessage,
541
+ promptOf,
542
+ turnOptionsOf,
543
+ stopGeneration,
544
+ clearConversation,
545
+ clearError,
546
+ setMessages,
547
+ suggestedNewConversation,
548
+ dismissSuggestedNewConversation,
549
+ agentRun,
550
+ failedTurn,
551
+ retryFailedTurn,
552
+ };
553
+ }
554
+
555
+ /** What `useConversationThread` hands back: the open conversation and everything that can be done to it. */
556
+ export type ConversationThread = ReturnType<typeof useConversationThread>;
@@ -0,0 +1,107 @@
1
+ import { toast } from '@oxy.so/bloom/toast';
2
+ import { useCallback } from 'react';
3
+ import { type AliaChatLabels, useLabels } from '../lib/labels';
4
+ import type { AliaAttachment } from '../types';
5
+ import { buildMessageContent, type DroppedAttachment } from './attachment-utils';
6
+ import { useDocumentUpload } from './oxy-files';
7
+ import { useComposerDraftStore, type DraftTarget } from './composer-draft-store';
8
+ import { useAliaRuntime } from './config';
9
+ import { useConversationStore } from './conversation-store';
10
+ import { useCreateConversation } from './conversations';
11
+ import type { SendOptions } from './types';
12
+
13
+ /**
14
+ * Say which attachments the turn went without.
15
+ *
16
+ * `buildMessageContent` used to drop them in silence: a document was filtered
17
+ * out by `a.type === 'image'`, and an image whose bytes would not read was a
18
+ * bare `continue`. The composer had already shown both as attached, so the
19
+ * turn left with the person believing the file went with it — the visible
20
+ * state and the real capability disagreeing, which is the failure #608 §1.6
21
+ * names.
22
+ *
23
+ * A toast rather than a blocked send: the message itself is fine and worth
24
+ * sending, and refusing the whole turn over a file the format cannot carry
25
+ * would be a worse trade. What matters is that nobody finds out by noticing
26
+ * the answer ignored their PDF.
27
+ */
28
+ export function reportDroppedAttachments(
29
+ dropped: DroppedAttachment[] | undefined,
30
+ labels: Pick<AliaChatLabels, 'attachmentNotSent' | 'attachmentUnreadable'>,
31
+ ): void {
32
+ if (dropped === undefined || dropped.length === 0) return;
33
+
34
+ for (const attachment of dropped) {
35
+ toast.error(
36
+ attachment.reason === 'unsupported'
37
+ ? labels.attachmentNotSent(attachment.name)
38
+ : labels.attachmentUnreadable(attachment.name),
39
+ );
40
+ }
41
+ }
42
+
43
+ export interface StartConversationOptions {
44
+ attachments?: AliaAttachment[];
45
+ options?: SendOptions;
46
+ /**
47
+ * The composer whose draft the message came from, to hand it back if the conversation cannot
48
+ * be made. A new-chat screen is `null` (the default); a screen that asks outside a chat names
49
+ * its own (`surface:automations`).
50
+ */
51
+ draft?: DraftTarget;
52
+ }
53
+
54
+ /**
55
+ * Start a conversation with a first message.
56
+ *
57
+ * The conversation is created, the message is queued for THAT conversation, and the host is told
58
+ * to show it (`openConversation`): the thread that opens sends the message, so the person lands
59
+ * on the conversation being answered. If the conversation cannot be made the composer gets its
60
+ * draft back. This is the one path for a new-chat screen, the sheet's first turn and a screen
61
+ * that starts a chat from elsewhere (a scheduled task described in words).
62
+ */
63
+ export function useStartConversation({ agentId }: { agentId?: string } = {}) {
64
+ const labels = useLabels();
65
+ const { host } = useAliaRuntime();
66
+ const { mutateAsync: createConversation } = useCreateConversation();
67
+ const upload = useDocumentUpload();
68
+
69
+ return useCallback(
70
+ async (
71
+ text: string,
72
+ { attachments = [], options, draft: target = null }: StartConversationOptions = {},
73
+ ): Promise<boolean> => {
74
+ if (!text.trim() && attachments.length === 0) return false;
75
+ const draft = useComposerDraftStore.getState().address(target);
76
+
77
+ // Attachments become multi-part content. The raw text and attachments ride along so a
78
+ // failed send can restore them.
79
+ const built =
80
+ attachments.length > 0 ? await buildMessageContent(text, attachments, upload) : null;
81
+ reportDroppedAttachments(built?.dropped, labels);
82
+ try {
83
+ const conversation = await createConversation({ agentId });
84
+ useConversationStore.getState().setPendingInitialMessage({
85
+ conversationId: conversation.id,
86
+ content: built ? built.content : text,
87
+ text,
88
+ attachments,
89
+ mcpServerId: options?.mcpServerId ?? null,
90
+ skillNames: options?.skillNames ?? [],
91
+ });
92
+ host.openConversation?.(conversation.id);
93
+ return true;
94
+ } catch {
95
+ // useCreateConversation.onError already shows the toast.
96
+ useComposerDraftStore.getState().restore(draft, {
97
+ text,
98
+ attachments,
99
+ mcpServerId: options?.mcpServerId ?? null,
100
+ skillNames: options?.skillNames ?? [],
101
+ });
102
+ return false;
103
+ }
104
+ },
105
+ [labels, host, createConversation, agentId, upload],
106
+ );
107
+ }