@alia.onl/sdk 15.7.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 (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -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/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -1,135 +1,235 @@
1
1
  /**
2
- * Shared chat content component used by both AliaChatSheet and AliaChatScreen.
2
+ * The conversation: header, thread and composer, with its voice call. The one screen of the
3
+ * bottom sheet, `AliaChatScreen` and the Alia app — they are frames around this.
3
4
  *
4
- * Owns the text chat; voice is an injected capability (`voiceSession`) so that
5
- * nothing here reaches the call or its audio engine at module-evaluation time. Containers
6
- * only handle layout chrome (sheet modal, full-screen SafeAreaView). The
7
- * breadcrumb header is Bloom's `AiChatContainer`, the one the app's conversation uses.
5
+ * It is the top of three layers, each depending only on the one before it: the runtime
6
+ * (`useConversationThread`, the stream, the cache), the hooks that turn it into what a person does
7
+ * (`useChatComposer`, `useChatVoice`, `useThreadView`), and this, which draws it — the thread
8
+ * (`AliaThread`) and the agent's history behind it, the composer with its levels, suggestions and
9
+ * questions, and the voice call: the button that opens it, the bar that replaces the composer, the
10
+ * notices and the transcript in the thread. The call loads on the first call, so nothing here
11
+ * reaches its audio engine at module-evaluation time.
12
+ *
13
+ * What a host brings is data and places: the conversation to show, the chrome around it
14
+ * (`chrome`), its rows in the add menu, its words (`labels`), where the thought panel and a
15
+ * scheduled task open (`runtime`), and a few gates (`voice`, `onSignInRequired`,
16
+ * `renderUsageLimit`).
8
17
  */
9
18
 
10
- import type React from 'react';
11
- import { Suspense, useState, useCallback, useEffect, useMemo, useRef } from 'react';
12
- import { View, type TextInput } from 'react-native';
13
- import type { SharedValue } from 'react-native-reanimated';
14
- import { turnActivityState } from '../lib/turn-state';
15
- import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
16
- import { useTTS } from '../hooks/useTTS';
17
- import { useFocusWhenPresented } from '../hooks/useFocusWhenPresented';
18
- import { usePendingQuestions } from '../hooks/usePendingQuestions';
19
- import { useImagePicker } from '../hooks/useImagePicker';
20
- import { imageUriToDataUrl } from '../lib/image-data-url';
21
- import { useAmbientWave } from '../hooks/useAmbientWave';
22
- import { AmbientField } from './AmbientField';
23
19
  import { getAccountDisplayName } from '@oxy.so/core';
24
20
  import { useOxy } from '@oxy.so/services/ui/client';
25
- import { AliaChatMessageList } from './AliaChatMessageList';
26
- import { AliaFlightsProvider, ALIA_TO_PROFILE } from '../lib/flights';
27
- import { AliaWelcomeMessage } from './AliaWelcomeMessage';
28
- import { AliaComposer } from './AliaComposer';
29
- import { useComposerSuggestions, useSuggestionQuery } from '../hooks/useComposerSuggestions';
30
- import { AiChatContainer } from '@oxy.so/bloom/ai-chat';
21
+ import { AiChatContainer, type AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
31
22
  import {
32
23
  AvatarFlightLayer,
33
- useAvatarFlights,
34
24
  type AvatarFlights,
25
+ useAvatarFlights,
35
26
  } from '@oxy.so/bloom/avatar-flight';
36
- import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
37
- import { RiImageLine } from '@oxy.so/bloom/icons';
38
- import { toast } from '@oxy.so/bloom/toast';
39
27
  import { Button } from '@oxy.so/bloom/button';
28
+ import { ScrollToBottomButton } from '@oxy.so/bloom/chat-screen';
29
+ import { RiEditLine } from '@oxy.so/bloom/icons/RiEditLine';
30
+ import { toast } from '@oxy.so/bloom/toast';
31
+ import { Text } from '@oxy.so/bloom/typography';
32
+ import { useQueryClient } from '@tanstack/react-query';
33
+ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
34
+ import { View } from 'react-native';
35
+ import type { SharedValue } from 'react-native-reanimated';
36
+ import { useAmbientWave } from '../hooks/useAmbientWave';
37
+ import { useAtBottom } from '../hooks/useAtBottom';
38
+ import { type AliaTurn, useChatComposer } from '../hooks/useChatComposer';
39
+ import { type AliaVoiceOptions, useChatVoice } from '../hooks/useChatVoice';
40
+ import { useAttachMenu } from '../hooks/useAttachMenu';
41
+ import { usePendingQuestions } from '../hooks/usePendingQuestions';
42
+ import { useThreadView } from '../hooks/useThreadView';
43
+ import { useTTS } from '../hooks/useTTS';
44
+ import type { AliaAddMenu } from '../lib/add-menu';
45
+ import { AliaFlightsProvider } from '../lib/flights';
46
+ import { type AliaChatParticipants, leadOf } from '../lib/participants';
47
+ import { type AliaToolRenderers, AliaToolRenderersProvider } from '../lib/tool-renderers';
48
+ import { KeyboardSafeAreaFloor } from '../lib/keyboard';
40
49
  import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
50
+ import { type DeviceModel, buildPowerLevelOptions } from '../lib/power-level-options';
51
+ import { ALIA_FEATURES, featureOffer, useAgentFeatures, useAgentProfile } from '../runtime/agents';
52
+ import { type AliaRuntimeHost, AliaRuntimeProvider, useAliaRuntime } from '../runtime/config';
53
+ import type { AliaHostTools } from '../runtime/host-tools';
54
+ import { useCreateConversation } from '../runtime/conversations';
55
+ import { aliaQueryKeys } from '../runtime/query-keys';
56
+ import type { Message } from '../runtime/types';
57
+ import { useAgentActivity } from '../runtime/use-agent-activity';
58
+ import { useConversationThread } from '../runtime/use-conversation-thread';
59
+ import { UsageLimitError } from '../runtime/usage-limit-error';
60
+ import type { AliaSuggestion } from '../types';
41
61
  import type { AliaAvatarState } from './AliaAvatar';
62
+ import { AliaComposer } from './AliaComposer';
63
+ import { useParticipants } from './AliaParticipants';
64
+ import { AliaWelcomeMessage } from './AliaWelcomeMessage';
65
+ import { AmbientField } from './AmbientField';
66
+ import { AliaThread } from './thread/AliaThread';
67
+ import { ThreadSearch } from './thread/ThreadSearch';
42
68
  import { VoiceModeIcon } from './VoiceModeIcon';
43
- import type {
44
- AliaAttachment,
45
- AliaSuggestion,
46
- ChatMessage,
47
- VoiceMessage,
48
- VoiceSessionComponent,
49
- VoiceSessionState,
50
- } from '../types';
69
+ import { AliaVoiceCall } from './voice/AliaVoiceCall';
51
70
 
52
- /** The distinct delegated agents among `messages`, in the order they first answered. */
53
- function participatingAgents(messages: readonly ChatMessage[]): AliaChatAgent[] {
54
- const seen = new Map<string, AliaChatAgent>();
55
- for (const { agentInfo } of messages) {
56
- if (agentInfo && !seen.has(agentInfo.id)) seen.set(agentInfo.id, agentInfo);
57
- }
58
- return [...seen.values()];
59
- }
60
-
61
- /** An agent that answered in the conversation, as the header shows it beside Alia. */
62
- export interface AliaChatAgent {
71
+ /** The agent a chat is opened on: its lead instead of Alia. */
72
+ export interface AliaChatAgentRef {
63
73
  id: string;
64
- name: string;
65
- /** Its look, as the server stores it (JSON); absent, the default look. */
66
- character?: unknown;
67
74
  }
68
75
 
69
- /** What the chat tells its header and actions: whether there is a conversation to clear, and who took part. */
76
+ /** What the chat tells its chrome: whether there is a conversation to act on, who takes part, what the lead is doing. */
70
77
  export interface AliaChatState {
78
+ /** The conversation open, or `undefined` before the first turn creates one. */
79
+ conversationId: string | undefined;
71
80
  hasMessages: boolean;
72
- clear: () => void;
73
- /** What Alia is doing; the header's avatar shows it. */
81
+ /** Begin a new conversation. Nothing is deleted: the old one stays in the list. */
82
+ startOver: () => void;
83
+ /** Empty the open conversation, on the server and on screen. */
84
+ clearMessages: () => Promise<unknown>;
85
+ /** What the lead is doing; the header's avatar shows it. */
74
86
  markState: AliaAvatarState;
75
- /** The delegated agents that have answered, in the order they first did. */
76
- agents: AliaChatAgent[];
87
+ /**
88
+ * Who is in the conversation — the lead, and the agents it consulted once they have joined.
89
+ * Draw it with `AliaParticipants` wherever the chrome puts the conversation's title.
90
+ */
91
+ participants: AliaChatParticipants;
92
+ /** Search what was said in the agent's thread. Present only on a thread (`threadHandle`). */
93
+ openSearch: (() => void) | undefined;
94
+ /** The conversation as it stands now, for an export. Read when called, not when rendered. */
95
+ getMessages: () => readonly Message[];
77
96
  }
78
97
 
79
- /** A turn of the person's: the text, and the photos (`data:` or `https:` URLs) sent with it. */
80
- export interface AliaTurn {
81
- text: string;
82
- images?: ReadonlyArray<string>;
98
+ /** What the container draws around the thread. */
99
+ export interface AliaChatChrome {
100
+ /** The top bar. */
101
+ header?: ReactNode;
102
+ /** The crumb of the container's breadcrumb row. */
103
+ title?: string;
104
+ /** The project the chat is filed under, in the breadcrumb. */
105
+ project?: string;
106
+ /** The chat's own actions, at the end of the breadcrumb row. */
107
+ actions?: ReactNode;
83
108
  }
84
109
 
85
110
  export interface AliaChatContentProps {
111
+ // ── Which conversation ──
112
+ /**
113
+ * The conversation to show. Given, the host owns which one is open (a route) and learns of a new
114
+ * one through `onConversationChange`; omitted, the chat keeps its own — unless the host
115
+ * navigates (`runtime.openConversation`), which then decides: the chat starts empty and
116
+ * creates its conversation with the first turn, or the first call.
117
+ */
118
+ conversationId?: string;
119
+ /**
120
+ * The agent this chat is with, any agent the person can reach: it leads the conversation — its
121
+ * greeting, name, character and starters, and the header's first avatar. Omitted, Alia.
122
+ */
123
+ agent?: AliaChatAgentRef;
124
+ /**
125
+ * The handle of the agent's permanent thread, when this conversation is the active stretch of
126
+ * one: the conversations behind it are paged in above, and the thread can be searched. Omitted,
127
+ * the chat is one conversation with nothing behind it.
128
+ */
129
+ threadHandle?: string;
130
+ /** The chat opened a conversation (a new one, from a first turn or a call) or left it (`undefined`). */
131
+ onConversationChange?: (conversationId: string | undefined) => void;
132
+ /** The host's side of the conversation runtime: navigation, artifacts, credits… */
133
+ runtime?: AliaRuntimeHost;
134
+ /**
135
+ * Nothing is stored: the first turn is sent without creating a conversation, so the whole chat
136
+ * lives on this screen (ghost mode).
137
+ */
138
+ ephemeral?: boolean;
139
+ apiUrl?: string;
140
+ /** App context injected into the system prompt. */
86
141
  clientContext?: string;
142
+ /**
143
+ * The UI effects this app's client performs for the lead (`docs/agent-embedding.md` §2). Declared
144
+ * with every turn; an action the stream asks for is performed once, by `execute`, and its
145
+ * outcome reaches the model with the next turn.
146
+ */
147
+ hostTools?: AliaHostTools;
148
+ /** The app's own drawing of a catalogue tool's result, keyed `<app>:<tool>`, before the generic card. */
149
+ toolRenderers?: AliaToolRenderers;
150
+ /**
151
+ * The lead's feature gate refused something (`attachments.documents`…): the host shows its own
152
+ * upsell. Also called for a locked row of the attach menu.
153
+ */
154
+ onFeatureLocked?: (key: string) => void;
155
+ // ── The power level ──
156
+ /**
157
+ * The power level (`auto`, `instant`, `medium`, `high`, `xhigh`, `pro`, `ultra`) or exact
158
+ * `publisher/model` every turn is sent with; omitted, the server's default.
159
+ */
87
160
  model?: string;
88
- apiUrl?: string;
89
- /** Error handler (e.g. toast) */
90
- onError?: (message: string) => void;
91
- /** The empty chat's greeting; omitted, Alia greets the signed-in person by name (`labels`). */
161
+ /** Makes the level selectable in the composer: the person picked `model`. Omitted, no selector. */
162
+ onModelChange?: (model: string) => void;
163
+ /** Models on the person's own devices, offered after the levels in the selector. */
164
+ deviceModels?: readonly DeviceModel[];
165
+ // ── Words ──
166
+ /** Overrides for the chat's own copy; anything omitted is English. */
167
+ labels?: Partial<AliaChatLabels>;
168
+ /** The empty chat's greeting; omitted, the agent's own, or Alia greets the signed-in person by name (`labels`). */
92
169
  welcomeGreeting?: string;
93
170
  welcomeSubtitle?: string;
94
- /** The lines offered over the composer on an empty chat. */
171
+ /** The lines offered over the composer on an empty chat; omitted, the agent's starters. */
95
172
  welcomeSuggestions?: AliaSuggestion[];
96
173
  /** What the host found for the typed text (see `onSuggestionQuery`). */
97
174
  suggestionMatches?: AliaSuggestion[];
98
175
  /** The typed text, once typing has settled, for the host to search suggestions with. */
99
176
  onSuggestionQuery?: (query: string) => void;
100
- // Message action callbacks
101
- onEditMessage?: (messageId: string, newContent: string) => void;
102
- onThumbsUp?: (messageId: string) => void;
103
- onThumbsDown?: (messageId: string) => void;
104
- onApprovePlan?: (planId: string) => void;
105
- onRejectPlan?: (planId: string) => void;
106
- /** Override markdown renderer (app passes CustomMarkdown) */
107
- renderMarkdown?: (content: string) => React.ReactNode;
177
+ /** A suggestion was picked (before it is sent, or put in the composer if it is a template). */
178
+ onSuggestionPicked?: (suggestion: AliaSuggestion) => void;
179
+ // ── The frame ──
108
180
  /**
109
- * The chat's top bar. Receives what it needs from the chat: whether there is anything to clear,
110
- * how, who took part, and what Alia is doing.
181
+ * What the container draws around the thread, from what the chat knows (who took part, whether
182
+ * there is anything to clear, whether it is a searchable thread).
111
183
  */
112
- header?: (state: AliaChatState) => React.ReactNode;
184
+ chrome?: (state: AliaChatState) => AliaChatChrome;
185
+ /** The container draws its own surface (the card of a page), instead of leaving the fill to the frame around it. */
186
+ surface?: boolean;
113
187
  /** Dark-mode flag — forwarded to the ambient wave overlay. */
114
188
  isDarkMode?: boolean;
189
+ /** The ambient field's shape: the whole `screen` (default) or a `sheet`'s smaller one. */
190
+ layout?: 'screen' | 'sheet';
115
191
  /**
116
- * Voice capability — pass `VoiceSession` from `@alia.onl/sdk/voice` to offer
117
- * voice calls. Omitted, the chat is text-only and no LiveKit code is reachable
118
- * from this module. May be a `React.lazy` component.
192
+ * Whether the screen holding the chat is the one on show. A screen kept mounted under another
193
+ * pauses its ambient field, and a live call ends when it is not. Default `true`.
119
194
  */
120
- voiceSession?: VoiceSessionComponent;
195
+ isFocused?: boolean;
121
196
  /**
122
- * Space the composer keeps clear at the bottom edge, in px — the home indicator, when the
123
- * chat is flush with the screen (a phone sheet). The ambient background still runs behind it.
197
+ * Space the composer keeps clear at the bottom edge, in px — the home indicator, when the chat
198
+ * is flush with the screen. It gives way to the keyboard. The ambient background still runs
199
+ * behind it.
124
200
  */
125
201
  bottomInset?: number;
202
+ // ── The composer ──
203
+ /** The host's rows in the add menu. Omitted, the menu offers photos and files, as the lead takes them. */
204
+ addMenu?: AliaAddMenu;
205
+ /** The tab on the composer's top edge (the project the chat is filed under). Replaced by the edit strip while a question is rewritten. */
206
+ composerStatus?: ReactNode;
207
+ /** Error handler (e.g. toast) */
208
+ onError?: (message: string) => void;
209
+ /**
210
+ * The person is signed out and sent a turn: the host asks them to sign in. The draft stays in
211
+ * the composer. Omitted, the turn is sent and the server's refusal is told.
212
+ */
213
+ onSignInRequired?: () => void;
214
+ /**
215
+ * The turn was refused for the person's usage limit: the composer is closed until `dismiss`,
216
+ * and this draws what the host says about it (a dialog).
217
+ */
218
+ renderUsageLimit?: (limit: { error: UsageLimitError; dismiss: () => void }) => ReactNode;
219
+ /**
220
+ * The chat's voice call. On by default; `false` makes the chat text-only. An object brings
221
+ * what only the host knows: the gate before a call (`canStartVoice`, `onVoiceBlocked`), what
222
+ * to refresh when one ends (`onCallEnded`), where its notices go (`onNotice`), the sounds it
223
+ * plays. The call always lives in the chat's conversation, made when it starts if the chat has
224
+ * none.
225
+ */
226
+ voice?: false | AliaVoiceOptions;
227
+ // ── The sheet's ──
126
228
  /**
127
229
  * Hands the first turn of an empty chat to the host instead of sending it here — a sheet opens
128
230
  * the conversation in a new sheet over this one. Omitted, the chat sends it itself.
129
231
  */
130
232
  onStart?: (turn: AliaTurn) => void;
131
- /** The agents the host knows, with their look: a delegation's thinking avatar is drawn from them. */
132
- agents?: UseAliaChatOptions['agents'];
133
233
  /**
134
234
  * The avatar flights this chat takes part in, when a sheet shares them across its chats (the
135
235
  * greeting's avatar flies into a header in another sheet). Omitted, the chat has its own.
@@ -145,67 +245,73 @@ export interface AliaChatContentProps {
145
245
  presentedProgress?: SharedValue<number>;
146
246
  /** Internal sheet lifecycle bridge: receives the live request canceller. */
147
247
  onStopReady?: (stop: (() => void) | null) => void;
148
- /** Overrides for the chat's own copy; anything omitted is English. */
149
- labels?: Partial<AliaChatLabels>;
150
248
  }
151
249
 
152
- /** The chat API carries photos (`image_url` parts) and nothing else. */
153
- const PHOTOS_ONLY = ['image'] as const;
154
-
155
- /** The add menu: photos are the one attachment the chat API carries (`image_url` parts). */
156
- function addMenu(labels: AliaChatLabels): ComposerPanelAddMenuGroup[] {
157
- return [
158
- {
159
- label: labels.addMenuGroup,
160
- rows: [{ id: 'photos', label: labels.addPhotos, icon: RiImageLine }],
161
- },
162
- ];
163
- }
164
-
165
- /** Adapt a voice message into the chat message format. */
166
- function adaptVoiceMessage(vm: VoiceMessage): ChatMessage {
167
- return {
168
- id: vm.id,
169
- role: vm.role,
170
- content: vm.content,
171
- toolInvocations: vm.toolInvocations,
172
- createdAt: Date.now(),
173
- source: 'voice' as const,
174
- speaker: vm.speaker,
175
- };
176
- }
177
-
178
- function AliaChatContentBody({
250
+ /** No live conversation while the thread looks at a moment of its past, as ONE array. */
251
+ const NO_MESSAGES: Message[] = [];
252
+
253
+ /** A host without renderers, as ONE object, so the thread's rows do not re-render for a new `{}`. */
254
+ const NO_RENDERERS: AliaToolRenderers = {};
255
+
256
+ /** The conversation view: everything of `AliaChatContentProps` that is not about which conversation is open. */
257
+ type ConversationViewProps = Omit<
258
+ AliaChatContentProps,
259
+ | 'labels'
260
+ | 'onConversationChange'
261
+ | 'runtime'
262
+ | 'conversationId'
263
+ | 'apiUrl'
264
+ | 'toolRenderers'
265
+ | 'onFeatureLocked'
266
+ > & { conversationId: string | undefined };
267
+
268
+ function AliaConversationView({
179
269
  clientContext,
180
270
  model,
181
- apiUrl,
271
+ onModelChange,
272
+ deviceModels,
273
+ conversationId,
274
+ agent,
275
+ hostTools,
276
+ threadHandle,
277
+ ephemeral = false,
182
278
  onError,
183
279
  welcomeGreeting,
184
280
  welcomeSubtitle,
185
281
  welcomeSuggestions,
186
282
  suggestionMatches,
187
283
  onSuggestionQuery,
188
- onEditMessage,
189
- onThumbsUp,
190
- onThumbsDown,
191
- onApprovePlan,
192
- onRejectPlan,
193
- renderMarkdown,
194
- header,
284
+ onSuggestionPicked,
285
+ chrome,
286
+ surface = false,
195
287
  isDarkMode,
196
- voiceSession: VoiceSession,
288
+ layout = 'screen',
289
+ isFocused = true,
197
290
  bottomInset = 0,
291
+ addMenu,
292
+ composerStatus,
293
+ onSignInRequired,
294
+ renderUsageLimit,
295
+ voice: voiceOptions,
198
296
  onStart,
199
- agents: knownAgents,
200
297
  flights: sharedFlights,
201
298
  initialTurn,
202
299
  presentedProgress,
203
300
  onStopReady,
204
- }: AliaChatContentProps) {
301
+ }: ConversationViewProps) {
205
302
  const ownFlights = useAvatarFlights();
206
303
  const flights = sharedFlights ?? ownFlights;
207
304
  const labels = useLabels();
208
- const addMenuGroups = useMemo(() => addMenu(labels), [labels]);
305
+ const { t } = labels;
306
+ const runtime = useAliaRuntime();
307
+ const queryClient = useQueryClient();
308
+ const { user, isAuthenticated } = useOxy();
309
+ const agentId = agent?.id;
310
+
311
+ // ── Who leads: the agent the chat was opened on, or Alia ──
312
+ const { data: profile, isLoading: profileLoading } = useAgentProfile(agentId);
313
+ const lead = useMemo(() => leadOf(profile), [profile]);
314
+ const { data: features } = useAgentFeatures(agentId);
209
315
 
210
316
  // A failure reaches the host's handler when it has one, and Bloom's toast otherwise.
211
317
  const reportError = useCallback(
@@ -215,190 +321,118 @@ function AliaChatContentBody({
215
321
  },
216
322
  [onError],
217
323
  );
218
- // ── Chat ──
219
- const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext, agents: knownAgents };
220
- const { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan } =
221
- useAliaChat(chatOptions);
222
- const agents = useMemo(() => participatingAgents(messages), [messages]);
223
- const asked = usePendingQuestions(messages, isStreaming);
224
-
225
- // The first turn of an empty chat goes to the host when it asked for it; every other turn is sent here.
226
- const startsElsewhere = onStart !== undefined && messages.length === 0;
227
- const firstTurn = messages.length === 0;
228
- const submitTurn = useCallback(
229
- async (text: string, images?: ReadonlyArray<string>) => {
230
- if (startsElsewhere) {
231
- // The host's conversation sheet opens first so that the flight, drawn when it launches,
232
- // is above it; this chat stays on the greeting, so the avatar can still be measured.
233
- onStart({ text, images });
234
- await flights.launch([ALIA_TO_PROFILE]);
235
- return;
236
- }
237
- // The greeting leaves with the first turn: measure it before it does.
238
- if (firstTurn) await flights.launch([ALIA_TO_PROFILE]);
239
- send(text, images ? { images } : undefined);
240
- },
241
- [startsElsewhere, onStart, firstTurn, flights, send],
242
- );
243
324
 
244
- // The conversation this chat was opened for, sent once.
245
- const initialSent = useRef(false);
246
- useEffect(() => {
247
- if (!initialTurn || initialSent.current) return;
248
- initialSent.current = true;
249
- send(initialTurn.text, initialTurn.images ? { images: initialTurn.images } : undefined);
250
- }, [initialTurn, send]);
325
+ // ── The conversation ──
326
+ const thread = useConversationThread({
327
+ conversationId,
328
+ agentId,
329
+ model,
330
+ clientContext,
331
+ hostTools,
332
+ });
333
+ const {
334
+ messages,
335
+ setMessages,
336
+ isLoading,
337
+ conversationLoading,
338
+ error,
339
+ clearError,
340
+ stopGeneration: stop,
341
+ startOver,
342
+ clearConversation,
343
+ suggestedNewConversation,
344
+ dismissSuggestedNewConversation,
345
+ agentRun,
346
+ failedTurn,
347
+ retryFailedTurn,
348
+ } = thread;
349
+ const participants = useParticipants({ lead, messages, flights });
350
+ const asked = usePendingQuestions(messages, isLoading);
351
+ const view = useThreadView(threadHandle, conversationId);
352
+ const agentActivity = useAgentActivity(agentRun?.turnId ?? null, agentRun?.agentId);
353
+
354
+ // The turn was refused for the usage limit: the composer is closed until the host's dialog is
355
+ // dismissed. Name as well as class — Hermes can break `instanceof` for Error subclasses.
356
+ const usageLimit =
357
+ error instanceof UsageLimitError || error?.name === 'UsageLimitError'
358
+ ? (error as UsageLimitError)
359
+ : null;
360
+ const closed = usageLimit !== null;
251
361
 
252
362
  useEffect(() => {
253
363
  onStopReady?.(stop);
254
364
  return () => onStopReady?.(null);
255
365
  }, [onStopReady, stop]);
256
366
 
257
- // ── Input state ──
258
- const [inputValue, setInputValue] = useState('');
259
-
260
- // Picked photos wait here, as tiles above the field, until the turn is sent.
261
- const [photos, setPhotos] = useState<AliaAttachment[]>([]);
262
- const { pickImage } = useImagePicker();
263
-
264
- const handleAddMenuSelect = useCallback(
265
- async (rowId: string) => {
266
- if (rowId !== 'photos') return;
267
- const assets = await pickImage();
268
- if (!assets?.length) return;
269
- setPhotos((prev) => [
270
- ...prev,
271
- ...assets.map((asset) => ({
272
- id: `img-${Date.now()}-${Math.random().toString(36).slice(2)}`,
273
- type: 'image' as const,
274
- name: asset.name,
275
- uri: asset.uri,
276
- size: asset.size,
277
- mimeType: asset.mimeType,
278
- })),
279
- ]);
280
- },
281
- [pickImage],
282
- );
283
-
284
- // Suggestions over the composer on an empty chat, as the Alia app has them.
285
- const suggestionQuery = useSuggestionQuery(inputValue, messages.length === 0);
286
- useEffect(() => {
287
- onSuggestionQuery?.(suggestionQuery);
288
- }, [suggestionQuery, onSuggestionQuery]);
289
- const handlePickSuggestion = useCallback(
290
- (suggestion: AliaSuggestion) => {
291
- setInputValue('');
292
- submitTurn(suggestion.text);
293
- },
294
- [submitTurn],
367
+ // ── What the person does: write, send, rewrite, call ──
368
+ // An agent's starters are its welcome suggestions, unless the host brought its own.
369
+ const starters = useMemo<AliaSuggestion[] | undefined>(
370
+ () =>
371
+ lead.starters.length === 0
372
+ ? undefined
373
+ : lead.starters.map((starter, index) => ({
374
+ id: `starter-${index}`,
375
+ text: starter.prompt,
376
+ })),
377
+ [lead.starters],
295
378
  );
296
- const suggestions = useComposerSuggestions({
297
- draft: inputValue,
298
- enabled: messages.length === 0,
299
- welcome: welcomeSuggestions,
300
- matches: suggestionMatches,
301
- onPick: handlePickSuggestion,
379
+ const composer = useChatComposer({
380
+ conversationId,
381
+ thread,
382
+ ephemeral,
383
+ closed,
384
+ onWrite: view.backToLatest,
385
+ onStart,
386
+ flights,
387
+ lead,
388
+ initialTurn,
389
+ onSignInRequired,
390
+ reportError,
391
+ welcomeSuggestions: welcomeSuggestions ?? starters,
392
+ suggestionMatches,
393
+ onSuggestionQuery,
394
+ onSuggestionPicked,
395
+ presentedProgress,
302
396
  });
303
-
304
- const handleSubmit = useCallback(
305
- async (value: string) => {
306
- const text = value.trim();
307
- if (!text && photos.length === 0) return;
308
- const pending = photos;
309
- setInputValue('');
310
- setPhotos([]);
311
- try {
312
- const images = await Promise.all(
313
- pending.map((photo) => imageUriToDataUrl(photo.uri, photo.mimeType)),
314
- );
315
- submitTurn(text, images);
316
- } catch (err) {
317
- // Keep the draft: the photos could not be read, nothing was sent.
318
- setInputValue(value);
319
- setPhotos(pending);
320
- reportError(err instanceof Error ? err.message : 'Could not attach the photos');
321
- }
322
- },
323
- [submitTurn, photos, reportError],
324
- );
325
-
326
- // ── TTS ──
327
- const tts = useTTS({ apiUrl });
328
-
329
- // ── Voice mode state ──
330
- // `isVoiceActive` mounts the session; `voiceState` is what the session reports
331
- // back, and stays null until the call is under way.
332
- const [isVoiceActive, setIsVoiceActive] = useState(false);
333
- const [voiceState, setVoiceState] = useState<VoiceSessionState | null>(null);
334
- const textSnapshotRef = useRef<ChatMessage[]>([]);
335
-
336
- // Merge voice messages into chat messages
337
- const voiceMessages = voiceState?.messages;
338
- useEffect(() => {
339
- if (!voiceMessages) return;
340
- setMessages([...textSnapshotRef.current, ...voiceMessages.map(adaptVoiceMessage)]);
341
- }, [voiceMessages, setMessages]);
342
-
343
- const messagesRef = useRef(messages);
344
- messagesRef.current = messages;
345
- const activateVoice = useCallback(() => {
346
- if (isVoiceActive) return;
347
- textSnapshotRef.current = [...messagesRef.current];
348
- setIsVoiceActive(true);
349
- }, [isVoiceActive]);
350
-
351
- // Unmounting the session disconnects the room; the transcript merged above
352
- // stays in the chat.
353
- // A call that fails ends the same way a hang-up does, but the person is told why: a
354
- // button that silently does nothing reads as broken.
355
- const deactivateVoice = useCallback(
356
- (error?: string) => {
357
- setIsVoiceActive(false);
358
- setVoiceState(null);
359
- textSnapshotRef.current = [];
360
- if (error) reportError(error, labels.voiceFailed);
361
- },
362
- [reportError, labels.voiceFailed],
363
- );
364
-
365
- // ── TTS read aloud handler ──
366
- const { readAloud } = tts;
367
- const handleReadAloud = useCallback(
368
- (messageId: string, text: string) => {
369
- readAloud(messageId, text);
370
- },
371
- [readAloud],
397
+ const voice = useChatVoice({
398
+ options: voiceOptions,
399
+ conversationId,
400
+ agentId,
401
+ isFocused,
402
+ ensureConversation: thread.ensureConversation,
403
+ messages,
404
+ setMessages,
405
+ storedMessages: thread.conversation?.messages,
406
+ isLoading,
407
+ });
408
+ // The default menu offers what the lead takes: Alia every attachment; an agent what its feature
409
+ // answer lists, nothing until it has answered.
410
+ const offers = useMemo(() => {
411
+ const states = agentId === undefined ? undefined : (features ?? []);
412
+ return {
413
+ images: featureOffer(states, ALIA_FEATURES.images),
414
+ documents: featureOffer(states, ALIA_FEATURES.documents),
415
+ };
416
+ }, [agentId, features]);
417
+ const attachMenu = useAttachMenu(composer.address, offers, runtime.host.onFeatureLocked);
418
+ const menu = addMenu ?? attachMenu;
419
+ const modes = useMemo(
420
+ () => (onModelChange ? buildPowerLevelOptions(t, deviceModels ?? []) : undefined),
421
+ [onModelChange, t, deviceModels],
372
422
  );
373
423
 
374
- // ── Mark state (drives the header brand mark) ──
375
- const voiceAgentState = voiceState?.agentState;
376
- const markState = useMemo<AliaAvatarState>(() => {
377
- if (isVoiceActive) {
378
- if (voiceAgentState === 'speaking') return 'writing';
379
- if (voiceAgentState === 'thinking') return 'thinking';
380
- return 'idle';
381
- }
382
- return turnActivityState(messages, isStreaming);
383
- }, [messages, isStreaming, isVoiceActive, voiceAgentState]);
384
-
385
- const { user } = useOxy();
386
- const greeting =
387
- welcomeGreeting ?? (user ? labels.greetingNamed(getAccountDisplayName(user)) : labels.greeting);
388
- const welcomeComponent = useMemo(() => {
389
- if (messages.length > 0) return undefined;
390
- return (
391
- <View className="flex-1 items-center justify-center px-4 py-10">
392
- <AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} />
393
- </View>
394
- );
395
- }, [greeting, welcomeSubtitle, messages.length]);
424
+ // ── The way back to the newest turn ──
425
+ const threadRef = useRef<AiChatThreadHandle | null>(null);
426
+ const { isAtBottom, onScroll } = useAtBottom();
427
+ const jumpToEnd = useCallback(() => threadRef.current?.scrollToEnd({ animated: false }), []);
396
428
 
397
- // ── Ambient wave — one persistent overlay across idle/voice/TTS/STT ──
429
+ // ── The ambient wave — one persistent overlay across idle, voice, read-aloud and dictation ──
430
+ const tts = useTTS({ apiUrl: runtime.apiUrl, ...runtime.speech });
431
+ const { state: voiceState } = voice;
398
432
  const wave = useAmbientWave({
399
433
  voice: voiceState
400
434
  ? {
401
- isActive: isVoiceActive,
435
+ isActive: voice.isActive,
402
436
  isConnected: voiceState.isConnected,
403
437
  agentState: voiceState.agentState,
404
438
  waveAmplitude: voiceState.waveAmplitude,
@@ -406,133 +440,311 @@ function AliaChatContentBody({
406
440
  : undefined,
407
441
  isTTSPlaying: tts.playbackState === 'playing',
408
442
  ttsWaveAmplitude: tts.ttsWaveAmplitude,
409
- isGenerating: isStreaming,
443
+ isGenerating: isLoading,
410
444
  });
411
-
412
445
  // The welcome and a call are the field's moments, so they get the intro's full strength; a
413
446
  // conversation being read keeps the hook's subtle one, so the text stays easy to read.
414
- const ambientIntensity = isVoiceActive || messages.length === 0 ? 1 : wave.intensity;
415
-
416
- // ── Voice activate button for empty submit (only if voice was provided) ──
417
- const voiceActivateButton = useMemo(
418
- () =>
419
- VoiceSession ? (
420
- <Button
421
- iconOnly
422
- tone="action"
423
- size="md"
424
- accessibilityLabel={labels.startVoice}
425
- onPress={activateVoice}
426
- leadingIcon={VoiceModeIcon}
427
- />
428
- ) : undefined,
429
- [VoiceSession, activateVoice, labels.startVoice],
430
- );
431
-
432
- const composerRef = useRef<TextInput | null>(null);
433
- useFocusWhenPresented(presentedProgress, () => composerRef.current?.focus());
434
-
435
- const composer =
436
- VoiceSession && isVoiceActive ? (
437
- /* Suspense so the session may be handed to us as a React.lazy import. */
438
- <Suspense fallback={null}>
439
- <VoiceSession apiUrl={apiUrl} onStateChange={setVoiceState} onEnd={deactivateVoice} />
440
- </Suspense>
441
- ) : (
442
- <AliaComposer
443
- apiUrl={apiUrl}
444
- inputRef={composerRef}
445
- disableKeyboardAvoidance
446
- onKeyPress={suggestions.onKeyPress}
447
- suggestions={{ ...suggestions, onPick: handlePickSuggestion }}
448
- // Asked beside the field, not in the thread: the card docks above the composer.
449
- questions={{
450
- items: asked.questions,
451
- onSubmit: (message) => send(message),
452
- onDismiss: asked.dismiss,
453
- }}
454
- value={inputValue}
455
- onValueChange={setInputValue}
456
- onSubmit={() => void handleSubmit(inputValue)}
457
- busy={isStreaming}
458
- onStop={stop}
459
- emptyAction={voiceActivateButton}
460
- placeholder={labels.placeholder}
461
- addMenu={addMenuGroups}
462
- onAddMenuSelect={handleAddMenuSelect}
463
- attachments={photos}
464
- onAddAttachment={(photo) => setPhotos((prev) => [...prev, photo])}
465
- onRemoveAttachment={(id) => setPhotos((prev) => prev.filter((p) => p.id !== id))}
466
- attachmentKinds={PHOTOS_ONLY}
467
- onError={onError}
468
- />
469
- );
470
-
471
- const hasMessages = messages.length > 0;
472
- const headerNode = useMemo(
473
- () => header?.({ hasMessages, clear, markState, agents }),
474
- [header, hasMessages, clear, markState, agents],
475
- );
447
+ const ambientIntensity = voice.isActive || composer.firstTurn ? 1 : wave.intensity;
476
448
  const background = useMemo(
477
449
  () => (
478
450
  <AmbientField
479
- layout="sheet"
451
+ layout={layout}
480
452
  waveAmplitude={wave.waveAmplitude}
481
453
  agentState={wave.agentState}
482
454
  intensity={ambientIntensity}
483
455
  isDarkMode={isDarkMode}
456
+ // A screen kept mounted under another does not keep animating back there.
457
+ paused={!isFocused}
484
458
  />
485
459
  ),
486
- [wave.waveAmplitude, wave.agentState, ambientIntensity, isDarkMode],
460
+ [layout, wave.waveAmplitude, wave.agentState, ambientIntensity, isDarkMode, isFocused],
461
+ );
462
+
463
+ // ── The greeting of an empty chat ──
464
+ const greeting =
465
+ welcomeGreeting ??
466
+ lead.greeting ??
467
+ (isAuthenticated && user
468
+ ? labels.greetingNamed(getAccountDisplayName(user, labels.locale))
469
+ : labels.greeting);
470
+ // An agent's chat greets as the agent: nothing, rather than Alia, until its profile is read.
471
+ const welcome = useMemo(
472
+ () =>
473
+ profileLoading ? null : (
474
+ <AliaWelcomeMessage greeting={greeting} subtitle={welcomeSubtitle} lead={lead} />
475
+ ),
476
+ [profileLoading, greeting, welcomeSubtitle, lead],
477
+ );
478
+
479
+ // ── The agent's offer to start the next stretch fresh ──
480
+ const createConversation = useCreateConversation();
481
+ /**
482
+ * Take the agent up on its offer: start the next stretch of this thread.
483
+ *
484
+ * The agent cannot do this — its tool emits one frame and writes nothing — so the act is the
485
+ * person's, here. A new conversation with the same agent becomes the most recent, which is the
486
+ * one the thread calls active, so re-reading the thread is what moves the screen onto it. On a
487
+ * chat that is not a thread there is nothing to continue: the offer empties the chat, which is
488
+ * "start a new conversation" on a screen that has no thread.
489
+ */
490
+ const acceptNewConversation = useCallback(() => {
491
+ dismissSuggestedNewConversation();
492
+ if (agentId === undefined || threadHandle === undefined) {
493
+ void startOver();
494
+ return;
495
+ }
496
+ createConversation.mutate(
497
+ { agentId },
498
+ {
499
+ onSuccess: () => {
500
+ void queryClient.invalidateQueries({ queryKey: aliaQueryKeys.threads.all(threadHandle) });
501
+ // And the history with it: the stretch that was live becomes history the moment a new
502
+ // one is active, and the pages held were fetched before its last turns existed.
503
+ void queryClient.invalidateQueries({
504
+ queryKey: aliaQueryKeys.threads.messages(threadHandle),
505
+ });
506
+ },
507
+ },
508
+ );
509
+ }, [
510
+ agentId,
511
+ threadHandle,
512
+ createConversation,
513
+ queryClient,
514
+ dismissSuggestedNewConversation,
515
+ startOver,
516
+ ]);
517
+ const newConversationOffer = useMemo(
518
+ () =>
519
+ suggestedNewConversation === null
520
+ ? undefined
521
+ : {
522
+ reason: suggestedNewConversation,
523
+ onAccept: acceptNewConversation,
524
+ onDismiss: dismissSuggestedNewConversation,
525
+ },
526
+ [suggestedNewConversation, acceptNewConversation, dismissSuggestedNewConversation],
527
+ );
528
+
529
+ // ── The chrome ──
530
+ const latestMessages = useRef(messages);
531
+ latestMessages.current = messages;
532
+ const hasMessages = messages.length > 0;
533
+ const state = useMemo<AliaChatState>(
534
+ () => ({
535
+ conversationId,
536
+ hasMessages,
537
+ startOver,
538
+ clearMessages: clearConversation,
539
+ markState: voice.markState,
540
+ participants,
541
+ openSearch: threadHandle === undefined ? undefined : view.openSearch,
542
+ getMessages: () => latestMessages.current,
543
+ }),
544
+ [
545
+ conversationId,
546
+ hasMessages,
547
+ startOver,
548
+ clearConversation,
549
+ voice.markState,
550
+ participants,
551
+ threadHandle,
552
+ view.openSearch,
553
+ ],
487
554
  );
555
+ const frame = useMemo(() => chrome?.(state) ?? {}, [chrome, state]);
556
+
557
+ // ── The composer ──
558
+ const { editing, edit } = composer;
559
+ const voiceButton = voice.enabled ? (
560
+ <Button
561
+ iconOnly
562
+ tone="action"
563
+ size="md"
564
+ accessibilityLabel={labels.startVoice}
565
+ onPress={voice.activate}
566
+ leadingIcon={VoiceModeIcon}
567
+ />
568
+ ) : undefined;
569
+ const status: ReactNode =
570
+ editing !== undefined ? (
571
+ // What is happening while a question is rewritten, and the way out of it. Drawn as the
572
+ // status tab it replaces: the thread scrolls behind the composer, and a strip without the
573
+ // tab's surface printed its words over the transcript's.
574
+ <View className="mx-7 h-[34px] flex-row items-center gap-2 rounded-t-2xl bg-background-tertiary-default px-2 py-1">
575
+ <RiEditLine size="sm" />
576
+ <Text className="flex-1 text-xs text-muted-foreground" numberOfLines={1}>
577
+ {t('composer.editingMessage')}
578
+ </Text>
579
+ <Button appearance="subtle" tone="accent" size="xs" onPress={edit.cancel}>
580
+ {t('composer.cancelEdit')}
581
+ </Button>
582
+ </View>
583
+ ) : (
584
+ composerStatus
585
+ );
488
586
 
587
+ const composerNode = (
588
+ <>
589
+ {voice.enabled && voice.isActive ? (
590
+ <AliaVoiceCall {...voice.launcher.sessionProps} />
591
+ ) : (
592
+ <AliaComposer
593
+ // A different account is a different composer: reads in flight for the last one are
594
+ // aborted rather than landing here.
595
+ key={composer.address.account ?? ''}
596
+ apiUrl={runtime.apiUrl}
597
+ speechLanguage={runtime.speech.language}
598
+ inputRef={composer.inputRef}
599
+ disableKeyboardAvoidance
600
+ onKeyPress={composer.onKeyPress}
601
+ suggestions={composer.suggestions}
602
+ // Asked beside the field, not in the thread: the card docks above the composer.
603
+ questions={{
604
+ items: asked.questions,
605
+ onSubmit: (message) => void composer.sendTurn(message, undefined, composer.turnOptions),
606
+ onDismiss: asked.dismiss,
607
+ }}
608
+ value={composer.text}
609
+ onValueChange={composer.setText}
610
+ onSubmit={() => void composer.submit(composer.text)}
611
+ busy={isLoading}
612
+ disabled={closed}
613
+ onStop={stop}
614
+ emptyAction={voiceButton}
615
+ accessory={
616
+ <ScrollToBottomButton
617
+ visible={!isAtBottom}
618
+ onPress={jumpToEnd}
619
+ accessibilityLabel={t('chat.bloom.scrollToLatest')}
620
+ />
621
+ }
622
+ placeholder={
623
+ closed
624
+ ? t('usageLimit.inputDisabledPlaceholder')
625
+ : editing !== undefined
626
+ ? t('composer.editPlaceholder')
627
+ : labels.placeholder(lead.name)
628
+ }
629
+ status={status}
630
+ // The mode selector is the power level: how much power this turn gets.
631
+ {...(modes === undefined
632
+ ? {}
633
+ : { modes, mode: model ?? 'auto', onModeChange: onModelChange })}
634
+ addMenu={menu.groups}
635
+ onAddMenuSelect={menu.onSelect}
636
+ attachments={composer.photos}
637
+ onAddAttachment={composer.addAttachment}
638
+ onRemoveAttachment={composer.removeAttachment}
639
+ attachmentKinds={addMenu === undefined ? attachMenu.kinds : undefined}
640
+ onError={onError}
641
+ />
642
+ )}
643
+ {bottomInset > 0 ? <KeyboardSafeAreaFloor inset={bottomInset} /> : null}
644
+ </>
645
+ );
646
+
647
+ const showsPast = view.jumped;
489
648
  return (
490
649
  <AliaFlightsProvider value={flights}>
491
650
  <AiChatContainer
492
- header={headerNode}
493
- composer={
494
- bottomInset > 0 ? (
495
- <View style={{ paddingBottom: bottomInset }}>{composer}</View>
496
- ) : (
497
- composer
498
- )
499
- }
500
- // The thread scrolls behind the header and the composer, which fade in over it — as
501
- // in the app's conversation. The wave is the container's background layer, between
502
- // its surface and the turns.
651
+ header={frame.header}
652
+ title={frame.title}
653
+ project={frame.project}
654
+ actions={frame.actions}
655
+ composer={composerNode}
656
+ // The thread scrolls behind the header and the composer, which fade in over it. The wave
657
+ // is the container's background layer, between its surface and the turns.
503
658
  floatingChrome
504
- // The sheet or screen around it owns the fill and the shape; the container adds neither.
505
- surface={false}
659
+ // A frame that owns the fill and the shape (a sheet, a screen) leaves the surface off.
660
+ surface={surface}
506
661
  background={background}
507
662
  >
508
- <View className="flex-1">
509
- {/* Welcome or Messages */}
510
- <AliaChatMessageList
511
- messages={messages}
512
- isStreaming={isStreaming}
513
- onReadAloud={handleReadAloud}
514
- ttsActiveMessageId={tts.activeMessageId}
515
- ttsPlaybackState={tts.playbackState}
516
- onEditMessage={onEditMessage}
517
- onThumbsUp={onThumbsUp}
518
- onThumbsDown={onThumbsDown}
519
- onApprovePlan={onApprovePlan ?? approvePlan}
520
- onRejectPlan={onRejectPlan ?? rejectPlan}
521
- renderMarkdown={renderMarkdown}
522
- welcomeComponent={welcomeComponent}
523
- />
524
- </View>
663
+ <AliaThread
664
+ // Nothing live under a window: it is a view of the past, and the conversation being
665
+ // streamed into is not below it in the thread.
666
+ messages={showsPast ? NO_MESSAGES : messages}
667
+ conversationId={conversationId}
668
+ isLoading={isLoading}
669
+ conversationLoading={conversationLoading}
670
+ history={view.history}
671
+ focusCursor={view.focusCursor}
672
+ failedTurn={failedTurn}
673
+ onRetryTurn={retryFailedTurn}
674
+ onRegenerate={composer.regenerate}
675
+ onStartEdit={edit.start}
676
+ newConversationOffer={newConversationOffer}
677
+ agentActivity={agentRun === null ? null : agentActivity}
678
+ call={voice.isActive ? { agentState: voice.agentState } : undefined}
679
+ empty={welcome}
680
+ threadRef={threadRef}
681
+ onScroll={onScroll}
682
+ />
683
+ {!showsPast ? null : (
684
+ <View className="absolute inset-x-0 top-16 z-10 items-center" pointerEvents="box-none">
685
+ <Button appearance="outline" tone="neutral" size="sm" onPress={view.backToLatest}>
686
+ {t('chat.backToLatest')}
687
+ </Button>
688
+ </View>
689
+ )}
690
+ {!view.searchOpen || threadHandle === undefined ? null : (
691
+ <ThreadSearch handle={threadHandle} onJump={view.jump} onClose={view.closeSearch} />
692
+ )}
525
693
  </AiChatContainer>
694
+ {usageLimit === null ? null : renderUsageLimit?.({ error: usageLimit, dismiss: clearError })}
526
695
  {sharedFlights ? null : <AvatarFlightLayer flights={ownFlights} />}
527
696
  </AliaFlightsProvider>
528
697
  );
529
698
  }
530
699
 
531
- /** The chat: header, thread and composer, with the host's labels in reach of all of it. */
532
- export function AliaChatContent({ labels, ...props }: AliaChatContentProps) {
700
+ /**
701
+ * The chat: header, thread and composer, with the host's labels in reach of all of it.
702
+ *
703
+ * It is a conversation view over the shared runtime. The conversation open in it is the host's
704
+ * when `conversationId` is given, and its own otherwise.
705
+ */
706
+ export function AliaChatContent({
707
+ labels,
708
+ conversationId: controlledId,
709
+ onConversationChange,
710
+ runtime,
711
+ apiUrl,
712
+ toolRenderers,
713
+ onFeatureLocked,
714
+ ...props
715
+ }: AliaChatContentProps) {
716
+ const [ownId, setOwnId] = useState<string | undefined>(undefined);
717
+ const parent = useAliaRuntime();
718
+ // Who decides which conversation is open: a host that navigates (a route) does; with none, the
719
+ // chat does (the sheet). Either way the host hears of it first, then `onConversationChange`.
720
+ const hostOpens = runtime?.openConversation ?? parent.host.openConversation;
721
+ const hostCloses = runtime?.closeConversation ?? parent.host.closeConversation;
722
+ const hostNavigates = hostOpens !== undefined;
723
+ const host = useMemo<AliaRuntimeHost>(
724
+ () => ({
725
+ ...runtime,
726
+ ...(onFeatureLocked === undefined ? {} : { onFeatureLocked }),
727
+ openConversation: (id) => {
728
+ hostOpens?.(id);
729
+ if (!hostNavigates) setOwnId(id);
730
+ onConversationChange?.(id);
731
+ },
732
+ closeConversation: () => {
733
+ hostCloses?.();
734
+ if (!hostNavigates) setOwnId(undefined);
735
+ onConversationChange?.(undefined);
736
+ },
737
+ }),
738
+ [runtime, onFeatureLocked, hostOpens, hostCloses, hostNavigates, onConversationChange],
739
+ );
740
+
533
741
  return (
534
742
  <AliaChatLabelsProvider labels={labels}>
535
- <AliaChatContentBody {...props} />
743
+ <AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host}>
744
+ <AliaToolRenderersProvider value={toolRenderers ?? NO_RENDERERS}>
745
+ <AliaConversationView {...props} conversationId={controlledId ?? ownId} />
746
+ </AliaToolRenderersProvider>
747
+ </AliaRuntimeProvider>
536
748
  </AliaChatLabelsProvider>
537
749
  );
538
750
  }