@alia.onl/sdk 11.1.0 → 13.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 (36) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/package.json +5 -4
  3. package/src/components/AliaAvatar.tsx +16 -20
  4. package/src/components/AliaChatContent.tsx +234 -96
  5. package/src/components/AliaChatHeader.tsx +102 -76
  6. package/src/components/AliaChatMessageList.tsx +174 -134
  7. package/src/components/AliaChatScreen.tsx +45 -23
  8. package/src/components/AliaChatSheet.tsx +151 -50
  9. package/src/components/AliaWelcomeMessage.tsx +26 -53
  10. package/src/components/AmbientField.tsx +19 -16
  11. package/src/components/ComposerSuggestions.tsx +45 -0
  12. package/src/components/IdentityMark.tsx +3 -1
  13. package/src/components/Markdown.tsx +18 -24
  14. package/src/components/QuestionCard.tsx +35 -33
  15. package/src/components/ThinkingBlock.tsx +9 -13
  16. package/src/components/VoiceModeIcon.tsx +15 -7
  17. package/src/components/voice/VoiceControls.tsx +17 -9
  18. package/src/hooks/useAliaChat.ts +124 -5
  19. package/src/hooks/useComposerSuggestions.ts +84 -0
  20. package/src/index.ts +10 -10
  21. package/src/lib/agent-character.ts +28 -0
  22. package/src/lib/chat-stream.ts +25 -3
  23. package/src/lib/citations.ts +62 -10
  24. package/src/lib/delegation.ts +34 -0
  25. package/src/lib/domain.ts +12 -0
  26. package/src/lib/flights.ts +24 -0
  27. package/src/lib/greeting.ts +15 -0
  28. package/src/lib/image-data-url.ts +26 -5
  29. package/src/lib/labels.tsx +10 -2
  30. package/src/lib/questions.ts +15 -1
  31. package/src/lib/suggestions.ts +53 -0
  32. package/src/lib/task-labels.ts +7 -39
  33. package/src/lib/use-after-delay.ts +19 -0
  34. package/src/lib/work-log.ts +3 -9
  35. package/src/tools.ts +8 -1
  36. package/src/types.ts +6 -5
@@ -1,30 +1,15 @@
1
1
  import React, { useMemo, type ReactNode } from 'react';
2
- import { View, Text, Platform, Linking, useColorScheme } from 'react-native';
2
+ import { View, Text, Platform, Linking } from 'react-native';
3
+ import { useTheme } from '@oxy.so/bloom/theme';
3
4
  import Markdown, { MarkdownIt, type ASTNode } from 'react-native-markdown-display';
4
5
  import { CodeBlock } from '@oxy.so/bloom/code';
5
6
  import type { AliaColors } from '../theme';
6
7
 
7
8
  // Hardcoded fallback colors for standalone SDK usage (when no color override is passed).
8
9
  // The main app always passes resolved colors via the `colors` prop.
9
- const FALLBACK_LIGHT: Pick<
10
- AliaColors,
11
- 'text' | 'muted' | 'border' | 'primary' | 'mutedForeground'
12
- > = {
13
- text: '#11181C',
14
- muted: '#F4F4F5',
15
- border: '#E5E5EA',
16
- primary: '#7C3AED',
17
- mutedForeground: '#71717A',
18
- };
19
- const FALLBACK_DARK: typeof FALLBACK_LIGHT = {
20
- text: '#ECEDEE',
21
- muted: '#27272A',
22
- border: '#2C2C2E',
23
- primary: '#A78BFA',
24
- mutedForeground: '#A1A1AA',
25
- };
26
-
27
- const BODY_TEXT = { fontSize: 16, lineHeight: 28 } as const;
10
+ /** The body text of a reply: its bubble sets its padding from these. */
11
+ export const MARKDOWN_BODY = { fontSize: 16, lineHeight: 28 } as const;
12
+ const BODY_TEXT = MARKDOWN_BODY;
28
13
  const HEADING_TEXT = { fontSize: 16, lineHeight: 22 } as const;
29
14
  const MONO_FONT = Platform.select({ ios: 'Courier', android: 'monospace', default: 'monospace' })!;
30
15
 
