@ateam-ai-mobile/ui 0.1.9 → 0.1.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/ui",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Prebuilt ADAS chat experience for React Native — full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,8 +9,8 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.9",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.9",
12
+ "@ateam-ai-mobile/core": "0.1.11",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.11",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -9,8 +9,6 @@
9
9
  *
10
10
  * Everything host-specific is injected:
11
11
  * - `code` the deep-link code (capture + parsing stay host-side)
12
- * - `onAdoptTenant` the host's SINGLE tenant writer, AWAITED before onSignedIn
13
- * so ACTIVE_TENANT can't drift on a cross-tenant handoff
14
12
  * - `onSignedIn` session adoption (host: authStore.login → core session +
15
13
  * the shell-only tenantConfirmed / phase gate)
16
14
  * - `onDone` clear the consumed single-use code (host store) after success
@@ -31,12 +29,6 @@ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
31
29
  export interface HandoffScreenProps {
32
30
  /** The one-time handoff code the host captured from the deep link (null = nothing pending). */
33
31
  code: string | null;
34
- /**
35
- * Adopt the handed-off tenant. AWAITED before onSignedIn so the host's active
36
- * tenant is set before the session reads it (the exchange result carries the
37
- * tenant the code was bound to). No-op is fine when the result has no tenant.
38
- */
39
- onAdoptTenant: (tenant: string) => void | Promise<void>;
40
32
  /** Adopt the session JWT (host wires its authStore.login → core session + tenantConfirmed). */
41
33
  onSignedIn: (jwt: string) => void | Promise<void>;
42
34
  /** Clear the consumed single-use code so a relaunch never retries it. Called after success. */
@@ -45,7 +37,7 @@ export interface HandoffScreenProps {
45
37
  onCancel: () => void;
46
38
  }
47
39
 
48
- export default function HandoffScreen({ code, onAdoptTenant, onSignedIn, onDone, onCancel }: HandoffScreenProps) {
40
+ export default function HandoffScreen({ code, onSignedIn, onDone, onCancel }: HandoffScreenProps) {
49
41
  const [err, setErr] = useState<string | null>(null);
50
42
  // Exchange each code AT MOST ONCE. A single-use code exchanged twice
51
43
  // (StrictMode/fast-refresh double-invoke) burns itself: the first call wins and
@@ -57,9 +49,9 @@ export default function HandoffScreen({ code, onAdoptTenant, onSignedIn, onDone,
57
49
  useEffect(() => {
58
50
  if (!code || startedForRef.current === code) return;
59
51
  startedForRef.current = code;
60
- runHandoffExchange(code, { exchange: exchangeHandoff, onAdoptTenant, onSignedIn, onDone })
52
+ runHandoffExchange(code, { exchange: exchangeHandoff, onSignedIn, onDone })
61
53
  .catch((e: any) => setErr(handoffErrorMessage(e?.message)));
62
- }, [code, onAdoptTenant, onSignedIn, onDone]);
54
+ }, [code, onSignedIn, onDone]);
63
55
 
64
56
  return (
65
57
  <View style={styles.container}>
@@ -8,9 +8,12 @@
8
8
  * client ids are baked into the SDK.
9
9
  *
10
10
  * Host-injected seams:
11
- * - activeTenant the reactive pre-auth tenant pick (re-fetches the config)
12
- * - defaultTenant the fallback for the consumer-disabled self-heal guard
13
- * - onConsumerDisabled reset to the default tenant + persist + re-trigger
11
+ * - activeTenant the reactive pre-auth team (re-fetches the config);
12
+ * '' → the screen asks for the team
13
+ * - onPickTenant the user named a team ("Use a different team", or no
14
+ * team yet): the host stores it as its signed-out pick
15
+ * - onConsumerDisabled the team has no consumer sign-in: the host clears its
16
+ * pick; the screen asks for another team
14
17
  * - onSignedIn session adoption (host: authStore.login)
15
18
  * - onJoinTeam? open the host's "join a team by name" (OpenJoin) sub-view
16
19
  *
@@ -19,20 +22,21 @@
19
22
  * to signInWithGoogle. The host wraps this in its own SafeAreaView.
20
23
  */
21
24
  import { useEffect, useMemo, useState } from 'react';
22
- import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator } from 'react-native';
25
+ import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator, TextInput } from 'react-native';
23
26
  import { fetchConsumerConfig, signInWithGoogle, signInWithApple, ada, type ConsumerCapabilityConfig } from '@ateam-ai-mobile/core';
24
27
  import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
25
28
  import { getOAuthProvider } from '../providers';
26
- import { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './login';
29
+ import { googleClientIds, isConsumerDisabled, completeProviderSignIn } from './login';
27
30
 
28
31
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
29
32
 
30
33
  export interface LoginScreenProps {
31
- /** The reactive pre-auth tenant pick (host: useSession().tenant). Re-fetches config on change. */
34
+ /** The reactive pre-auth team (host: useSession().tenant). Re-fetches config on change.
35
+ * '' → the screen asks for the team. */
32
36
  activeTenant: string;
33
- /** The fallback tenant for the consumer-disabled self-heal guard (host: DEFAULT_TENANT). */
34
- defaultTenant: string;
35
- /** Reset to the default tenant + persist + bump activeTenant so this screen refetches. */
37
+ /** The user named a team to sign in to: the host stores it as its signed-out pick. */
38
+ onPickTenant: (tenant: string) => void | Promise<void>;
39
+ /** The team has no consumer sign-in: the host clears its pick (the screen asks for another). */
36
40
  onConsumerDisabled: () => void | Promise<void>;
37
41
  /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
38
42
  onSignedIn: (jwt: string) => void | Promise<void>;
@@ -40,27 +44,37 @@ export interface LoginScreenProps {
40
44
  onJoinTeam?: () => void;
41
45
  }
42
46
 
43
- export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDisabled, onSignedIn, onJoinTeam }: LoginScreenProps) {
47
+ export default function LoginScreen({ activeTenant, onPickTenant, onConsumerDisabled, onSignedIn, onJoinTeam }: LoginScreenProps) {
44
48
  const [config, setConfig] = useState<ConsumerCapabilityConfig | null>(null);
45
49
  const [error, setError] = useState<string | null>(null);
50
+ // The team whose config said "no consumer sign-in", kept across the host
51
+ // clearing its pick so the ask-for-team view can say why it is back.
52
+ const [disabledTeam, setDisabledTeam] = useState<string | null>(null);
53
+ // "Use a different team" — or the current team can't sign in.
54
+ const [askTeam, setAskTeam] = useState(false);
55
+ const askingTeam = !activeTenant || askTeam;
56
+ const pick = async (tenant: string) => { setAskTeam(false); setDisabledTeam(null); await onPickTenant(tenant); };
46
57
 
47
58
  useEffect(() => {
48
59
  setConfig(null);
49
60
  setError(null);
61
+ if (!activeTenant) return;
50
62
  fetchConsumerConfig()
51
63
  .then(setConfig)
52
64
  .catch(async err => {
53
65
  const msg = err?.message || 'Failed to load sign-in config';
54
66
  // A consumer-disabled tenant 404s with "capability not enabled". Don't
55
- // dead-end on red text — the host resets to the default tenant and bumps
56
- // activeTenant, which re-runs this effect (the login screen self-heals).
57
- if (shouldRecoverToDefaultTenant(msg, activeTenant, defaultTenant)) {
67
+ // dead-end on red text — ask for another team (and the host clears its
68
+ // pick). Asking here, not re-fetching, so a disabled fallback can't loop.
69
+ if (isConsumerDisabled(msg)) {
70
+ setDisabledTeam(activeTenant);
71
+ setAskTeam(true);
58
72
  await onConsumerDisabled();
59
73
  return;
60
74
  }
61
75
  setError(msg);
62
76
  });
63
- }, [activeTenant, defaultTenant, onConsumerDisabled]);
77
+ }, [activeTenant, onConsumerDisabled]);
64
78
 
65
79
  return (
66
80
  <View style={styles.container}>
@@ -71,9 +85,15 @@ export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDis
71
85
  </View>
72
86
 
73
87
  <View style={styles.buttonSection}>
74
- {!config && !error && <ActivityIndicator color={ada.ink} />}
75
- {!!error && <Text style={styles.errorText}>{error}</Text>}
76
- {!!config && <SignInButtons config={config} onSignedIn={onSignedIn} />}
88
+ {askingTeam && <TeamField notice={disabledTeam} onPick={pick} />}
89
+ {!askingTeam && !config && !error && <ActivityIndicator color={ada.ink} />}
90
+ {!askingTeam && !!error && <Text style={styles.errorText}>{error}</Text>}
91
+ {!askingTeam && !!config && <SignInButtons config={config} onSignedIn={onSignedIn} />}
92
+ {!askingTeam && (
93
+ <Pressable onPress={() => setAskTeam(true)} hitSlop={8} style={{ paddingVertical: 4 }}>
94
+ <Text style={styles.joinLink}>Use a different team</Text>
95
+ </Pressable>
96
+ )}
77
97
  {onJoinTeam && (
78
98
  <Pressable onPress={onJoinTeam} hitSlop={8} style={{ paddingVertical: 8 }}>
79
99
  <Text style={styles.joinLink}>Join a team by name</Text>
@@ -85,6 +105,33 @@ export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDis
85
105
  );
86
106
  }
87
107
 
108
+ /** Ask which team to sign in to. Signing in needs the team's own consumer
109
+ * config (its client ids). */
110
+ function TeamField({ notice, onPick }: { notice: string | null; onPick: (tenant: string) => void | Promise<void> }) {
111
+ const [team, setTeam] = useState('');
112
+ const slug = team.trim().toLowerCase();
113
+ const submit = () => { if (slug) void onPick(slug); };
114
+ return (
115
+ <>
116
+ {!!notice && <Text style={styles.errorText}>“{notice}” doesn't have app sign-in. Try another team.</Text>}
117
+ <TextInput
118
+ style={styles.teamInput}
119
+ value={team}
120
+ onChangeText={setTeam}
121
+ placeholder="your team (e.g. acme)"
122
+ placeholderTextColor={ada.inkMuted}
123
+ autoCapitalize="none"
124
+ autoCorrect={false}
125
+ returnKeyType="go"
126
+ onSubmitEditing={submit}
127
+ />
128
+ <Pressable style={[styles.signInBtn, !slug && styles.signInBtnDisabled]} onPress={submit} disabled={!slug}>
129
+ <Text style={styles.signInText}>Continue</Text>
130
+ </Pressable>
131
+ </>
132
+ );
133
+ }
134
+
88
135
  function SignInButtons({ config, onSignedIn }: { config: ConsumerCapabilityConfig; onSignedIn: (jwt: string) => void | Promise<void> }) {
89
136
  const [busy, setBusy] = useState(false);
90
137
  const [error, setError] = useState<string | null>(null);
@@ -175,6 +222,10 @@ const styles = StyleSheet.create({
175
222
  signInGlyph: { fontSize: 18, fontWeight: '700', color: ada.paper, fontFamily: fontFam },
176
223
  signInText: { fontSize: 16, fontWeight: '600', color: ada.paper, letterSpacing: -0.2, fontFamily: fontFam },
177
224
  appleBtn: { width: '100%', height: 52 },
225
+ teamInput: {
226
+ width: '100%', backgroundColor: ada.paperSoft, borderWidth: 1, borderColor: ada.border, borderRadius: 14,
227
+ paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: ada.ink,
228
+ },
178
229
  errorText: { fontSize: 13, color: '#c43d3d', textAlign: 'center', paddingHorizontal: 16 },
179
230
  disclaimer: { fontSize: 12, color: ada.inkMuted, textAlign: 'center', marginTop: 8, lineHeight: 18, fontFamily: fontFam },
180
231
  joinLink: { fontSize: 14, fontWeight: '600', color: ada.orangeDeep, textAlign: 'center', fontFamily: fontFam },
@@ -9,8 +9,8 @@
9
9
  * Native id_token acquisition + the Apple HIG button come from the host-wired
10
10
  * OAuthProvider (setOAuthProvider). With no cfg passed to signInWithGoogle, the
11
11
  * provider uses the host's BUNDLED Google client id (this screen carries no
12
- * tenant config). Everything host-specific — session, the single ACTIVE_TENANT
13
- * writer, the back transition, the SafeAreaView — is injected.
12
+ * tenant config). Everything host-specific — session, the back transition, the
13
+ * SafeAreaView — is injected. The joined tenant is the JWT's own claim.
14
14
  */
15
15
  import { useEffect, useState } from 'react';
16
16
  import { View, Text, Pressable, StyleSheet, ActivityIndicator, Platform, TextInput } from 'react-native';
@@ -22,15 +22,13 @@ import { completeOpenJoin, openJoinErrorMessage } from './openJoin';
22
22
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
23
23
 
24
24
  export interface OpenJoinScreenProps {
25
- /** Adopt the joined tenant (host's single ACTIVE_TENANT writer), awaited before sign-in. */
26
- onAdoptTenant: (tenant: string) => void | Promise<void>;
27
25
  /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
28
26
  onSignedIn: (jwt: string) => void | Promise<void>;
29
27
  /** Back to sign in — the host toggles the pre-auth phase (not a router). */
30
28
  onBack: () => void;
31
29
  }
32
30
 
33
- export default function OpenJoinScreen({ onAdoptTenant, onSignedIn, onBack }: OpenJoinScreenProps) {
31
+ export default function OpenJoinScreen({ onSignedIn, onBack }: OpenJoinScreenProps) {
34
32
  const [team, setTeam] = useState('');
35
33
  const [busy, setBusy] = useState(false);
36
34
  const [err, setErr] = useState<string | null>(null);
@@ -54,7 +52,7 @@ export default function OpenJoinScreen({ onAdoptTenant, onSignedIn, onBack }: Op
54
52
  setErr(null); setBusy(true);
55
53
  try {
56
54
  const idToken = await acquire(oauth);
57
- await completeOpenJoin(slug, idToken, { join, onAdoptTenant, onSignedIn });
55
+ await completeOpenJoin(slug, idToken, { join, onSignedIn });
58
56
  } catch (e: any) {
59
57
  setErr(openJoinErrorMessage(e?.message));
60
58
  } finally {
@@ -2,7 +2,7 @@
2
2
  * Handoff exchange — the pure, testable half of HandoffScreen (R9B).
3
3
  *
4
4
  * Kept JSX-free in its own module (like R6's runLogoutTeardown) so the
5
- * security-sensitive error mapper and the adopt-before-sign-in ORDERING can be
5
+ * security-sensitive error mapper and the sign-in-before-clear ORDERING can be
6
6
  * node-tested directly, without rendering the RN component.
7
7
  */
8
8
  import type { HandoffExchangeResult } from '@ateam-ai-mobile/core';
@@ -10,8 +10,6 @@ import type { HandoffExchangeResult } from '@ateam-ai-mobile/core';
10
10
  export interface HandoffExchangeDeps {
11
11
  /** Trade the one-time code for a session JWT (host injects core.exchangeHandoff). */
12
12
  exchange: (code: string) => Promise<HandoffExchangeResult>;
13
- /** Adopt the handed-off tenant (host's single tenant writer). */
14
- onAdoptTenant: (tenant: string) => void | Promise<void>;
15
13
  /** Adopt the session JWT. */
16
14
  onSignedIn: (jwt: string) => void | Promise<void>;
17
15
  /** Clear the consumed single-use code. */
@@ -19,15 +17,14 @@ export interface HandoffExchangeDeps {
19
17
  }
20
18
 
21
19
  /**
22
- * Run the handoff: exchange → (adopt tenant) → sign in → clear the code, in that
23
- * exact order. The tenant adoption is AWAITED BEFORE onSignedIn so the host's
24
- * active tenant is set before the session reads it — a cross-tenant handoff must
25
- * never sign in under the wrong tenant. Throws on any failure (the caller maps it
26
- * to a user-facing message); onSignedIn / onDone never run on the error path.
20
+ * Run the handoff: exchange → sign in → clear the code, in that exact order. The
21
+ * handed-off tenant is the JWT's own `tenant` claim (getTenant() reads it), so a
22
+ * cross-tenant handoff is scoped correctly with nothing for the host to store.
23
+ * Throws on any failure (the caller maps it to a user-facing message);
24
+ * onSignedIn / onDone never run on the error path.
27
25
  */
28
26
  export async function runHandoffExchange(code: string, deps: HandoffExchangeDeps): Promise<void> {
29
27
  const res = await deps.exchange(code);
30
- if (res.tenant) await deps.onAdoptTenant(res.tenant);
31
28
  await deps.onSignedIn(res.jwt);
32
29
  deps.onDone();
33
30
  }
package/src/auth/login.ts CHANGED
@@ -21,17 +21,12 @@ export function googleClientIds(config: ConsumerCapabilityConfig): { webClientId
21
21
  }
22
22
 
23
23
  /**
24
- * Whether a failed config fetch should self-heal by falling back to the default
25
- * tenant. A consumer-DISABLED tenant 404s with "capability not enabled"; rather
26
- * than dead-end the user on red text we coerce back to the default and refetch.
27
- * Guarded by currentTenant !== defaultTenant so we never loop on the default.
24
+ * Whether a failed config fetch means the picked tenant has no consumer sign-in
25
+ * ("capability not enabled" 404). The host then clears its pick and shows its
26
+ * team picker — it never falls back to a default tenant.
28
27
  */
29
- export function shouldRecoverToDefaultTenant(
30
- errorMsg: string | undefined,
31
- currentTenant: string,
32
- defaultTenant: string,
33
- ): boolean {
34
- return /capability not enabled/i.test(errorMsg || '') && currentTenant !== defaultTenant;
28
+ export function isConsumerDisabled(errorMsg: string | undefined): boolean {
29
+ return /capability not enabled/i.test(errorMsg || '');
35
30
  }
36
31
 
37
32
  export interface ProviderSignInDeps {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Open self-signup — the pure, testable half of OpenJoinScreen (R9B).
3
3
  *
4
- * JSX-free so the join ORDERING (join → adopt tenant → sign in) and the error
4
+ * JSX-free so the join ORDERING (join → sign in) and the error
5
5
  * mapper are node-testable without rendering. The native id_token acquisition
6
6
  * happens in the component via getOAuthProvider(); the acquired token is passed
7
7
  * in here, so this module stays pure and provider-free.
@@ -11,8 +11,6 @@ import type { ConsumerSignInResult } from '@ateam-ai-mobile/core';
11
11
  export interface OpenJoinDeps {
12
12
  /** Exchange { tenant, id_token } for a session JWT (host injects core.openJoinWith*). */
13
13
  join: (slug: string, idToken: string) => Promise<ConsumerSignInResult>;
14
- /** Adopt the joined tenant (host's single ACTIVE_TENANT writer). */
15
- onAdoptTenant: (tenant: string) => void | Promise<void>;
16
14
  /** Adopt the session JWT. */
17
15
  onSignedIn: (jwt: string) => void | Promise<void>;
18
16
  }
@@ -22,10 +20,10 @@ export interface OpenJoinDeps {
22
20
  *
23
21
  * Returns false WITHOUT side effects when idToken is null — the caller acquired
24
22
  * no token (the user cancelled the native sheet, or it returned none), which is
25
- * a silent no-op, never an error. Otherwise: join → adopt the joined tenant →
26
- * sign in, in that order (the tenant is adopted only AFTER the server confirms
27
- * the join, and BEFORE sign-in so the session reads the right tenant). Throws on
28
- * a join failure (the caller maps it to a message); sign-in never runs then.
23
+ * a silent no-op, never an error. Otherwise: join → sign in. The joined tenant
24
+ * is the returned JWT's own `tenant` claim (getTenant() reads it), so there is
25
+ * nothing for the host to store. Throws on a join failure (the caller maps it to
26
+ * a message); sign-in never runs then.
29
27
  */
30
28
  export async function completeOpenJoin(
31
29
  slug: string,
@@ -34,7 +32,6 @@ export async function completeOpenJoin(
34
32
  ): Promise<boolean> {
35
33
  if (!idToken) return false;
36
34
  const consumer = await deps.join(slug, idToken);
37
- await deps.onAdoptTenant(slug);
38
35
  await deps.onSignedIn(consumer.jwt);
39
36
  return true;
40
37
  }
package/src/index.ts CHANGED
@@ -60,7 +60,7 @@ export { completeInviteJoin, inviteHeadline } from './auth/inviteJoin';
60
60
  export type { InviteJoinDeps } from './auth/inviteJoin';
61
61
  export { default as LoginScreen } from './auth/LoginScreen';
62
62
  export type { LoginScreenProps } from './auth/LoginScreen';
63
- export { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './auth/login';
63
+ export { googleClientIds, isConsumerDisabled, completeProviderSignIn } from './auth/login';
64
64
  export type { ProviderSignInDeps } from './auth/login';
65
65
 
66
66
  // Voice (requires `voiceUrl` in the initAdas() config)
@@ -80,10 +80,12 @@ export {
80
80
  setUIActionNavigator,
81
81
  setPluginWebViewExtraHosts,
82
82
  setOAuthProvider,
83
+ setCallAudioProvider,
83
84
  } from './providers';
84
85
  export type {
85
86
  ClipboardProvider,
86
87
  UIActionRoute,
87
88
  OAuthProvider,
88
89
  OAuthGoogleConfig,
90
+ CallAudioProvider,
89
91
  } from './providers';
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useCallback } from 'react';
1
+ import { useState, useMemo, useCallback, useEffect } from 'react';
2
2
  import { View, Text, ActivityIndicator, StyleSheet, Platform } from 'react-native';
3
3
  import { WebView } from 'react-native-webview';
4
4
  import { getConfig, getCoreApiUrl, getVoiceApiUrl, getTenant, getToken, ada } from '@ateam-ai-mobile/core';
@@ -28,6 +28,9 @@ export default function EmbeddedPluginWebView({ pluginId, manifest, commandBuffe
28
28
  const { webViewRef, sendInit, handleMessage } = usePluginBridge(pluginId, manifest._connectorId, commandBufferRef);
29
29
 
30
30
  const url = resolvePluginUrl(manifest, tenant || undefined);
31
+ // An error belongs to the URL that produced it: a new URL (a fresh signature,
32
+ // a corrected tenant) gets a fresh load instead of the old error forever.
33
+ useEffect(() => { setError(null); }, [url]);
31
34
  const bridgeJS = buildPluginBridgeJS(token || '', tenant || '');
32
35
 
33
36
  // Hosts the plugin may navigate to — derived from the SDK config plus any
package/src/providers.ts CHANGED
@@ -48,6 +48,28 @@ export function getOAuthProvider(): OAuthProvider | null {
48
48
  return _oauth;
49
49
  }
50
50
 
51
+ // ── Call audio mode ────────────────────────────────────────────────────────
52
+ // A voice call needs the device in its CALL audio mode, or the phone's mic
53
+ // hears the loudspeaker and the AI interrupts itself (iOS: playAndRecord +
54
+ // voiceChat). Native, so the host wires it (via @ateam-ai-mobile/expo);
55
+ // voiceSession calls beginCall when a call starts and endCall when it ends.
56
+
57
+ export interface CallAudioProvider {
58
+ beginCall(): void;
59
+ endCall(): void;
60
+ }
61
+
62
+ let _callAudio: CallAudioProvider | null = null;
63
+
64
+ /** Register the device's call audio mode (e.g. via @ateam-ai-mobile/expo). */
65
+ export function setCallAudioProvider(callAudio: CallAudioProvider | null): void {
66
+ _callAudio = callAudio;
67
+ }
68
+
69
+ export function getCallAudioProvider(): CallAudioProvider | null {
70
+ return _callAudio;
71
+ }
72
+
51
73
  // ── Clipboard ──────────────────────────────────────────────────────────────
52
74
 
53
75
  export interface ClipboardProvider {
@@ -24,6 +24,7 @@
24
24
  * cannot reach the WebView beyond that one injected call.
25
25
  */
26
26
  import { getVoiceApiUrl, getToken, getTenant, getUser, refreshToken } from '@ateam-ai-mobile/core';
27
+ import { getCallAudioProvider } from '../providers';
27
28
  import {
28
29
  callDeviceTool,
29
30
  ensureDeviceToolsLoaded,
@@ -284,6 +285,11 @@ interface InternalState {
284
285
  persona?: VoicePersona;
285
286
  }
286
287
 
288
+ /** A call that holds the mic/speaker: connecting or connected. */
289
+ function isLiveCall(state: VoiceState): boolean {
290
+ return state === 'connecting' || state === 'connected';
291
+ }
292
+
287
293
  const IDLE: InternalState = {
288
294
  callState: 'idle',
289
295
  sessionId: null,
@@ -833,10 +839,26 @@ class VoiceSessionManager implements VoiceSessionProvider {
833
839
  // ── internals ────────────────────────────────────────────────
834
840
 
835
841
  private setState(partial: Partial<InternalState>): void {
842
+ const wasLive = isLiveCall(this.state.callState);
836
843
  this.state = { ...this.state, ...partial };
844
+ const isLive = isLiveCall(this.state.callState);
845
+ // The device's CALL audio mode follows the call: on as it starts connecting
846
+ // (before the WebView opens the mic), off as soon as it is no longer live —
847
+ // whichever path ends it (stop(), the page hanging up, an error).
848
+ if (wasLive !== isLive) this.setCallAudio(isLive);
837
849
  this.emit({ type: 'state' });
838
850
  }
839
851
 
852
+ private setCallAudio(on: boolean): void {
853
+ const audio = getCallAudioProvider();
854
+ if (!audio) return;
855
+ try {
856
+ if (on) audio.beginCall(); else audio.endCall();
857
+ } catch (e: any) {
858
+ console.warn('[voiceSession] call audio mode failed:', e?.message || String(e));
859
+ }
860
+ }
861
+
840
862
  private appendTranscript(entry: TranscriptEntry): void {
841
863
  this.state = { ...this.state, transcript: [...this.state.transcript, entry] };
842
864
  this.emit({ type: 'transcript' });