@alia.onl/sdk 20.1.0 → 21.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,78 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 21.1.0
4
+
5
+ A shared conversation has one address, Alia's, whichever app minted it (`docs/agent-embedding.md`
6
+ §6).
7
+
8
+ ### Added
9
+
10
+ - **`url` on `AliaConversationShare`**: `useShareConversation().share` (and a host's `onShare`)
11
+ gets the link to hand on, `https://alia.onl/shared/<token>`, rather than a token to build its own
12
+ page around.
13
+ - **`aliaSharedConversationUrl(token)`**: the same link, for an app that redirects the share links
14
+ it minted before to Alia's page.
15
+
16
+ ## 21.0.0
17
+
18
+ A conversation records the Oxy app it started in, so an embedding app lists only its own while
19
+ the Alia app lists all of them (`docs/agent-embedding.md` §6). The app is named once, on the chat.
20
+
21
+ ### Added
22
+
23
+ - **`app` on `AliaChatContent`, `AliaChatSheet`, `AliaChatScreen`, `AliaAskProvider` and
24
+ `AliaRuntimeProvider`**: the embedding app's slug (`homiio`, `mercaria`). Every turn carries it
25
+ as `host.app` — with an empty tool list when the chat has no host tools — and
26
+ `POST /v1/conversations/new` and `POST /v1/conversations` carry it as `app`, so a conversation
27
+ the chat starts is labelled with it. Omitted, the chat is Alia's own and labels nothing.
28
+ - **`useConversations({ app })`**: only the conversations started in that app
29
+ (`GET /v1/conversations?app=`), composable with `agentId`. The chat's `app` does not imply it:
30
+ the app that wants only its own asks for them.
31
+ - **`Conversation.app`**: the app a conversation started in, or `null`.
32
+ - **The sheet's recent conversations are the app's.** In a sheet with an `app`, the recents under
33
+ the greeting (20.1.0) are the ones started in that app; Alia's own sheet still lists any.
34
+
35
+ ### Changed (breaking)
36
+
37
+ - **`AliaHostTools` has no `app`.** Host tools are the chat's `app`'s, which a chat with
38
+ `hostTools` must name; one without throws on render. Move `hostTools.app` to the chat's `app`.
39
+ - **`hostRequest(app, tools, outcomes)`** and **`HostOutcomeQueue.dispatch(action, tools, app)`**
40
+ take the app.
41
+ - **`aliaQueryKeys.conversations.withAgent(id)` is `filtered({ agentId, app })`.**
42
+
43
+ ## 20.3.0
44
+
45
+ A host hears when a turn has stopped streaming.
46
+
47
+ ### Added
48
+
49
+ - **`AliaRuntimeHost.onTurnEnd`**, the pair of `onTurnStart`: called exactly once for each turn
50
+ the runtime sends, once it stops streaming for any reason — finished, stopped (Stop) or failed —
51
+ and only while the conversation that sent it is still mounted, so a surface that left mid-turn
52
+ is never called back. A host that holds back what would take the chat off the screen runs it
53
+ here, instead of watching `useConversationStore().streamingConversationId`.
54
+
55
+ ## 20.2.0
56
+
57
+ An app mounts its one sheet at the root without the chat in its entry bundle.
58
+
59
+ ### Added
60
+
61
+ - **`@alia.onl/sdk/ask`**, a leaf entry with `AliaAskProvider` and `useAskAgent` alone. The root
62
+ entry still exports both, but brings the whole chat with them.
63
+
64
+ ### Changed
65
+
66
+ - **`AliaAskProvider` loads its sheet lazily** (`components/AliaAskSheet.tsx`, one `import()`) and
67
+ mounts it on the first `ask`. Signed in, the module is fetched in the background, so the first
68
+ `ask` does not wait on the network. `check:entries` holds that `./ask` loads none of the chat
69
+ with the entry.
70
+ - **Ref and event types are React Native 0.88-ready.** A ref to a `ScrollView` or `TextInput` is
71
+ typed `ComponentRef<typeof …>` (0.88 types both as function components), and key presses are
72
+ `TextInputKeyPressEvent` (`TextInputKeyPressEventData` is deprecated since 0.86). Nothing
73
+ changes on 0.86. The peer ranges stay on Expo 57 / React Native 0.86: Bloom's own `TextInput`
74
+ ref props are not 0.88-ready yet.
75
+
3
76
  ## 20.1.0
4
77
 
5
78
  The one sheet is the whole surface an app needs for an agent: it picks a conversation up again and
package/README.md CHANGED
@@ -146,7 +146,7 @@ Every agent — Alia included — is the same agent (ADR 0020), and any Oxy app
146
146
  person can reach. The wire contract is [`docs/agent-embedding.md`](../../docs/agent-embedding.md).
147
147
 
148
148
  ```tsx
149
- import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk';
149
+ import { AliaAskProvider, useAskAgent } from '@alia.onl/sdk/ask';
150
150
 
151
151
  <AliaAskProvider
152
152
  agents={{ alia: {}, sindi: { id: SINDI_AGENT_ID } }} // no id = Alia
@@ -169,6 +169,10 @@ resume('sindi', conversationId); // a stored conversation, where the person left
169
169
  dismiss(); // e.g. after a host tool changed the page under the sheet
170
170
  ```
171
171
 
172
+ Mount it from `@alia.onl/sdk/ask`: that entry is the provider and its hook alone, and the sheet
173
+ loads with the first `ask` (fetched in the background once the person is signed in), so the chat
174
+ stays out of the app's entry bundle. The root entry exports the same two, with the whole chat.
175
+
172
176
  The sheet offers the person's latest conversations with its lead under the greeting
173
177
  (`recentConversations={false}` turns it off). The code that configures the sheet sits above it, so
174
178
  `AliaAskProvider` forwards a ref to the same `AliaAsk` for it.
@@ -337,6 +341,9 @@ const host: AliaRuntimeHost = {
337
341
  showThought: (opening) => (opening === 'automatic' && panel.isOpen ? false : panel.show('thought')),
338
342
  openAutomation: (id) => router.push(`/automations/${id}`), // a scheduled-task card is a link
339
343
  faviconUrl: (url) => favicons.of(url), // the work log's source icons
344
+ // the edges of a turn this runtime sends
345
+ onTurnStart: () => executor.beginTurn(),
346
+ onTurnEnd: () => executor.runHeldBack(), // finished, stopped or failed: once per turn
340
347
  };
341
348
 
342
349
  <AliaRuntimeProvider
@@ -350,6 +357,12 @@ const host: AliaRuntimeHost = {
350
357
  </AliaRuntimeProvider>;
351
358
  ```
352
359
 
360
+ `onTurnEnd` is called exactly once for each turn the runtime sends, when it stops streaming —
361
+ the answer was written, the person pressed Stop, or the turn failed — and only while the
362
+ conversation that sent it is still mounted. A host that holds back what would take the chat off
363
+ the screen (a host tool that navigates away) runs it there; a surface that left mid-turn is never
364
+ called back, so what it held back goes with it.
365
+
353
366
  The sheet and the Alia app differ ONLY in what they put there:
354
367
 
355
368
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "20.1.0",
3
+ "version": "21.1.0",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -8,6 +8,7 @@
8
8
  "types": "src/index.ts",
9
9
  "exports": {
10
10
  ".": "./src/index.ts",
11
+ "./ask": "./src/ask.ts",
11
12
  "./avatar": "./src/avatar.ts",
12
13
  "./content": "./src/lib/content-utils.ts",
13
14
  "./csp": "./src/csp.ts",
package/src/ask.ts ADDED
@@ -0,0 +1,7 @@
1
+ // @alia.onl/sdk/ask — the app's one Alia sheet, without the chat in the entry bundle
2
+ // `AliaAskProvider` and `useAskAgent` alone: the sheet they open is loaded with the first `ask`.
3
+ // An app mounts the provider at its root from here rather than from the root entry, which
4
+ // would bring the whole chat along with it.
5
+
6
+ export { AliaAskProvider, useAskAgent } from './components/AliaAskProvider';
7
+ export type { AliaAsk, AliaAskAgents, AliaAskProviderProps } from './components/AliaAskProvider';
@@ -5,13 +5,20 @@
5
5
  * once — its host tools, renderers, words, voice, context, upsell and share. A button calls
6
6
  * `useAskAgent().ask(key, turn)`. Signed out, the sheet is not mounted (it could not call Alia):
7
7
  * `ask` opens Oxy's sign-in instead, and the person asks again once signed in.
8
+ *
9
+ * The sheet itself (`AliaAskSheet`) is loaded lazily and mounted on the first `ask`, so an app
10
+ * that mounts this provider at its root — imported from `@alia.onl/sdk/ask` — keeps the chat out
11
+ * of its entry bundle. Signed in, its module is fetched in the background so the first `ask`
12
+ * does not wait on the network.
8
13
  */
9
14
 
10
15
  import { useAuth } from '@oxy.so/services/ui/client';
11
16
  import {
12
17
  createContext,
13
18
  forwardRef,
19
+ lazy,
14
20
  type ReactNode,
21
+ Suspense,
15
22
  useCallback,
16
23
  useContext,
17
24
  useEffect,
@@ -21,7 +28,11 @@ import {
21
28
  useState,
22
29
  } from 'react';
23
30
  import type { AliaTurn } from '../hooks/useChatComposer';
24
- import { AliaChatSheet, type AliaChatSheetProps, type AliaChatSheetRef } from './AliaChatSheet';
31
+ import type { AliaChatSheetProps, AliaChatSheetRef } from './AliaChatSheet';
32
+ import type { AliaAskOpening } from './AliaAskSheet';
33
+
34
+ const loadSheet = () => import('./AliaAskSheet');
35
+ const LazyAskSheet = lazy(() => loadSheet().then((module) => ({ default: module.AliaAskSheet })));
25
36
 
26
37
  /** The agents an app offers, by its own key. An entry without `id` is Alia. */
27
38
  export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
@@ -29,6 +40,7 @@ export type AliaAskAgents = Readonly<Record<string, { id?: string }>>;
29
40
  export interface AliaAskProviderProps
30
41
  extends Pick<
31
42
  AliaChatSheetProps,
43
+ | 'app'
32
44
  | 'hostTools'
33
45
  | 'toolRenderers'
34
46
  | 'labels'
@@ -73,14 +85,9 @@ export function useAskAgent(): AliaAsk {
73
85
  return ask;
74
86
  }
75
87
 
76
- /** Which agent the sheet is on, and the opening that waits for that sheet to mount. */
77
- interface Opening {
88
+ /** The opening that waits for the sheet of agent `key` to be the one mounted. */
89
+ interface Opening extends AliaAskOpening {
78
90
  key: string | undefined;
79
- turn: AliaTurn | undefined;
80
- /** A stored conversation to open instead of the welcome. */
81
- conversationId?: string;
82
- /** Changes on every `ask`, so asking the same agent twice opens twice. */
83
- serial: number;
84
91
  }
85
92
 
86
93
  /**
@@ -95,6 +102,8 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
95
102
  const sheetRef = useRef<AliaChatSheetRef>(null);
96
103
  const [opening, setOpening] = useState<Opening | null>(null);
97
104
  const [current, setCurrent] = useState<string | undefined>(undefined);
105
+ /** Nothing is mounted before the first `ask`. */
106
+ const [asked, setAsked] = useState(false);
98
107
  const agentsRef = useRef(agents);
99
108
  agentsRef.current = agents;
100
109
 
@@ -107,6 +116,7 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
107
116
  void signIn().catch(() => undefined);
108
117
  return;
109
118
  }
119
+ setAsked(true);
110
120
  setCurrent(agentKey);
111
121
  setOpening((previous) => ({
112
122
  key: agentKey,
@@ -125,13 +135,12 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
125
135
  );
126
136
  const dismiss = useCallback(() => sheetRef.current?.dismiss(), []);
127
137
 
128
- // Presented once the sheet for that agent is the one mounted (a different agent is a new sheet).
138
+ const opened = useCallback(() => setOpening(null), []);
139
+
140
+ // Signed in, the sheet's module comes down in the background, ahead of the first `ask`.
129
141
  useEffect(() => {
130
- if (opening === null || opening.key !== current) return;
131
- if (opening.conversationId !== undefined) sheetRef.current?.resume(opening.conversationId);
132
- else sheetRef.current?.present(opening.turn);
133
- setOpening(null);
134
- }, [opening, current]);
142
+ if (canUsePrivateApi) void loadSheet().catch(() => undefined);
143
+ }, [canUsePrivateApi]);
135
144
 
136
145
  const agentId = current === undefined ? undefined : agents?.[current]?.id;
137
146
  const agent = useMemo(() => (agentId === undefined ? undefined : { id: agentId }), [agentId]);
@@ -141,14 +150,19 @@ export const AliaAskProvider = forwardRef<AliaAsk, AliaAskProviderProps>(functio
141
150
  return (
142
151
  <AskContext.Provider value={value}>
143
152
  {children}
144
- {canUsePrivateApi ? (
145
- <AliaChatSheet
146
- key={agentId ?? 'alia'}
147
- ref={sheetRef}
148
- {...sheet}
149
- agent={agent}
150
- welcomeSuggestions={agent === undefined ? welcomeSuggestions : undefined}
151
- />
153
+ {canUsePrivateApi && asked ? (
154
+ <Suspense fallback={null}>
155
+ <LazyAskSheet
156
+ key={agentId ?? 'alia'}
157
+ ref={sheetRef}
158
+ {...sheet}
159
+ agent={agent}
160
+ welcomeSuggestions={agent === undefined ? welcomeSuggestions : undefined}
161
+ // A different agent is a new sheet: the opening waits for that one to mount.
162
+ opening={opening !== null && opening.key === current ? opening : null}
163
+ onOpened={opened}
164
+ />
165
+ </Suspense>
152
166
  ) : null}
153
167
  </AskContext.Provider>
154
168
  );
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The sheet `AliaAskProvider` mounts, in its own module so the provider can load it lazily: an app
3
+ * that mounts the provider at its root carries only the provider in its entry bundle, and the
4
+ * chat — thread, composer, voice, renderers — arrives with the first `ask`.
5
+ *
6
+ * It presents the opening it is handed once it is mounted, which is the only moment a lazily
7
+ * loaded sheet can be presented, and tells the provider the opening is spent.
8
+ */
9
+
10
+ import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
11
+ import type { AliaTurn } from '../hooks/useChatComposer';
12
+ import { AliaChatSheet, type AliaChatSheetProps, type AliaChatSheetRef } from './AliaChatSheet';
13
+
14
+ /** What the person asked for: a turn to send, or a stored conversation to pick up again. */
15
+ export interface AliaAskOpening {
16
+ turn: AliaTurn | undefined;
17
+ conversationId?: string;
18
+ /** Changes on every `ask`, so asking the same agent twice opens twice. */
19
+ serial: number;
20
+ }
21
+
22
+ export interface AliaAskSheetProps extends AliaChatSheetProps {
23
+ /** The opening to present once mounted; `null` when there is none waiting. */
24
+ opening: AliaAskOpening | null;
25
+ /** The opening was presented. */
26
+ onOpened: () => void;
27
+ }
28
+
29
+ export const AliaAskSheet = forwardRef<AliaChatSheetRef, AliaAskSheetProps>(function AliaAskSheet(
30
+ { opening, onOpened, ...sheet },
31
+ ref,
32
+ ) {
33
+ const sheetRef = useRef<AliaChatSheetRef>(null);
34
+ useImperativeHandle(
35
+ ref,
36
+ () => ({
37
+ present: (turn?: AliaTurn) => sheetRef.current?.present(turn),
38
+ resume: (conversationId: string) => sheetRef.current?.resume(conversationId),
39
+ dismiss: () => sheetRef.current?.dismiss(),
40
+ }),
41
+ [],
42
+ );
43
+
44
+ useEffect(() => {
45
+ if (opening === null) return;
46
+ if (opening.conversationId !== undefined) sheetRef.current?.resume(opening.conversationId);
47
+ else sheetRef.current?.present(opening.turn);
48
+ onOpened();
49
+ }, [opening, onOpened]);
50
+
51
+ return <AliaChatSheet ref={sheetRef} {...sheet} />;
52
+ });
@@ -50,7 +50,7 @@ import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/l
50
50
  import { type DeviceModel, buildPowerLevelOptions } from '../lib/power-level-options';
51
51
  import { ALIA_FEATURES, featureOffer, useAgentFeatures, useAgentProfile } from '../runtime/agents';
52
52
  import { type AliaRuntimeHost, AliaRuntimeProvider, useAliaRuntime } from '../runtime/config';
53
- import type { AliaHostTools } from '../runtime/host-tools';
53
+ import { type AliaHostTools, APP_SLUG } from '../runtime/host-tools';
54
54
  import { useCreateConversation } from '../runtime/conversations';
55
55
  import { aliaQueryKeys } from '../runtime/query-keys';
56
56
  import type { Message } from '../runtime/types';
@@ -137,12 +137,20 @@ export interface AliaChatContentProps {
137
137
  */
138
138
  ephemeral?: boolean;
139
139
  apiUrl?: string;
140
+ /**
141
+ * The Oxy app this chat is embedded in, by its slug (`homiio`, `mercaria`). A conversation the
142
+ * chat starts is labelled with it, so the app can list only its own
143
+ * (`useConversations({ app })`), and its `hostTools` are this app's. Omitted, the chat is
144
+ * Alia's own (`docs/agent-embedding.md` §6).
145
+ */
146
+ app?: string;
140
147
  /** App context injected into the system prompt. */
141
148
  clientContext?: string;
142
149
  /**
143
150
  * 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.
151
+ * with every turn under the chat's `app`, which a chat with host tools must name; an action the
152
+ * stream asks for is performed once, by `execute`, and its outcome reaches the model with the
153
+ * next turn.
146
154
  */
147
155
  hostTools?: AliaHostTools;
148
156
  /** The app's own drawing of a catalogue tool's result, keyed `<app>:<tool>`, before the generic card. */
@@ -715,12 +723,24 @@ export function AliaChatContent({
715
723
  onConversationChange,
716
724
  runtime,
717
725
  apiUrl,
726
+ app,
718
727
  toolRenderers,
719
728
  onFeatureLocked,
720
729
  ...props
721
730
  }: AliaChatContentProps) {
722
731
  const [ownId, setOwnId] = useState<string | undefined>(undefined);
723
732
  const parent = useAliaRuntime();
733
+ const chatApp = app ?? parent.app;
734
+ if (chatApp !== undefined && !APP_SLUG.test(chatApp)) {
735
+ throw new Error(
736
+ `AliaChatContent: app "${chatApp}" is not an app slug (^[a-z][a-z0-9-]{0,31}$)`,
737
+ );
738
+ }
739
+ if (props.hostTools !== undefined && chatApp === undefined) {
740
+ throw new Error(
741
+ "AliaChatContent: hostTools are an app's, so a chat with hostTools names its app",
742
+ );
743
+ }
724
744
  // Who decides which conversation is open: a host that navigates (a route) does; with none, the
725
745
  // chat does (the sheet). Either way the host hears of it first, then `onConversationChange`.
726
746
  const hostOpens = runtime?.openConversation ?? parent.host.openConversation;
@@ -746,7 +766,7 @@ export function AliaChatContent({
746
766
 
747
767
  return (
748
768
  <AliaChatLabelsProvider labels={labels}>
749
- <AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host}>
769
+ <AliaRuntimeProvider apiUrl={apiUrl ?? parent.apiUrl} host={host} app={chatApp}>
750
770
  <AliaToolRenderersProvider value={toolRenderers ?? NO_RENDERERS}>
751
771
  <AliaConversationView {...props} conversationId={controlledId ?? ownId} />
752
772
  </AliaToolRenderersProvider>
@@ -42,6 +42,8 @@ export interface AliaChatScreenProps {
42
42
  agent?: AliaChatAgentRef;
43
43
  /** App context injected into system prompt */
44
44
  clientContext?: string;
45
+ /** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
46
+ app?: string;
45
47
  /** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
46
48
  hostTools?: AliaHostTools;
47
49
  /** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
@@ -87,6 +89,7 @@ export interface AliaChatScreenProps {
87
89
 
88
90
  export function AliaChatScreen({
89
91
  agent,
92
+ app,
90
93
  clientContext,
91
94
  hostTools,
92
95
  toolRenderers,
@@ -147,6 +150,7 @@ export function AliaChatScreen({
147
150
  <KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
148
151
  <AliaChatContent
149
152
  agent={agent}
153
+ app={app}
150
154
  clientContext={clientContext}
151
155
  hostTools={hostTools}
152
156
  toolRenderers={toolRenderers}
@@ -54,6 +54,8 @@ export interface AliaChatSheetProps {
54
54
  agent?: AliaChatAgentRef;
55
55
  /** App context injected into system prompt */
56
56
  clientContext?: string;
57
+ /** The Oxy app this chat is embedded in, by its slug (see `AliaChatContentProps.app`). */
58
+ app?: string;
57
59
  /** The UI effects this app performs for the lead (see `AliaChatContentProps.hostTools`). */
58
60
  hostTools?: AliaHostTools;
59
61
  /** The app's own drawing of catalogue tools' results, keyed `<app>:<tool>`. */
@@ -110,6 +112,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
110
112
  (
111
113
  {
112
114
  agent,
115
+ app,
113
116
  clientContext,
114
117
  hostTools,
115
118
  toolRenderers,
@@ -268,6 +271,7 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
268
271
  flights,
269
272
  layout: 'sheet' as const,
270
273
  agent,
274
+ app,
271
275
  clientContext,
272
276
  hostTools,
273
277
  toolRenderers,
@@ -7,12 +7,7 @@ import {
7
7
  type ReactNode,
8
8
  type RefObject,
9
9
  } from 'react';
10
- import {
11
- View,
12
- type NativeSyntheticEvent,
13
- type TextInput,
14
- type TextInputKeyPressEventData,
15
- } from 'react-native';
10
+ import { View, type TextInputKeyPressEvent } from 'react-native';
16
11
  import {
17
12
  ComposerPill,
18
13
  type ComposerPanelAddMenuGroup,
@@ -28,6 +23,7 @@ import type { Completion } from '../lib/suggestions';
28
23
  import { composerTiles, intakeError } from '../lib/attachment-tiles';
29
24
  import type { IntakeKind } from '../lib/attachment-intake';
30
25
  import { KeyboardAvoidingView } from '../lib/keyboard';
26
+ import type { TextInputHandle } from '../lib/text-input';
31
27
  import { type AliaChatLabels, AliaChatLabelsProvider, useLabels } from '../lib/labels';
32
28
  import type { AskedQuestion } from '../lib/questions';
33
29
  import type { VoiceErrorCode } from '../lib/speech-messages';
@@ -116,11 +112,11 @@ export interface AliaComposerProps<T extends AliaSuggestion = AliaSuggestion> {
116
112
  */
117
113
  accessory?: ReactNode;
118
114
  /** The field's keys, before the panel's own Enter rule (suggestions over it). */
119
- onKeyPress?: (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => void;
115
+ onKeyPress?: (event: TextInputKeyPressEvent) => void;
120
116
  /** The host already avoids the keyboard. */
121
117
  disableKeyboardAvoidance?: boolean;
122
118
  /** The field itself, for a host that moves focus into it. */
123
- inputRef?: RefObject<TextInput | null>;
119
+ inputRef?: RefObject<TextInputHandle | null>;
124
120
  /** Dictation's language, a BCP-47 tag; omitted, the platform's own. */
125
121
  speechLanguage?: string;
126
122
  /** Alia's API, which transcribes dictation; omitted, `EXPO_PUBLIC_ALIA_API_URL` or production. */
@@ -3,6 +3,7 @@ import { Text } from '@oxy.so/bloom/typography';
3
3
  import { useMemo } from 'react';
4
4
  import { View } from 'react-native';
5
5
  import { useLabels } from '../lib/labels';
6
+ import { useAliaRuntime } from '../runtime/config';
6
7
  import { useConversations } from '../runtime/conversations';
7
8
 
8
9
  /** How many of the lead's conversations the sheet offers to pick up again. */
@@ -11,7 +12,9 @@ export const RECENT_CONVERSATIONS_SHOWN = 3;
11
12
  /**
12
13
  * The person's latest conversations with the sheet's lead, under its greeting: "Ask Sindi" picks
13
14
  * up where it left off instead of every opening being a blank page. Alia's own sheet lists the
14
- * person's latest conversations of any kind. Nothing while loading or when there are none.
15
+ * person's latest conversations of any kind. In an app's sheet (the chat's `app`) they are the ones
16
+ * started in that app: Mercaria's sheet offers Mercaria's, never a Homiio chat. Nothing while
17
+ * loading or when there are none.
15
18
  */
16
19
  export function AliaRecentConversations({
17
20
  agentId,
@@ -22,7 +25,11 @@ export function AliaRecentConversations({
22
25
  onOpen: (conversationId: string) => void;
23
26
  }) {
24
27
  const labels = useLabels();
25
- const { data } = useConversations(agentId === undefined ? {} : { agentId });
28
+ const { app } = useAliaRuntime();
29
+ const { data } = useConversations({
30
+ ...(agentId === undefined ? {} : { agentId }),
31
+ ...(app === undefined ? {} : { app }),
32
+ });
26
33
  const recent = useMemo(
27
34
  () => data?.pages[0]?.conversations.slice(0, RECENT_CONVERSATIONS_SHOWN) ?? [],
28
35
  [data],
@@ -1,10 +1,11 @@
1
1
  import type { AvatarFlights } from '@oxy.so/bloom/avatar-flight';
2
2
  import { useOxy } from '@oxy.so/services/ui/client';
3
3
  import { useCallback, useEffect, useMemo, useRef } from 'react';
4
- import type { NativeSyntheticEvent, TextInput, TextInputKeyPressEventData } from 'react-native';
4
+ import type { TextInputKeyPressEvent } from 'react-native';
5
5
  import type { SharedValue } from 'react-native-reanimated';
6
6
  import { leadToProfile } from '../lib/flights';
7
7
  import type { AliaLead } from '../lib/participants';
8
+ import type { TextInputHandle } from '../lib/text-input';
8
9
  import { imageUriToDataUrl } from '../lib/image-data-url';
9
10
  import {
10
11
  useComposerDraft,
@@ -254,7 +255,7 @@ export function useChatComposer({
254
255
  });
255
256
 
256
257
  // ── The field: focus, and its keys ──
257
- const inputRef = useRef<TextInput | null>(null);
258
+ const inputRef = useRef<TextInputHandle | null>(null);
258
259
  useFocusWhenPresented(presentedProgress, () => inputRef.current?.focus());
259
260
  // Starting an edit puts the caret in the composer that now holds the question: the Edit press
260
261
  // left focus on the row's button, so typing went nowhere and Escape could not reach the edit.
@@ -265,7 +266,7 @@ export function useChatComposer({
265
266
  const { onKeyPress: onSuggestionKeyPress } = suggestions;
266
267
  const cancelEdit = edit.cancel;
267
268
  const onKeyPress = useCallback(
268
- (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
269
+ (event: TextInputKeyPressEvent) => {
269
270
  // Escape leaves an edit, as its Cancel does: it is the top-most thing open while the
270
271
  // question is being rewritten.
271
272
  if (editing !== undefined && event.nativeEvent.key === 'Escape') {
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useMemo, useState } from 'react';
2
- import type { NativeSyntheticEvent, TextInputKeyPressEventData } from 'react-native';
2
+ import type { TextInputKeyPressEvent } from 'react-native';
3
3
  import {
4
4
  type Completion,
5
5
  SEARCH_DEBOUNCE_MS,
@@ -62,7 +62,7 @@ export function useComposerSuggestions<T extends AliaSuggestion>({
62
62
  );
63
63
 
64
64
  const onKeyPress = useCallback(
65
- (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
65
+ (event: TextInputKeyPressEvent) => {
66
66
  if (completions.length === 0) return;
67
67
  const key = event.nativeEvent.key;
68
68
  if (key === 'ArrowDown' || key === 'ArrowUp') {
@@ -16,11 +16,14 @@ type KeyboardAwareScrollViewProps = ScrollViewProps & {
16
16
  extraKeyboardSpace?: number;
17
17
  };
18
18
 
19
- const KeyboardAwareScrollView = React.forwardRef<ScrollView, KeyboardAwareScrollViewProps>(
20
- ({ bottomOffset, disableScrollOnKeyboardHide, enabled, extraKeyboardSpace, ...props }, ref) => (
21
- <ScrollView ref={ref} {...props} />
22
- ),
23
- );
19
+ // `ComponentRef`, not `ScrollView` itself: React Native 0.88 types `ScrollView` as a function
20
+ // component, so the class is no longer its instance type.
21
+ const KeyboardAwareScrollView = React.forwardRef<
22
+ React.ComponentRef<typeof ScrollView>,
23
+ KeyboardAwareScrollViewProps
24
+ >(({ bottomOffset, disableScrollOnKeyboardHide, enabled, extraKeyboardSpace, ...props }, ref) => (
25
+ <ScrollView ref={ref} {...props} />
26
+ ));
24
27
  KeyboardAwareScrollView.displayName = 'KeyboardAwareScrollView';
25
28
 
26
29
  // `automaticOffset` is keyboard-controller's: measure the view's position in
@@ -13,3 +13,12 @@ export const ALIA_API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api
13
13
  * with CORS open (`packages/app/worker/index.js`).
14
14
  */
15
15
  export const ALIA_WEB_ORIGIN = 'https://alia.onl';
16
+
17
+ /**
18
+ * The one public page of a shared conversation, on Alia's web app, for every agent and every app
19
+ * that embeds one (`docs/agent-embedding.md` §6). An app shares this URL rather than a page of its
20
+ * own, so a link reads the same wherever it was minted.
21
+ */
22
+ export function aliaSharedConversationUrl(token: string): string {
23
+ return `${ALIA_WEB_ORIGIN}/shared/${encodeURIComponent(token)}`;
24
+ }
@@ -0,0 +1,9 @@
1
+ import type { ComponentRef } from 'react';
2
+ import type { TextInput } from 'react-native';
3
+
4
+ /**
5
+ * What a ref to a `TextInput` holds. `ComponentRef`, not `TextInput` itself: React Native 0.88
6
+ * types `TextInput` as a function component, so the class is no longer its instance type, and
7
+ * `focus()` is only on the instance.
8
+ */
9
+ export type TextInputHandle = ComponentRef<typeof TextInput>;
@@ -72,6 +72,12 @@ export interface AliaRuntimeHost {
72
72
  turnRequest?: () => AliaTurnRequest;
73
73
  /** A turn is about to be sent from the composer or a retry. */
74
74
  onTurnStart?: () => void;
75
+ /**
76
+ * A turn this runtime sent has stopped streaming: it finished, was stopped, or failed. Called
77
+ * exactly once per turn, after the stream has settled, and only while the conversation that
78
+ * sent it is still mounted: a surface that left mid-turn is never called back for it.
79
+ */
80
+ onTurnEnd?: () => void;
75
81
  /** Real answer text just arrived (throttled): a haptic tick. */
76
82
  onOutput?: () => void;
77
83
  /** The stream's usage frame: credits left and the spending warning. */
@@ -134,6 +140,14 @@ export interface AliaRuntimeConfig {
134
140
  /** How read-aloud, dictation and calls speak; omitted, the server's and the platform's defaults. */
135
141
  speech: SpeechPreferences;
136
142
  host: AliaRuntimeHost;
143
+ /**
144
+ * The Oxy app the chat is embedded in, by its slug (`^[a-z][a-z0-9-]{0,31}$`): `homiio`,
145
+ * `mercaria`. A conversation it starts is labelled with it, and its host tools are this app's.
146
+ * Omitted, the chat is Alia's own and its conversations carry no app (`docs/agent-embedding.md`
147
+ * §6). A label for listing, never authority: which app a request comes from is proven by the
148
+ * app assertion, not by this.
149
+ */
150
+ app: string | undefined;
137
151
  }
138
152
 
139
153
  export interface AliaRuntimeProviderProps extends Partial<Omit<AliaRuntimeConfig, 'host'>> {
@@ -147,6 +161,7 @@ const DEFAULT_CONFIG: AliaRuntimeConfig = {
147
161
  streamFetch: undefined,
148
162
  speech: {},
149
163
  host: {},
164
+ app: undefined,
150
165
  };
151
166
 
152
167
  const AliaRuntimeContext = createContext<AliaRuntimeConfig>(DEFAULT_CONFIG);
@@ -164,6 +179,7 @@ export function AliaRuntimeProvider({
164
179
  streamFetch,
165
180
  speech,
166
181
  host,
182
+ app,
167
183
  children,
168
184
  }: AliaRuntimeProviderProps) {
169
185
  const parent = useContext(AliaRuntimeContext);
@@ -175,8 +191,9 @@ export function AliaRuntimeProvider({
175
191
  streamFetch: streamFetch ?? parent.streamFetch,
176
192
  speech: speech ?? parent.speech,
177
193
  host: host === undefined ? parent.host : { ...parent.host, ...host },
194
+ app: app ?? parent.app,
178
195
  };
179
- }, [apiUrl, offline, streamFetch, speech, host, parent]);
196
+ }, [apiUrl, offline, streamFetch, speech, host, app, parent]);
180
197
  return <AliaRuntimeContext.Provider value={value}>{children}</AliaRuntimeContext.Provider>;
181
198
  }
182
199
 
@@ -13,7 +13,7 @@ import { type AliaApi, useAliaApi } from './api';
13
13
  import { type OfflineConversationStore, useAliaRuntime } from './config';
14
14
  import { errorMessage as getErrorMessage, errorStatus } from './error-utils';
15
15
  import { normalizeConversationMessages } from './message-history';
16
- import { aliaQueryKeys } from './query-keys';
16
+ import { aliaQueryKeys, type ConversationsFilter } from './query-keys';
17
17
  import { API_ROUTES } from './routes';
18
18
  import { createRandomUuid } from './random-uuid';
19
19
  import type { Conversation, ConversationsInfiniteData, ConversationsPage, Message } from './types';
@@ -96,23 +96,25 @@ export interface ConversationsClient {
96
96
  offline: OfflineConversations;
97
97
  /** Read at call time: the title of a chat created while signed out. */
98
98
  newChatTitle: string;
99
+ /** The chat's app, which labels a conversation it creates (`AliaRuntimeConfig.app`). */
100
+ app: string | undefined;
99
101
  }
100
102
 
101
103
  /** The runtime's conversation client for the nearest provider. */
102
104
  export function useConversationsClient(): ConversationsClient {
103
105
  const api = useAliaApi();
104
- const { offline } = useAliaRuntime();
106
+ const { offline, app } = useAliaRuntime();
105
107
  const { newChat } = useLabels();
106
108
  return useMemo(
107
- () => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat }),
108
- [api, offline, newChat],
109
+ () => ({ api, offline: new OfflineConversations(offline), newChatTitle: newChat, app }),
110
+ [api, offline, newChat, app],
109
111
  );
110
112
  }
111
113
 
112
114
  async function fetchConversationsPage(
113
115
  client: ConversationsClient,
114
116
  pageParam: string | undefined,
115
- agentId: string | undefined,
117
+ { agentId, app }: ConversationsFilter,
116
118
  ): Promise<ConversationsPage> {
117
119
  try {
118
120
  const { data } = await client.api.get<{
@@ -124,6 +126,7 @@ async function fetchConversationsPage(
124
126
  limit: PAGE_SIZE,
125
127
  ...(pageParam ? { cursor: pageParam } : {}),
126
128
  ...(agentId === undefined ? {} : { agentId }),
129
+ ...(app === undefined ? {} : { app }),
127
130
  },
128
131
  });
129
132
  return {
@@ -137,9 +140,9 @@ async function fetchConversationsPage(
137
140
  hasMore: data.hasMore,
138
141
  };
139
142
  } catch (error: unknown) {
140
- // If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's are
141
- // never kept offline).
142
- if (errorStatus(error) === 401 && agentId === undefined) {
143
+ // If unauthorized, fall back to the offline copy (Alia's own conversations: an agent's and an
144
+ // app's are never kept offline).
145
+ if (errorStatus(error) === 401 && agentId === undefined && app === undefined) {
143
146
  const conversations = (await client.offline.read()).map((c) => ({
144
147
  ...c,
145
148
  messages: [] as Message[],
@@ -158,19 +161,21 @@ async function fetchConversationsPage(
158
161
 
159
162
  /**
160
163
  * The person's conversations, newest first, a page at a time. The sidebar and the sheet read this.
161
- * Given `agentId`, only the conversations with that agent (`GET /v1/conversations?agentId=`).
164
+ * Given `agentId`, only the conversations with that agent; given `app`, only the ones started in
165
+ * that app — an embedding app's own list (`GET /v1/conversations?agentId=&app=`). Neither is
166
+ * implied by the chat's `app`: the app that wants only its own says so here.
162
167
  */
163
- export function useConversations({ agentId }: { agentId?: string } = {}) {
168
+ export function useConversations({ agentId, app }: ConversationsFilter = {}) {
164
169
  const { isAuthenticated } = useOxy();
165
170
  const client = useConversationsClient();
166
171
 
167
172
  return useInfiniteQuery({
168
173
  queryKey:
169
- agentId === undefined
174
+ agentId === undefined && app === undefined
170
175
  ? aliaQueryKeys.conversations.all
171
- : aliaQueryKeys.conversations.withAgent(agentId),
176
+ : aliaQueryKeys.conversations.filtered({ agentId, app }),
172
177
  queryFn: ({ pageParam }: { pageParam: string | undefined }) =>
173
- fetchConversationsPage(client, pageParam, agentId),
178
+ fetchConversationsPage(client, pageParam, { agentId, app }),
174
179
  initialPageParam: undefined as string | undefined,
175
180
  getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextCursor : undefined),
176
181
  staleTime: FIVE_MINUTES,
@@ -309,12 +314,14 @@ export function useSaveConversation() {
309
314
  conversationId: id,
310
315
  messages,
311
316
  ...(title && { title }),
317
+ ...(client.app === undefined ? {} : { app: client.app }),
312
318
  });
313
319
  return {
314
320
  id: data.id,
315
321
  title: data.title,
316
322
  lastMessage: data.lastMessage,
317
323
  source: data.source,
324
+ app: data.app,
318
325
  createdAt: new Date(data.createdAt),
319
326
  updatedAt: new Date(data.updatedAt),
320
327
  messages,
@@ -515,6 +522,7 @@ export function useCreateConversation() {
515
522
  try {
516
523
  const { data } = await client.api.post<ApiConversation>(API_ROUTES.conversations.create, {
517
524
  ...(params?.agentId && { agentId: params.agentId }),
525
+ ...(client.app === undefined ? {} : { app: client.app }),
518
526
  });
519
527
  return {
520
528
  id: data.id,
@@ -522,6 +530,7 @@ export function useCreateConversation() {
522
530
  lastMessage: undefined,
523
531
  source: data.source,
524
532
  agentId: data.agentId,
533
+ app: data.app,
525
534
  createdAt: new Date(data.createdAt),
526
535
  updatedAt: new Date(data.updatedAt),
527
536
  messages: [],
@@ -35,10 +35,12 @@ export interface AliaHostOutcome {
35
35
  detail?: string;
36
36
  }
37
37
 
38
- /** The host tools a surface declares (`hostTools` on the chat). */
38
+ /**
39
+ * The host tools a surface declares (`hostTools` on the chat). They are the tools of the chat's
40
+ * `app`, which a surface with host tools must name: the app is said once, on the chat, and is what
41
+ * the model sees them under (`host_<app>__<name>`).
42
+ */
39
43
  export interface AliaHostTools {
40
- /** The app's slug, `^[a-z][a-z0-9-]{0,31}$`. */
41
- app: string;
42
44
  /**
43
45
  * At most 16. What the host's screen shows now is not part of the declaration: it is the chat's
44
46
  * `clientContext`, built by the host per turn.
@@ -80,14 +82,18 @@ export class HostOutcomeQueue {
80
82
  private ready: HostOutcomeRecord[] = [];
81
83
 
82
84
  /**
83
- * Perform `action` with `tools`, once. Resolves when its outcome is queued; `false` when the
84
- * action had already been performed and nothing was done.
85
+ * Perform `action` with the tools `app` declared, once. Resolves when its outcome is queued;
86
+ * `false` when the action had already been performed and nothing was done.
85
87
  */
86
- async dispatch(action: HostAction, tools: AliaHostTools | undefined): Promise<boolean> {
88
+ async dispatch(
89
+ action: HostAction,
90
+ tools: AliaHostTools | undefined,
91
+ app: string | undefined,
92
+ ): Promise<boolean> {
87
93
  if (this.performed.has(action.toolCallId)) return false;
88
94
  this.performed.add(action.toolCallId);
89
95
  let outcome: AliaHostOutcome;
90
- if (tools === undefined || action.app !== tools.app) {
96
+ if (tools === undefined || app === undefined || action.app !== app) {
91
97
  outcome = { outcome: 'rejected', detail: 'This app did not declare that tool.' };
92
98
  } else if (!tools.definitions.some((tool) => tool.name === action.name)) {
93
99
  outcome = { outcome: 'rejected', detail: `Unknown tool "${action.name}".` };
@@ -121,16 +127,23 @@ export class HostOutcomeQueue {
121
127
  }
122
128
  }
123
129
 
124
- /** The `host` member of a request, or nothing for a surface without host tools. */
130
+ /**
131
+ * The `host` member of a request: the chat's `app` and its host tools (none is an empty list),
132
+ * or nothing for a chat that names no app — Alia's own.
133
+ *
134
+ * The app is also what labels a conversation this turn starts (`docs/agent-embedding.md` §6), so
135
+ * a surface without host tools still sends it.
136
+ */
125
137
  export function hostRequest(
138
+ app: string | undefined,
126
139
  tools: AliaHostTools | undefined,
127
140
  outcomes: readonly HostOutcomeRecord[],
128
141
  ): HostRequest | undefined {
129
- if (tools === undefined) return undefined;
142
+ if (app === undefined) return undefined;
130
143
  return {
131
- app: tools.app,
144
+ app,
132
145
  version: 1,
133
- tools: tools.definitions.map(({ name, description, inputSchema }) => ({
146
+ tools: (tools?.definitions ?? []).map(({ name, description, inputSchema }) => ({
134
147
  name,
135
148
  description,
136
149
  inputSchema,
@@ -138,3 +151,6 @@ export function hostRequest(
138
151
  ...(outcomes.length === 0 ? {} : { outcomes: [...outcomes] }),
139
152
  };
140
153
  }
154
+
155
+ /** The slug an app names itself by, here and on the server: `^[a-z][a-z0-9-]{0,31}$`. */
156
+ export const APP_SLUG = /^[a-z][a-z0-9-]{0,31}$/;
@@ -51,6 +51,7 @@ export {
51
51
  useShareConversation,
52
52
  useSharedConversation,
53
53
  } from './sharing';
54
+ export { aliaSharedConversationUrl } from '../lib/origins';
54
55
  export type {
55
56
  AliaConversationShare,
56
57
  AliaSharedAgent,
@@ -60,7 +61,7 @@ export type {
60
61
  export { uploadToOxyFiles, useDocumentUpload } from './oxy-files';
61
62
  export type { OxyFilesClient } from './oxy-files';
62
63
  export type { AliaApi, AliaApiRequest, AliaApiResult } from './api';
63
- export { aliaQueryKeys } from './query-keys';
64
+ export { aliaQueryKeys, type ConversationsFilter } from './query-keys';
64
65
  export {
65
66
  ConversationNotFoundError,
66
67
  useClearConversation,
@@ -2,12 +2,24 @@
2
2
  * The React Query keys of the conversation runtime. The host's persister and its sidebar read
3
3
  * the same keys the hooks write, so a list shown anywhere is the list the chat updates.
4
4
  */
5
+ /** What a conversation list may be narrowed to (`GET /v1/conversations?agentId=&app=`). */
6
+ export interface ConversationsFilter {
7
+ /** Only the conversations with this agent. */
8
+ agentId?: string;
9
+ /** Only the conversations started in this app, by its slug (`docs/agent-embedding.md` §6). */
10
+ app?: string;
11
+ }
12
+
5
13
  export const aliaQueryKeys = {
6
14
  conversations: {
7
15
  all: ['conversations'] as const,
8
16
  detail: (id: string) => ['conversation', id] as const,
9
- /** The person's conversations with one agent: under `all`, so every write to the list reaches it. */
10
- withAgent: (agentId: string) => ['conversations', { agentId }] as const,
17
+ /**
18
+ * The person's conversations narrowed to one agent, one app or both: under `all`, so every
19
+ * write to the list reaches it.
20
+ */
21
+ filtered: (filter: ConversationsFilter) =>
22
+ ['conversations', { agentId: filter.agentId, app: filter.app }] as const,
11
23
  },
12
24
  /** An agent the chat is opened on: its profile and the state of its feature gates. */
13
25
  agents: {
@@ -1,5 +1,6 @@
1
1
  import { useMutation, useQuery } from '@tanstack/react-query';
2
2
  import { useMemo } from 'react';
3
+ import { aliaSharedConversationUrl } from '../lib/origins';
3
4
  import { useAliaApi } from './api';
4
5
  import { useAliaRuntime } from './config';
5
6
  import { aliaQueryKeys } from './query-keys';
@@ -9,8 +10,10 @@ import type { Message } from './types';
9
10
  /** A conversation's read-only link (`POST /v1/conversations/:id/share`, `docs/agent-embedding.md` §6). */
10
11
  export interface AliaConversationShare {
11
12
  conversationId: string;
12
- /** What `GET /v1/shared/:token` reads it by; the host builds its own URL around it. */
13
+ /** What `GET /v1/shared/:token` reads it by. */
13
14
  token: string;
15
+ /** The link to hand on: Alia's one shared-conversation page (`aliaSharedConversationUrl`). */
16
+ url: string;
14
17
  expiresAt: string;
15
18
  }
16
19
 
@@ -36,7 +39,12 @@ export function useShareConversation() {
36
39
  if (typeof data?.token !== 'string' || typeof data.expiresAt !== 'string') {
37
40
  throw new Error('The share link came back without a token.');
38
41
  }
39
- return { conversationId, token: data.token, expiresAt: data.expiresAt };
42
+ return {
43
+ conversationId,
44
+ token: data.token,
45
+ url: aliaSharedConversationUrl(data.token),
46
+ expiresAt: data.expiresAt,
47
+ };
40
48
  },
41
49
  });
42
50
  const revoke = useMutation({
@@ -93,6 +93,8 @@ export interface Conversation {
93
93
  * arrives carrying an explicit `null`.
94
94
  */
95
95
  agentId?: string | null;
96
+ /** The app the conversation started in, by its slug, or `null` for Alia's own surfaces. */
97
+ app?: string | null;
96
98
  createdAt: Date;
97
99
  updatedAt: Date;
98
100
  messages: Message[];
@@ -153,10 +153,12 @@ export function useConversationStream({
153
153
  clientContext,
154
154
  hostTools,
155
155
  }: UseConversationStreamOptions = {}) {
156
- const { apiUrl, streamFetch, host } = useAliaRuntime();
156
+ const { apiUrl, streamFetch, host, app } = useAliaRuntime();
157
157
  /** Read at send time and when an action arrives: the host's declaration may change between turns. */
158
158
  const hostToolsRef = useRef(hostTools);
159
159
  hostToolsRef.current = hostTools;
160
+ const appRef = useRef(app);
161
+ appRef.current = app;
160
162
  /** This conversation's host actions: performed once, their outcomes held until a request carries them. */
161
163
  const hostOutcomesRef = useRef(new HostOutcomeQueue());
162
164
  const labels = useLabels();
@@ -260,9 +262,14 @@ export function useConversationStream({
260
262
  }, 50);
261
263
  }, [flushPendingUpdates]);
262
264
 
263
- // Cleanup: flush remaining content and clear timer on unmount
265
+ /** Whether this thread is on screen: a turn that outlives it ends without `onTurnEnd`. */
266
+ const mountedRef = useRef(false);
267
+
268
+ // Mounted, a turn that ends is announced (`onTurnEnd`); unmounting flushes and clears the timer.
264
269
  useEffect(() => {
270
+ mountedRef.current = true;
265
271
  return () => {
272
+ mountedRef.current = false;
266
273
  flushPendingUpdates();
267
274
  if (flushTimerRef.current) {
268
275
  clearTimeout(flushTimerRef.current);
@@ -613,7 +620,7 @@ export function useConversationStream({
613
620
 
614
621
  // The host's tools, and the outcomes of the actions it performed since the last request.
615
622
  const outcomes = hostOutcomesRef.current.pending();
616
- const hostMember = hostRequest(hostToolsRef.current, outcomes);
623
+ const hostMember = hostRequest(appRef.current, hostToolsRef.current, outcomes);
617
624
 
618
625
  // Authentication, the preflight refresh and the single 401 replay belong
619
626
  // to the linked Oxy client; this layer never reads or writes a bearer.
@@ -885,7 +892,11 @@ export function useConversationStream({
885
892
  // the next request. The stream does not wait for it.
886
893
  const action = parseHostAction(parsed);
887
894
  if (action !== null) {
888
- void hostOutcomesRef.current.dispatch(action, hostToolsRef.current);
895
+ void hostOutcomesRef.current.dispatch(
896
+ action,
897
+ hostToolsRef.current,
898
+ appRef.current,
899
+ );
889
900
  }
890
901
  continue;
891
902
  }
@@ -1344,6 +1355,8 @@ export function useConversationStream({
1344
1355
  * rendered messages, which have not caught up with the last batched flush.
1345
1356
  */
1346
1357
  if (replyText !== '') host.onTurnPreview?.(agentId, replyText);
1358
+ // Every way a turn stops streaming — finished, stopped, failed — passes here once.
1359
+ if (mountedRef.current) host.onTurnEnd?.();
1347
1360
  }
1348
1361
  },
1349
1362
  [