@@ -180,7 +165,8 @@ function createRules(colors: AliaColors, renderCodeBlock?: RenderCodeBlock) {
180
165
  <Text style={{ marginRight: 8, minWidth: 14, ...BODY_TEXT, color: mutedForeground }}>
181
166
  {bullet}
182
167
  </Text>
183
- <Text style={{ flex: 1, ...BODY_TEXT, color: textColor }}>{children}</Text>
168
+ {/* A View, not a Text: a nested list is a View, and inside a Text it runs inline. */}
169
+ <View style={{ flex: 1 }}>{children}</View>
184
170
  </View>
185
171
  );
186
172
  },
@@ -476,9 +462,17 @@ export function AliaMarkdown({
476
462
  fontFamily,
477
463
  renderCodeBlock,
478
464
  }: AliaMarkdownProps) {
479
- const scheme = useColorScheme();
480
- const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
481
- const colors = { ...fallback, ...colorOverrides } as AliaColors;
465
+ // Bloom's theme, which follows the app's chosen mode, not the system's scheme: a person who
466
+ // picked light on a dark OS gets dark text on a light surface, not the reverse.
467
+ const { colors: theme } = useTheme();
468
+ const colors = {
469
+ text: theme.text,
470
+ muted: theme.backgroundSecondary,
471
+ border: theme.border,
472
+ primary: theme.primary,
473
+ mutedForeground: theme.textSecondary,
474
+ ...colorOverrides,
475
+ } as AliaColors;
482
476
 
483
477
  const customRules = useMemo(
484
478
  () => createRules(colors, renderCodeBlock),
@@ -5,31 +5,21 @@ import { type AskedQuestion, formatAnswers } from '../lib/questions';
5
5
 
6
6
  interface QuestionCardProps {
7
7
  questions: AskedQuestion[];
8
- /** Sends the answers as the person's next message. Absent: the card is read-only. */
9
- onSubmit?: (message: string) => void;
8
+ /** Sends the answers as the person's next message. */
9
+ onSubmit: (message: string) => void;
10
+ /** Shows the corner dismiss; typing a reply in the composer answers just as well. */
11
+ onDismiss?: () => void;
10
12
  }
11
13
 
12
14
  /** Ids are positions: the questions are the model's, and carry none. */
13
15
  const idOf = (index: number) => `q${index}`;
14
16
 
15
17
  /**
16
- * The questions Alia asked: Bloom's `Questionnaire` while the person can still answer, and
17
- * the questions as plain lines once they have (the answer is their message, right below).
18
- * Typing a reply in the composer answers just as well, which is why there is no dismiss.
18
+ * The questions Alia asked, as Bloom's `Questionnaire`. Give it to the composer's `question`
19
+ * slot while the person can still answer: it docks above the composer, where they are about
20
+ * to type, rather than sitting in the thread.
19
21
  */
20
- export function QuestionCard({ questions, onSubmit }: QuestionCardProps) {
21
- if (onSubmit === undefined) {
22
- return (
23
- <View className="my-2 gap-1">
24
- {questions.map((q, i) => (
25
- // The model's questions never reorder: the position is the identity.
26
- // biome-ignore lint/suspicious/noArrayIndexKey: see above
27
- <Muted key={i}>{q.question}</Muted>
28
- ))}
29
- </View>
30
- );
31
- }
32
-
22
+ export function QuestionCard({ questions, onSubmit, onDismiss }: QuestionCardProps) {
33
23
  const complete = (answers: QuestionnaireAnswers) =>
34
24
  onSubmit(
35
25
  formatAnswers(
@@ -42,21 +32,33 @@ export function QuestionCard({ questions, onSubmit }: QuestionCardProps) {
42
32
  );
43
33
 
44
34
  return (
45
- <View className="my-2">
46
- <Questionnaire
47
- questions={questions.map((q, i) => ({
48
- id: idOf(i),
49
- question: q.question,
50
- select: q.multiSelect ? 'multiple' : 'single',
51
- other: true,
52
- options: q.options.map((option, index) => ({
53
- value: String(index),
54
- label: option.label,
55
- description: option.description,
56
- })),
57
- }))}
58
- onComplete={complete}
59
- />
35
+ <Questionnaire
36
+ questions={questions.map((q, i) => ({
37
+ id: idOf(i),
38
+ question: q.question,
39
+ select: q.multiSelect ? 'multiple' : 'single',
40
+ other: true,
41
+ options: q.options.map((option, index) => ({
42
+ value: String(index),
43
+ label: option.label,
44
+ description: option.description,
45
+ })),
46
+ }))}
47
+ onComplete={complete}
48
+ onDismiss={onDismiss}
49
+ />
50
+ );
51
+ }
52
+
53
+ /** The questions Alia asked, as plain lines: the thread's record of them once they are answered. */
54
+ export function AskedQuestions({ questions }: { questions: AskedQuestion[] }) {
55
+ return (
56
+ <View className="my-2 gap-1">
57
+ {questions.map((q, i) => (
58
+ // The model's questions never reorder: the position is the identity.
59
+ // biome-ignore lint/suspicious/noArrayIndexKey: see above
60
+ <Muted key={i}>{q.question}</Muted>
61
+ ))}
60
62
  </View>
61
63
  );
62
64
  }
@@ -1,6 +1,5 @@
1
1
  import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
2
2
  import { useEffect, useRef, useState } from 'react';
3
- import { View } from 'react-native';
4
3
  import { useLabels } from '../lib/labels';
5
4
  import { AliaMarkdown } from './Markdown';
6
5
 
@@ -12,10 +11,11 @@ interface ThinkingBlockProps {
12
11
  }
13
12
 
14
13
  /**
15
- * A turn's reasoning: Bloom's `AgentThinking` as a disclosure (live while it
16
- * streams, "Thought for N seconds" once done), with the text set as Markdown.
17
- * Open and close, the indicator and the chevron are Bloom's; this only counts
18
- * how long the stream ran, because the message carries no duration.
14
+ * A turn's reasoning, once it is done: Bloom's `AgentThinking` as a closed disclosure, "Thought
15
+ * for N seconds", with the text set as Markdown. While the reasoning streams it draws nothing:
16
+ * the avatar under the reply is the one indicator that the turn is thinking
17
+ * (`AgentReplyIdentity`), and a second one beside it is noise. It still counts how long the stream
18
+ * ran, because the message carries no duration.
19
19
  */
20
20
  export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
21
21
  const labels = useLabels();
@@ -33,15 +33,11 @@ export function ThinkingBlock({ text, streaming }: ThinkingBlockProps) {
33
33
 
34
34
  const finishedLabel = seconds ? labels.thoughtFor(seconds) : labels.reasoning;
35
35
 
36
+ if (streaming) return null;
37
+
36
38
  return (
37
- <AgentThinking
38
- variant="stars"
39
- thinking={streaming}
40
- label={streaming ? undefined : finishedLabel}
41
- >
42
- <View style={{ opacity: streaming ? 0.8 : 1 }}>
43
- <AliaMarkdown content={text} />
44
- </View>
39
+ <AgentThinking thinking={false} label={finishedLabel}>
40
+ <AliaMarkdown content={text} />
45
41
  </AgentThinking>
46
42
  );
47
43
  }
@@ -1,10 +1,18 @@
1
- import Entypo from '@expo/vector-icons/Entypo';
2
- import type { BloomIconComponent } from '@oxy.so/bloom/icons';
1
+ import Svg, { Path } from 'react-native-svg';
2
+
3
+ /** The glyph's outline: a speaker cone with three rays, drawn on a 512 grid (y down). */
4
+ const GLYPH =
5
+ 'M136 117Q158 95 212.5 119.5Q267 144 323 200Q379 256 403.5 311Q428 366 406 387Q396 398 373 414Q350 430 313.5 447Q277 464 240 472.5Q203 481 162 472.5Q121 464 90.5 433.5Q60 403 51 361.5Q42 320 50.5 283Q59 246 76.5 209.5Q94 173 109.5 150.5Q125 128 136 117ZM348.5 361.5Q348.5 361.5 358.25 364.25Q368 367 373 362.5Q378 358 372.5 338Q367 318 347 286Q327 254 298 225Q269 196 237 176.5Q205 157 185.5 151Q166 145 161 150Q156 155 162 174.5Q168 194 187.5 226Q207 258 236 287.5Q265 317 297 336.5Q329 356 348.5 361.5ZM395 153Q388 161 377 161Q366 161 358.5 153.5Q351 146 351 135Q351 124 359 117L408 68Q415 61 425.5 61Q436 61 443.5 68.5Q451 76 451 86.5Q451 97 444 104ZM285 97Q272 90 272 75Q272 68 275 62L303 13Q310 0 325 0Q336 0 343.5 7.5Q351 15 351 26Q351 32 347 38L320 87Q312 101 297 101Q291 101 285 97ZM509 174Q512 180 512 187Q512 202 499 209L449 236Q443 240 437 240Q426 240 418.5 232.5Q411 225 411 214Q411 199 424 192L474 165Q480 161 486 161Q501 161 509 174Z';
3
6
 
4
7
  /**
5
- * The glyph of Alia's voice button: Entypo's `sound`. Remix has no equivalent, so it is
6
- * handed Bloom's icon contract (`width` / `fill`) for a Bloom `Button` to size and colour.
8
+ * The glyph of Alia's voice button: the speaker with rays the button has always worn. Bloom has no
9
+ * equivalent, so it is drawn here against Bloom's icon contract (`width` / `fill`) for a Bloom
10
+ * `Button` to size and colour.
7
11
  */
8
- export const VoiceModeIcon: BloomIconComponent = ({ width = 18, fill }) => (
9
- <Entypo name="sound" size={width} color={fill} />
10
- );
12
+ export function VoiceModeIcon({ width = 18, fill }: { width?: number; fill?: string }) {
13
+ return (
14
+ <Svg width={width} height={width} viewBox="23 -17 512 512">
15
+ <Path d={GLYPH} fill={fill} />
16
+ </Svg>
17
+ );
18
+ }
@@ -1,11 +1,12 @@
1
1
  /**
2
- * Bottom voice control bar that replaces the composer when voice mode is active.
3
- * Contains status text and Bloom's mute/end call controls.
2
+ * Bottom voice control bar that replaces the composer when voice mode is active: the status
3
+ * text over Bloom's own `VoiceControls` (microphone and end), the controls of Bloom's voice
4
+ * mode, not a call's.
4
5
  */
5
6
 
6
- import { CallControls } from '@oxy.so/bloom/call-ui';
7
7
  import { Loading } from '@oxy.so/bloom/loading';
8
8
  import { Text } from '@oxy.so/bloom/typography';
9
+ import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
9
10
  import { View } from 'react-native';
10
11
  import type { RoomState, AgentState } from '../../types';
11
12
 
@@ -60,6 +61,13 @@ function getStatusText(
60
61
  return '';
61
62
  }
62
63
 
64
+ /** What Bloom's voice controls call the call's state: the microphone is only live while it is. */
65
+ function voiceStateOf(roomState: RoomState, agentState: AgentState): VoiceState {
66
+ if (roomState === 'error') return 'error';
67
+ if (roomState !== 'connected') return 'idle';
68
+ return agentState === 'idle' ? 'listening' : agentState;
69
+ }
70
+
63
71
  export function VoiceControls({
64
72
  roomState,
65
73
  agentState,
@@ -84,13 +92,13 @@ export function VoiceControls({
84
92
  ) : null}
85
93
 
86
94
  {roomState === 'connected' ? (
87
- <CallControls
95
+ <BloomVoiceControls
96
+ state={voiceStateOf(roomState, agentState)}
88
97
  muted={isMuted}
89
- onMutedChange={onToggleMute}
90
- onEndCall={onEnd}
91
- // TODO(bloom): `CallControls` draws its captions for a dark call stage, so on the
92
- // chat's light surface they vanish. The buttons keep their accessible names.
93
- labels={{ mute: labels.mute, unmute: labels.unmute, endCall: labels.end }}
98
+ // Called with nothing: Bloom hands its handlers the next value or the press, and
99
+ // `onToggleMute` / `onEnd` of a session take optional arguments of their own.
100
+ onMutedChange={() => onToggleMute()}
101
+ onEnd={() => onEnd()}
94
102
  />
95
103
  ) : null}
96
104
 
@@ -3,12 +3,18 @@ import { useOxy } from '@oxy.so/services/ui/client';
3
3
  import type { ChatMessage, ToolInvocation } from '../types';
4
4
  import { getTextFromContent, type MessageContent } from '../lib/content-utils';
5
5
  import { resolveModelId } from '../lib/catalogue';
6
+ import { agentMessageId, delegatedAgent } from '../lib/delegation';
6
7
  import { streamAliaChat } from '../lib/chat-transport';
7
8
  import type { AliaChatStreamEvent } from '../lib/chat-stream';
8
9
 
9
10
  const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
10
11
 
11
12
  export interface UseAliaChatOptions {
13
+ /**
14
+ * The agents the host knows, with their look: a delegation's thinking avatar is drawn from here
15
+ * until the agent's answer brings its own. Without it (or for an agent it lacks) the default look.
16
+ */
17
+ agents?: ReadonlyArray<{ id: string; character?: unknown }>;
12
18
  /**
13
19
  * Base URL the hook sends its requests to. Defaults to
14
20
  * `EXPO_PUBLIC_ALIA_API_URL`, then `https://api.alia.onl`.
@@ -57,6 +63,10 @@ export interface UseAliaChatReturn {
57
63
  isStreaming: boolean;
58
64
  stop: () => void;
59
65
  clear: () => void;
66
+ /** The person accepted a proposed plan; the card collapses to "approved". */
67
+ approvePlan: (planId: string) => void;
68
+ /** The person cancelled a proposed plan: the card collapses to "cancelled" and the turn stops. */
69
+ rejectPlan: (planId: string) => void;
60
70
  error: string | null;
61
71
  }
62
72
 
@@ -70,6 +80,8 @@ interface PendingUpdates {
70
80
  content: string;
71
81
  reasoning: string;
72
82
  tools: ToolInvocation[];
83
+ /** How many delegated agents have answered in this turn. */
84
+ agentAnswers: number;
73
85
  timer: ReturnType<typeof setTimeout> | null;
74
86
  }
75
87
 
@@ -118,6 +130,8 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
118
130
  */
119
131
  export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
120
132
  const { apiUrl = API_URL, model, clientContext, accessToken: accessTokenProp } = options;
133
+ const directoryRef = useRef(new Map<string, { id: string; character?: unknown }>());
134
+ directoryRef.current = new Map((options.agents ?? []).map((agent) => [agent.id, agent]));
121
135
  const requestUrl = '/v1/chat/completions';
122
136
 
123
137
  const { oxyServices } = useOxy();
@@ -168,6 +182,75 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
168
182
  });
169
183
  }, []);
170
184
 
185
+ /**
186
+ * An agent's message of this turn, before Alia's own: opened empty by the call that sends for the
187
+ * agent, filled by its answer. Without an open one, an answer makes a message of its own.
188
+ */
189
+ const insertAgentMessage = useCallback(
190
+ (
191
+ assistantId: string,
192
+ agent: { id: string; name: string; character?: unknown },
193
+ content: string,
194
+ ) => {
195
+ if (!mountedRef.current) return;
196
+ const id = agentMessageId(assistantId, agent.id);
197
+ setMessages((previous) => {
198
+ const open = previous.find((message) => message.id === id);
199
+ let updated: ChatMessage[];
200
+ if (open && (content === '' || getTextFromContent(open.content) === '')) {
201
+ // Already open: an answer fills it; a second call to the same agent changes nothing.
202
+ if (content === '') return previous;
203
+ // The answer carries the look the server has now: it replaces the one the call guessed.
204
+ updated = previous.map((message) =>
205
+ message === open
206
+ ? {
207
+ ...open,
208
+ content,
209
+ agentInfo: {
210
+ id: agent.id,
211
+ name: agent.name,
212
+ character: agent.character ?? open.agentInfo?.character,
213
+ },
214
+ }
215
+ : message,
216
+ );
217
+ } else {
218
+ const message: ChatMessage = {
219
+ id: open ? `${id}-${previous.length}` : id,
220
+ role: 'assistant',
221
+ content,
222
+ createdAt: Date.now(),
223
+ agentInfo: { id: agent.id, name: agent.name, character: agent.character },
224
+ };
225
+ const at = previous.findIndex((candidate) => candidate.id === assistantId);
226
+ updated = [...previous];
227
+ updated.splice(at < 0 ? updated.length : at, 0, message);
228
+ }
229
+ messagesRef.current = updated;
230
+ return updated;
231
+ });
232
+ },
233
+ [],
234
+ );
235
+
236
+ /** A turn that ends with an agent never answering leaves no empty message of the agent's. */
237
+ const removeUnansweredAgents = useCallback((assistantId: string) => {
238
+ if (!mountedRef.current) return;
239
+ setMessages((previous) => {
240
+ const updated = previous.filter(
241
+ (message) =>
242
+ !(
243
+ message.agentInfo !== undefined &&
244
+ message.id.startsWith(`${assistantId}-agent-`) &&
245
+ getTextFromContent(message.content).trim() === ''
246
+ ),
247
+ );
248
+ if (updated.length === previous.length) return previous;
249
+ messagesRef.current = updated;
250
+ return updated;
251
+ });
252
+ }, []);
253
+
171
254
  const flushPendingUpdates = useCallback(
172
255
  (pending: PendingUpdates) => {
173
256
  const content = pending.content;
@@ -310,6 +393,7 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
310
393
  content: '',
311
394
  reasoning: '',
312
395
  tools: [],
396
+ agentAnswers: 0,
313
397
  timer: null,
314
398
  };
315
399
  pendingRef.current = pending;
@@ -339,6 +423,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
339
423
  if (index < 0) pending.tools = [...pending.tools, invocation];
340
424
  else pending.tools[index] = invocation;
341
425
  flushPendingUpdates(pending);
426
+ // A call to an agent opens the agent's message at once: empty, thinking, until it answers.
427
+ const agent = delegatedAgent(invocation);
428
+ // The call names the agent; its look is the host's directory's, until the answer brings the server's.
429
+ if (agent)
430
+ insertAgentMessage(
431
+ pending.assistantId,
432
+ { ...agent, character: directoryRef.current.get(agent.id)?.character },
433
+ '',
434
+ );
342
435
  return;
343
436
  }
344
437
  case 'tool_result': {
@@ -373,11 +466,14 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
373
466
  },
374
467
  }));
375
468
  return;
376
- case 'agent_answer':
377
- pending.content += event.content;
378
- updateAssistant(pending.assistantId, () => ({ agentInfo: event.agent }));
379
- scheduleFlush(pending);
469
+ case 'agent_answer': {
470
+ // A delegated agent speaks as itself: its answer is its own message, placed before
471
+ // Alia's reply, as the Alia app places it. The call that sent for it already opened
472
+ // the message; an answer with no call opens it. Several agents are several messages.
473
+ pending.agentAnswers++;
474
+ insertAgentMessage(pending.assistantId, event.agent, event.content);
380
475
  return;
476
+ }
381
477
  }
382
478
  };
383
479
 
@@ -409,6 +505,9 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
409
505
  } finally {
410
506
  cancelPendingTimer(pending);
411
507
  flushPendingUpdates(pending);
508
+ removeUnansweredAgents(pending.assistantId);
509
+ // Alia said nothing herself after handing the turn over: no empty reply under the answers.
510
+ if (pending.agentAnswers > 0) removeEmptyAssistant(pending.assistantId);
412
511
  linked.dispose();
413
512
  if (pendingRef.current === pending) pendingRef.current = null;
414
513
  if (activeRequestRef.current?.key === request.key) {
@@ -427,6 +526,8 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
427
526
  oxyServices,
428
527
  requestUrl,
429
528
  removeEmptyAssistant,
529
+ removeUnansweredAgents,
530
+ insertAgentMessage,
430
531
  scheduleFlush,
431
532
  updateAssistant,
432
533
  ],
@@ -439,5 +540,23 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
439
540
  setError(null);
440
541
  }, [stop]);
441
542
 
442
- return { messages, setMessages, send, isStreaming, stop, clear, error };
543
+ const decidePlan = useCallback((planId: string, decision: 'approved' | 'rejected') => {
544
+ setMessages((prev) =>
545
+ prev.map((message) =>
546
+ message.pendingPlan?.planId === planId
547
+ ? { ...message, pendingPlan: { ...message.pendingPlan, [decision]: true } }
548
+ : message,
549
+ ),
550
+ );
551
+ }, []);
552
+ const approvePlan = useCallback((planId: string) => decidePlan(planId, 'approved'), [decidePlan]);
553
+ const rejectPlan = useCallback(
554
+ (planId: string) => {
555
+ decidePlan(planId, 'rejected');
556
+ stop();
557
+ },
558
+ [decidePlan, stop],
559
+ );
560
+
561
+ return { messages, setMessages, send, isStreaming, stop, clear, approvePlan, rejectPlan, error };
443
562
  }
@@ -0,0 +1,84 @@
1
+ import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import type { NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native';
3
+ import {
4
+ type Completion,
5
+ SEARCH_DEBOUNCE_MS,
6
+ SEARCH_FROM,
7
+ composerCompletions,
8
+ } from '../lib/suggestions';
9
+ import type { AliaSuggestion } from '../types';
10
+
11
+ /**
12
+ * The draft, once typing has settled, for the host to search with; empty while the draft is
13
+ * too short to search or suggestions are off. The data stays the host's: the Alia app asks its
14
+ * API through react-query, a host of the SDK chat asks whatever it has.
15
+ */
16
+ export function useSuggestionQuery(draft: string, enabled: boolean): string {
17
+ const [query, setQuery] = useState('');
18
+ useEffect(() => {
19
+ const next = enabled ? draft.trim() : '';
20
+ if (next.length < SEARCH_FROM) {
21
+ setQuery('');
22
+ return;
23
+ }
24
+ const timer = setTimeout(() => setQuery(next), SEARCH_DEBOUNCE_MS);
25
+ return () => clearTimeout(timer);
26
+ }, [draft, enabled]);
27
+ return query;
28
+ }
29
+
30
+ /**
31
+ * The suggestions over a composer: the rows for the draft, which one the keyboard is on, and
32
+ * the composer's own `onKeyPress` (arrows move, Enter picks the selected row, Escape closes).
33
+ * Enter is left to the composer while no row is selected.
34
+ */
35
+ export function useComposerSuggestions<T extends AliaSuggestion>({
36
+ draft,
37
+ enabled,
38
+ welcome,
39
+ matches,
40
+ onPick,
41
+ }: {
42
+ draft: string;
43
+ /** Only a fresh conversation offers suggestions. */
44
+ enabled: boolean;
45
+ welcome: readonly T[] | undefined;
46
+ matches: readonly T[] | undefined;
47
+ onPick: (suggestion: T) => void;
48
+ }) {
49
+ const [selected, setSelected] = useState(-1);
50
+ const [dismissed, setDismissed] = useState(false);
51
+
52
+ // A new draft reopens the list and clears the keyboard selection.
53
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the draft is the trigger
54
+ useEffect(() => {
55
+ setDismissed(false);
56
+ setSelected(-1);
57
+ }, [draft]);
58
+
59
+ const completions: Completion<T>[] = useMemo(
60
+ () => (enabled && !dismissed ? composerCompletions(draft, welcome, matches) : []),
61
+ [enabled, dismissed, draft, welcome, matches],
62
+ );
63
+
64
+ const onKeyPress = useCallback(
65
+ (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
66
+ if (completions.length === 0) return;
67
+ const key = event.nativeEvent.key;
68
+ if (key === 'ArrowDown' || key === 'ArrowUp') {
69
+ event.preventDefault();
70
+ const step = key === 'ArrowDown' ? 1 : -1;
71
+ setSelected((current) => (current + step + completions.length) % completions.length);
72
+ } else if (key === 'Enter' && selected >= 0) {
73
+ event.preventDefault();
74
+ onPick(completions[selected]!.suggestion);
75
+ } else if (key === 'Escape') {
76
+ event.preventDefault();
77
+ setDismissed(true);
78
+ }
79
+ },
80
+ [completions, selected, onPick],
81
+ );
82
+
83
+ return { completions, selected, onKeyPress };
84
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // Reusable chat UI, voice, and streaming components for Oxy apps
4
4
 
5
5
  // ── Main components ──
6
+ export type { AliaChatAgent, AliaChatState } from './components/AliaChatContent';
6
7
  export { AliaChatSheet } from './components/AliaChatSheet';
7
8
  export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
8
9
  export { AliaChatScreen } from './components/AliaChatScreen';
@@ -14,7 +15,8 @@ export type { AliaChatLabels } from './lib/labels';
14
15
  export { VoiceModeIcon } from './components/VoiceModeIcon';
15
16
  export { AmbientField, PARALLAX_DURATION, PARALLAX_EASE } from './components/AmbientField';
16
17
  export type { AmbientFieldProps } from './components/AmbientField';
17
- export { AliaAvatar } from './components/AliaAvatar';
18
+ export { ALIA_AVATAR, AliaAvatar } from './components/AliaAvatar';
19
+ export { DEFAULT_AGENT_CHARACTER, readAgentCharacter } from './lib/agent-character';
18
20
  export type { AliaAvatarProps, AliaAvatarState } from './components/AliaAvatar';
19
21
  export { IdentityMark } from './components/IdentityMark';
20
22
  export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
@@ -22,7 +24,11 @@ export { AliaMarkdown } from './components/Markdown';
22
24
  export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
23
25
  export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
24
26
  export { PlanPreviewCard } from './components/PlanPreviewCard';
25
- export { QuestionCard } from './components/QuestionCard';
27
+ export { AskedQuestions, QuestionCard } from './components/QuestionCard';
28
+ export { ComposerSuggestions } from './components/ComposerSuggestions';
29
+ export { useComposerSuggestions, useSuggestionQuery } from './hooks/useComposerSuggestions';
30
+ export { composerCompletions } from './lib/suggestions';
31
+ export type { Completion } from './lib/suggestions';
26
32
  export {
27
33
  ASK_USER_QUESTION_TOOL,
28
34
  askedQuestions,
@@ -61,7 +67,7 @@ export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from
61
67
  export type {
62
68
  ChatMessage,
63
69
  ToolInvocation,
64
- WelcomeSuggestion,
70
+ AliaSuggestion,
65
71
  ResearchProgress,
66
72
  ResearchSource,
67
73
  PendingPlan,
@@ -83,13 +89,7 @@ export { getTextFromContent, getImagesFromContent } from './lib/content-utils';
83
89
  // ── The turn's work log: tool calls as Bloom's `WebSearch` and `TaskList` ──
84
90
  export { isWebInvocation, taskListLog, webSearchLog } from './lib/work-log';
85
91
  export type { LiveResearch, TaskListLog, WebSearchLog, WorkLogOptions } from './lib/work-log';
86
- export { englishWorkLog } from './lib/work-log-copy';
87
- export type { Translate } from './lib/work-log-copy';
88
- export { getToolDoneLabel, getToolPillLabel } from './lib/task-labels';
89
- export { extractCitationSources } from './lib/citations';
90
- export type { CitationSource } from './lib/citations';
91
- export { toolCallStatus } from './lib/tool-call-status';
92
- export type { ToolCallStatus } from './lib/tool-call-status';
92
+ // The pure parts (call status, citations, tool labels, `Translate`) are `@alia.onl/sdk/tools`.
93
93
 
94
94
  // ── Picker hooks ──
95
95
  export { useImagePicker } from './hooks/useImagePicker';
@@ -0,0 +1,28 @@
1
+ import { parseAvatarCharacter, type AvatarCharacter } from '@oxy.so/bloom/agent-avatar';
2
+
3
+ /**
4
+ * The look of an agent whose own is not known: a server that does not send one yet, an agent that
5
+ * has since been deleted, a stored character this Bloom cannot draw. One neutral character, the same
6
+ * everywhere, so an unknown look is recognisably "not chosen" and never one that belongs to somebody.
7
+ */
8
+ export const DEFAULT_AGENT_CHARACTER: AvatarCharacter = {
9
+ shape: 'rounded_cube',
10
+ eyes: 'oval',
11
+ eyewear: 'none',
12
+ accessory: 'none',
13
+ color: 'violet',
14
+ };
15
+
16
+ /**
17
+ * The character an agent is drawn as, from the JSON the server stores for it
18
+ * (`agents.character`): Bloom's own parser decides what it can draw, and anything else is the
19
+ * default look rather than an avatar that fails to render.
20
+ */
21
+ export function readAgentCharacter(value: unknown): AvatarCharacter {
22
+ if (value === null || value === undefined) return DEFAULT_AGENT_CHARACTER;
23
+ try {
24
+ return parseAvatarCharacter(value);
25
+ } catch {
26
+ return DEFAULT_AGENT_CHARACTER;
27
+ }
28
+ }