@ateam-ai-mobile/ui 0.1.5 → 0.1.8

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Handoff exchange — the pure, testable half of HandoffScreen (R9B).
3
+ *
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
6
+ * node-tested directly, without rendering the RN component.
7
+ */
8
+ import type { HandoffExchangeResult } from '@ateam-ai-mobile/core';
9
+
10
+ export interface HandoffExchangeDeps {
11
+ /** Trade the one-time code for a session JWT (host injects core.exchangeHandoff). */
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
+ /** Adopt the session JWT. */
16
+ onSignedIn: (jwt: string) => void | Promise<void>;
17
+ /** Clear the consumed single-use code. */
18
+ onDone: () => void;
19
+ }
20
+
21
+ /**
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.
27
+ */
28
+ export async function runHandoffExchange(code: string, deps: HandoffExchangeDeps): Promise<void> {
29
+ const res = await deps.exchange(code);
30
+ if (res.tenant) await deps.onAdoptTenant(res.tenant);
31
+ await deps.onSignedIn(res.jwt);
32
+ deps.onDone();
33
+ }
34
+
35
+ /**
36
+ * Map the server's rejection to plainer copy. SECURITY: the exchange endpoint
37
+ * deliberately does NOT tell an anonymous caller whether a code was unknown (404)
38
+ * vs used/expired (410) — distinguishing them would confirm a real code to a
39
+ * replay attacker. So both collapse to ONE message; never promise "expired" vs
40
+ * "already used". 429 is the one safe-to-distinguish case (a rate-limit hint
41
+ * leaks nothing about code validity).
42
+ */
43
+ export function handoffErrorMessage(msg?: string): string {
44
+ const m = (msg || '').toLowerCase();
45
+ if (m.includes('429') || m.includes('rate') || m.includes('too many')) {
46
+ return 'Too many attempts — wait a moment, then get a new sign-in link on the web.';
47
+ }
48
+ return 'This sign-in link is no longer valid. Get a new one on the web and try again.';
49
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Invite join — the pure, testable half of JoinScreen (R9B).
3
+ *
4
+ * JSX-free so the join ORDERING and the header derivation are node-testable
5
+ * without rendering. The native id_token acquisition happens in the component via
6
+ * getOAuthProvider(); the acquired token is passed in here.
7
+ */
8
+ import type { ConsumerSignInResult, InvitePreview } from '@ateam-ai-mobile/core';
9
+
10
+ export interface InviteJoinDeps {
11
+ /** Exchange { invite, id_token } for a tenant-scoped JWT (host injects core.joinWith*). */
12
+ join: (code: string, idToken: string) => Promise<ConsumerSignInResult>;
13
+ /** Clear the consumed single-use invite (host store). */
14
+ onDone: () => void;
15
+ /** Adopt the session JWT. */
16
+ onSignedIn: (jwt: string) => void | Promise<void>;
17
+ }
18
+
19
+ /**
20
+ * Complete an invite join with an already-acquired id_token.
21
+ *
22
+ * Returns false WITHOUT side effects when idToken is null (user cancelled the
23
+ * native sheet). Otherwise: join → clear the consumed invite → sign in, in that
24
+ * order (matching the app: the invite is cleared before sign-in so a relaunch
25
+ * never retries a burned code). Throws on a join failure; the invite is NOT
26
+ * cleared and sign-in never runs then, so the user can retry the same invite.
27
+ */
28
+ export async function completeInviteJoin(
29
+ code: string,
30
+ idToken: string | null,
31
+ deps: InviteJoinDeps,
32
+ ): Promise<boolean> {
33
+ if (!idToken) return false;
34
+ const consumer = await deps.join(code, idToken);
35
+ deps.onDone();
36
+ await deps.onSignedIn(consumer.jwt);
37
+ return true;
38
+ }
39
+
40
+ /** The invite header copy. Role is optional — "Join <Org>?" when absent. */
41
+ export function inviteHeadline(preview: InvitePreview | null): string {
42
+ if (!preview) return '';
43
+ return preview.role ? `Join ${preview.org} as ${preview.role}?` : `Join ${preview.org}?`;
44
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Login — the pure, testable half of LoginScreen (R9B).
3
+ *
4
+ * JSX-free so the tenant client-id derivation, the consumer-disabled self-heal
5
+ * decision, and the sign-in ordering are node-testable without rendering.
6
+ */
7
+ import type { ConsumerCapabilityConfig, ConsumerSignInResult } from '@ateam-ai-mobile/core';
8
+
9
+ /**
10
+ * The Google client ids to sign in with, from the tenant's consumer config.
11
+ * webClientId sets the id_token AUDIENCE the server verifies; iosClientId makes
12
+ * the native account picker show (missing → the SDK silently falls back to a web
13
+ * flow on iOS). Accepts the legacy single `client_id` as the web id.
14
+ */
15
+ export function googleClientIds(config: ConsumerCapabilityConfig): { webClientId: string; iosClientId: string } {
16
+ const g = (config.providers as any).google || {};
17
+ return {
18
+ webClientId: g.web_client_id || g.client_id || '',
19
+ iosClientId: g.ios_client_id || '',
20
+ };
21
+ }
22
+
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.
28
+ */
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;
35
+ }
36
+
37
+ export interface ProviderSignInDeps {
38
+ /** Trade the provider id_token for a session JWT (host injects core.signInWith*). */
39
+ exchange: (idToken: string) => Promise<ConsumerSignInResult>;
40
+ /** Adopt the session JWT. */
41
+ onSignedIn: (jwt: string) => void | Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Complete a provider sign-in with an already-acquired id_token: exchange → sign
46
+ * in. Returns false WITHOUT side effects on a null token (user cancelled the
47
+ * native sheet). Throws on an exchange failure; sign-in never runs then.
48
+ */
49
+ export async function completeProviderSignIn(
50
+ idToken: string | null,
51
+ deps: ProviderSignInDeps,
52
+ ): Promise<boolean> {
53
+ if (!idToken) return false;
54
+ const consumer = await deps.exchange(idToken);
55
+ await deps.onSignedIn(consumer.jwt);
56
+ return true;
57
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Open self-signup — the pure, testable half of OpenJoinScreen (R9B).
3
+ *
4
+ * JSX-free so the join ORDERING (join → adopt tenant → sign in) and the error
5
+ * mapper are node-testable without rendering. The native id_token acquisition
6
+ * happens in the component via getOAuthProvider(); the acquired token is passed
7
+ * in here, so this module stays pure and provider-free.
8
+ */
9
+ import type { ConsumerSignInResult } from '@ateam-ai-mobile/core';
10
+
11
+ export interface OpenJoinDeps {
12
+ /** Exchange { tenant, id_token } for a session JWT (host injects core.openJoinWith*). */
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
+ /** Adopt the session JWT. */
17
+ onSignedIn: (jwt: string) => void | Promise<void>;
18
+ }
19
+
20
+ /**
21
+ * Complete an open self-signup with an already-acquired id_token.
22
+ *
23
+ * Returns false WITHOUT side effects when idToken is null — the caller acquired
24
+ * 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.
29
+ */
30
+ export async function completeOpenJoin(
31
+ slug: string,
32
+ idToken: string | null,
33
+ deps: OpenJoinDeps,
34
+ ): Promise<boolean> {
35
+ if (!idToken) return false;
36
+ const consumer = await deps.join(slug, idToken);
37
+ await deps.onAdoptTenant(slug);
38
+ await deps.onSignedIn(consumer.jwt);
39
+ return true;
40
+ }
41
+
42
+ /**
43
+ * Map the server's open-join rejection to plainer copy. Unlike the handoff
44
+ * exchange, these ARE safe to distinguish — the caller already knows the tenant
45
+ * slug they typed, so "invite-only" / "no such team" leak nothing.
46
+ */
47
+ export function openJoinErrorMessage(msg?: string): string {
48
+ const m = (msg || '').toLowerCase();
49
+ if (m.includes('invite-only') || m.includes('invite only')) {
50
+ return 'This team is invite-only — ask an admin for an invite link.';
51
+ }
52
+ if (m.includes('not found') || m.includes('unknown tenant')) {
53
+ return 'No team with that handle.';
54
+ }
55
+ return msg || 'Could not join';
56
+ }
@@ -194,6 +194,9 @@ export default function MessageBubble({ message }: Props) {
194
194
  <UIActionsRow actions={message.actions} />
195
195
  )}
196
196
  {pq && (
197
+ // Per-chain: this bubble's OWN chain has a pending question. Parallel
198
+ // pending chains each render their own chips (jobProgress is per
199
+ // bubble); answered questions have no jobProgress → no chips.
197
200
  <QuestionChips inputType={pq.inputType} options={pq.options} />
198
201
  )}
199
202
  {!isProgressOnly && (
package/src/index.ts CHANGED
@@ -38,15 +38,52 @@ export type { TenantPickerProps } from './TenantPicker';
38
38
  export { default as Lightbox } from './Lightbox';
39
39
  export { useLightboxStore } from './stores/lightboxStore';
40
40
 
41
+ // Workspace subscription banner (server-authored message; GET /api/session)
42
+ export { default as WorkspaceBanner } from './WorkspaceBanner';
43
+ export type { WorkspaceBannerProps } from './WorkspaceBanner';
44
+
45
+ // Auth screens (R9B) — generic pre-auth presentation; the host injects native
46
+ // OAuth (via setOAuthProvider), the session/tenant callbacks, and wraps them in
47
+ // its own SafeAreaView + router phase. The native id_token acquisition never
48
+ // enters this package.
49
+ export { default as HandoffScreen } from './auth/HandoffScreen';
50
+ export type { HandoffScreenProps } from './auth/HandoffScreen';
51
+ export { runHandoffExchange, handoffErrorMessage } from './auth/handoffExchange';
52
+ export type { HandoffExchangeDeps } from './auth/handoffExchange';
53
+ export { default as OpenJoinScreen } from './auth/OpenJoinScreen';
54
+ export type { OpenJoinScreenProps } from './auth/OpenJoinScreen';
55
+ export { completeOpenJoin, openJoinErrorMessage } from './auth/openJoin';
56
+ export type { OpenJoinDeps } from './auth/openJoin';
57
+ export { default as JoinScreen } from './auth/JoinScreen';
58
+ export type { JoinScreenProps } from './auth/JoinScreen';
59
+ export { completeInviteJoin, inviteHeadline } from './auth/inviteJoin';
60
+ export type { InviteJoinDeps } from './auth/inviteJoin';
61
+ export { default as LoginScreen } from './auth/LoginScreen';
62
+ export type { LoginScreenProps } from './auth/LoginScreen';
63
+ export { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './auth/login';
64
+ export type { ProviderSignInDeps } from './auth/login';
65
+
41
66
  // Voice (requires `voiceUrl` in the initAdas() config)
42
67
  export { default as VoiceCallSheet } from './voice/VoiceCallSheet';
43
68
  export { default as useVoiceCall } from './voice/useVoiceCall';
44
69
  export type { TranscriptEntry, CallState } from './voice/useVoiceCall';
70
+ // The headless session + its app-root mounts. VoiceSessionHost keeps the loop
71
+ // alive independent of any screen; VoiceMicIndicator is the platform-owned mic
72
+ // evidence; `voiceSession` is the VoiceAPI provider handed to setVoiceProvider.
73
+ export { default as VoiceSessionHost } from './voice/VoiceSessionHost';
74
+ export { default as VoiceMicIndicator } from './voice/VoiceMicIndicator';
75
+ export { voiceSession } from './voice/voiceSession';
45
76
 
46
77
  // Host integration
47
78
  export {
48
79
  setClipboardProvider,
49
80
  setUIActionNavigator,
50
81
  setPluginWebViewExtraHosts,
82
+ setOAuthProvider,
83
+ } from './providers';
84
+ export type {
85
+ ClipboardProvider,
86
+ UIActionRoute,
87
+ OAuthProvider,
88
+ OAuthGoogleConfig,
51
89
  } from './providers';
52
- export type { ClipboardProvider, UIActionRoute } from './providers';
@@ -14,8 +14,7 @@ import {
14
14
  usePlugin,
15
15
  useNativeBridge,
16
16
  usePluginTheme,
17
- getPluginNative,
18
- gatePluginNative,
17
+ resolvePluginRuntimeProps,
19
18
  type PluginContext,
20
19
  } from '@ateam-ai-mobile/plugin-runtime';
21
20
  import EmbeddedPluginWebView from './EmbeddedPluginWebView';
@@ -83,13 +82,18 @@ export default function Plugin({ id, manifest, commandBufferRef, props: enginePr
83
82
  // Extract connectorId from MCP plugin ID: mcp:<connector>:<plugin>
84
83
  const connectorId = id.startsWith('mcp:') ? id.split(':')[1] : manifest._connectorId;
85
84
 
86
- // Wire up bridge, capabilities, theme
85
+ // Wire up bridge, the gated device surface, theme.
87
86
  const bridge = useNativeBridge(id, connectorId, commandBufferRef);
88
- // Per-plugin capability gate: undeclared native APIs become warn-and-ignore
89
- // proxies. Memoized so the plugin Component doesn't re-render on a fresh
90
- // object every pass (useApi effects key off prop identity).
91
- const declaredCaps = manifest?.native;
92
- const native = useMemo(() => gatePluginNative(getPluginNative(), declaredCaps), [declaredCaps]);
87
+ // ONE runtime contract — the SAME resolver every surface uses, so this drawer/
88
+ // fullscreen mount gates device/native + deviceState identically to the
89
+ // embedded mount. Memoized so the plugin Component doesn't re-render on a fresh
90
+ // object every pass (useApi effects key off prop identity). The manifest is the
91
+ // sole authority here (it is a required prop); `plugin` is read by the resolver
92
+ // only when there is no manifest, so the loaded bundle never widens its grants.
93
+ const { device, native, deviceState, voice } = useMemo(
94
+ () => resolvePluginRuntimeProps({ pluginId: id, manifest: manifest as any, plugin: plugin as any }),
95
+ [id, manifest, plugin],
96
+ );
93
97
  const theme = usePluginTheme();
94
98
 
95
99
  // Build context from the host-owned identity
@@ -130,7 +134,9 @@ export default function Plugin({ id, manifest, commandBufferRef, props: enginePr
130
134
  const { Component } = plugin;
131
135
  return (
132
136
  <PluginErrorBoundary pluginId={id}>
133
- <Component bridge={bridge} native={native} theme={theme} context={context} props={engineProps} />
137
+ {/* `device` is canonical (R4); `native` is the deprecated alias — same
138
+ gated object — so existing/remote plugin bundles keep working. */}
139
+ <Component bridge={bridge} device={device} native={native} deviceState={deviceState} voice={voice} theme={theme} context={context} props={engineProps} />
134
140
  </PluginErrorBoundary>
135
141
  );
136
142
  }
package/src/providers.ts CHANGED
@@ -3,6 +3,50 @@
3
3
  * @ateam-ai-mobile/plugin-runtime's host.ts. Everything has a safe default; the host
4
4
  * (or @ateam-ai-mobile/expo) wires real implementations at startup.
5
5
  */
6
+ import type { ReactNode } from 'react';
7
+
8
+ // ── Native OAuth (Google / Apple id_token acquisition) ──────────────────────
9
+ // The relocated auth screens (LoginScreen / JoinScreen / OpenJoinScreen) render
10
+ // the generic sign-in presentation, but acquiring the provider id_token is a
11
+ // NATIVE step — @react-native-google-signin + expo-apple-authentication — that
12
+ // must never enter this package. The host (via @ateam-ai-mobile/expo) wires a
13
+ // provider once at startup; the screens call getOAuthProvider() internally.
14
+ //
15
+ // This ONE method set replaces the per-screen acquire callbacks. signInWithGoogle
16
+ // takes an optional { webClientId, iosClientId }: LoginScreen passes the tenant's
17
+ // configured client ids; JoinScreen / OpenJoinScreen pass nothing, so the host
18
+ // uses its bundled GOOGLE_IOS_CLIENT_ID. Both return null on a user-cancel
19
+ // (SIGN_IN_CANCELLED / IN_PROGRESS / ERR_REQUEST_CANCELED) and throw on a real
20
+ // failure. The Apple availability probe + the official HIG button come off the
21
+ // same provider so expo-apple-authentication never enters ui/src and no
22
+ // look-alike button is ever drawn.
23
+
24
+ export interface OAuthGoogleConfig {
25
+ webClientId?: string;
26
+ iosClientId?: string;
27
+ }
28
+
29
+ export interface OAuthProvider {
30
+ /** Acquire a Google OIDC id_token. `cfg` present → tenant client ids; absent → host bundled id. Null on user-cancel. */
31
+ signInWithGoogle(cfg?: OAuthGoogleConfig): Promise<string | null>;
32
+ /** Acquire an Apple id_token. Null on user-cancel. */
33
+ signInWithApple(): Promise<string | null>;
34
+ /** AppleAuthentication.isAvailableAsync() (iOS 13+); resolves false everywhere else. Async so the screen probes in an effect, never renders a stale false. */
35
+ isAppleAvailable(): Promise<boolean>;
36
+ /** The real Apple HIG button as a slot — the screen decides WHEN to show it + supplies its layout `style`, the host supplies the native node + HIG chrome. */
37
+ renderAppleButton(props: { onPress: () => void; disabled: boolean; style?: unknown }): ReactNode;
38
+ }
39
+
40
+ let _oauth: OAuthProvider | null = null;
41
+
42
+ /** Register a native OAuth implementation (e.g. via @ateam-ai-mobile/expo). */
43
+ export function setOAuthProvider(oauth: OAuthProvider | null): void {
44
+ _oauth = oauth;
45
+ }
46
+
47
+ export function getOAuthProvider(): OAuthProvider | null {
48
+ return _oauth;
49
+ }
6
50
 
7
51
  // ── Clipboard ──────────────────────────────────────────────────────────────
8
52
 
@@ -2,8 +2,9 @@
2
2
  * VoiceCallSheet — the full-screen voice call experience.
3
3
  *
4
4
  * AdaOrb hero + live transcript (with important-data highlighting) + mute
5
- * control + an offscreen WebView that runs the actual WebRTC session (see
6
- * useVoiceCall). Requires `voiceUrl` in the initAdas() config.
5
+ * control. This is a VIEW onto the one `voiceSession` (via useVoiceCall) — the
6
+ * WebRTC WebView now lives in VoiceSessionHost at the app root, so closing this
7
+ * sheet no longer ends the call. Requires `voiceUrl` in the initAdas() config.
7
8
  */
8
9
  import { useEffect, useRef, useMemo } from 'react';
9
10
  import {
@@ -15,7 +16,6 @@ import {
15
16
  ScrollView,
16
17
  } from 'react-native';
17
18
  import Svg, { Path, Line } from 'react-native-svg';
18
- import { WebView } from 'react-native-webview';
19
19
  import { ada } from '@ateam-ai-mobile/core';
20
20
  import { AdaOrb, SurfaceFullscreen, type AdaOrbState } from '@ateam-ai-mobile/plugin-runtime';
21
21
  import useVoiceCall from './useVoiceCall';
@@ -93,8 +93,7 @@ function HighlightedText({
93
93
  export default function VoiceCallSheet({ visible, onClose, skillSlug }: Props) {
94
94
  const {
95
95
  callState, aiPartial, userPartial, transcript, error, muted,
96
- startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
97
- injectedTrackHook,
96
+ startCall, stopCall, toggleMute,
98
97
  } = useVoiceCall({ skillSlug });
99
98
 
100
99
  const scrollRef = useRef<ScrollView>(null);
@@ -201,25 +200,6 @@ export default function VoiceCallSheet({ visible, onClose, skillSlug }: Props) {
201
200
  <Text style={styles.muteLabel}>{muted ? 'Muted' : 'Mute'}</Text>
202
201
  </View>
203
202
  ) : null}
204
-
205
- {/* WebView mounted offscreen — runs WebRTC silently */}
206
- {callState !== 'idle' && (
207
- <View style={styles.hiddenWebView} pointerEvents="none">
208
- <WebView
209
- ref={webViewRef}
210
- source={getWebViewSource()}
211
- style={{ flex: 1, backgroundColor: 'transparent' }}
212
- onMessage={handleWebViewMessage}
213
- onError={(e: any) => console.log('[VoiceWebView] error:', e.nativeEvent?.description)}
214
- injectedJavaScriptBeforeContentLoaded={injectedTrackHook}
215
- mediaPlaybackRequiresUserAction={false}
216
- allowsInlineMediaPlayback={true}
217
- mediaCapturePermissionGrantType="grant"
218
- javaScriptEnabled={true}
219
- originWhitelist={['*']}
220
- />
221
- </View>
222
- )}
223
203
  </SurfaceFullscreen>
224
204
  );
225
205
  }
@@ -321,12 +301,4 @@ const styles = StyleSheet.create({
321
301
  color: ada.inkMuted,
322
302
  letterSpacing: 0.3,
323
303
  },
324
- hiddenWebView: {
325
- position: 'absolute',
326
- width: 1,
327
- height: 1,
328
- top: -100,
329
- left: -100,
330
- opacity: 0,
331
- },
332
304
  });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * VoiceMicIndicator — the platform-owned "the mic is live" evidence.
3
+ *
4
+ * Spec invariant 4: a voice session must NEVER be live without the user knowing,
5
+ * and a headless session (no voice screen, phone pocketed) is exactly where "no
6
+ * UI" must not become "no evidence". So this is drawn by the PLATFORM, mounted
7
+ * once at the app root above all plugin content, and a plugin NEVER receives it:
8
+ * there is deliberately no `props.voice` call to hide, fake, or restyle it. It
9
+ * reads the one `voiceSession` directly and shows whenever the session is live.
10
+ *
11
+ * It is evidence, not a control — non-interactive on purpose. Reading a live
12
+ * session's transcript (props.voice.transcript(), a privacy surface) is only
13
+ * possible while a session is live, and a live session shows THIS. That coupling
14
+ * is the point: no path to the conversation without visible proof of the mic.
15
+ */
16
+ import React, { useEffect, useReducer, useRef } from 'react';
17
+ import { View, Text, Animated, StyleSheet, Platform } from 'react-native';
18
+ import { ada } from '@ateam-ai-mobile/core';
19
+ import { voiceSession } from './voiceSession';
20
+
21
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
22
+
23
+ export default function VoiceMicIndicator({ topInset = 8 }: { topInset?: number }) {
24
+ const [, force] = useReducer((x: number) => x + 1, 0);
25
+ const pulse = useRef(new Animated.Value(0.4)).current;
26
+
27
+ useEffect(() => {
28
+ const sub = voiceSession.subscribe(() => force());
29
+ return () => sub.unsubscribe();
30
+ }, []);
31
+
32
+ const snap = voiceSession.getSnapshot();
33
+ const live = snap.state === 'connecting' || snap.state === 'connected';
34
+
35
+ useEffect(() => {
36
+ if (!live) return;
37
+ const loop = Animated.loop(
38
+ Animated.sequence([
39
+ Animated.timing(pulse, { toValue: 1, duration: 700, useNativeDriver: true }),
40
+ Animated.timing(pulse, { toValue: 0.4, duration: 700, useNativeDriver: true }),
41
+ ]),
42
+ );
43
+ loop.start();
44
+ return () => loop.stop();
45
+ }, [live, pulse]);
46
+
47
+ if (!live) return null;
48
+
49
+ const label =
50
+ snap.state === 'connecting' ? 'Connecting…'
51
+ : snap.muted ? 'Mic muted'
52
+ : 'Ada is listening';
53
+ const dotColor = snap.muted ? ada.inkMuted : '#E5484D';
54
+
55
+ return (
56
+ <View style={[styles.wrap, { top: topInset }]} pointerEvents="none">
57
+ <View style={styles.pill}>
58
+ <Animated.View style={[styles.dot, { backgroundColor: dotColor, opacity: snap.muted ? 1 : pulse }]} />
59
+ <Text style={styles.label}>{label}</Text>
60
+ </View>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ const styles = StyleSheet.create({
66
+ wrap: {
67
+ position: 'absolute',
68
+ left: 0,
69
+ right: 0,
70
+ alignItems: 'center',
71
+ zIndex: 9999,
72
+ elevation: 9999,
73
+ },
74
+ pill: {
75
+ flexDirection: 'row',
76
+ alignItems: 'center',
77
+ gap: 7,
78
+ paddingHorizontal: 12,
79
+ paddingVertical: 6,
80
+ borderRadius: 999,
81
+ backgroundColor: ada.ink,
82
+ shadowColor: '#000',
83
+ shadowOpacity: 0.2,
84
+ shadowRadius: 8,
85
+ shadowOffset: { width: 0, height: 2 },
86
+ },
87
+ dot: {
88
+ width: 8,
89
+ height: 8,
90
+ borderRadius: 4,
91
+ },
92
+ label: {
93
+ fontSize: 12,
94
+ fontFamily: fontFam,
95
+ fontWeight: '600',
96
+ color: ada.paper,
97
+ letterSpacing: 0.2,
98
+ },
99
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * VoiceSessionHost — the app-root mount that keeps the voice session alive.
3
+ *
4
+ * Renders nothing visible: an OFFSCREEN WebView bound to the `voiceSession`
5
+ * singleton. Mounted ONCE at the app root (like VisionStreamHost for the
6
+ * camera), so the live loop no longer lives inside whatever screen happened to
7
+ * open it. Unmount a UI and the session survives; that is the whole point of the
8
+ * split (spec invariant 3).
9
+ *
10
+ * It re-renders ONLY when the session starts or stops (the mount signature),
11
+ * never on transcript or partial-caption churn — a remount would drop the
12
+ * WebView and kill the session, the exact bug this exists to prevent. The
13
+ * WebView source is computed once at start() and cached in the session, so it is
14
+ * stable across the host's lifetime.
15
+ */
16
+ import React, { useCallback, useEffect, useReducer, useRef } from 'react';
17
+ import { View, StyleSheet } from 'react-native';
18
+ import { WebView } from 'react-native-webview';
19
+ import { voiceSession } from './voiceSession';
20
+
21
+ export default function VoiceSessionHost() {
22
+ const [, force] = useReducer((x: number) => x + 1, 0);
23
+ const lastSig = useRef<string>('');
24
+
25
+ useEffect(() => {
26
+ // Re-render only when mount-relevant state changes — not on every transcript
27
+ // line, which would remount the WebView and drop the session.
28
+ const sub = voiceSession.subscribe(() => {
29
+ const s = voiceSession.getInternal();
30
+ const sig = `${s.mounted}|${s.webViewSource?.uri || ''}`;
31
+ if (sig !== lastSig.current) {
32
+ lastSig.current = sig;
33
+ force();
34
+ }
35
+ });
36
+ return () => sub.unsubscribe();
37
+ }, []);
38
+
39
+ // Stable ref callback — React re-invokes an INLINE ref on every render (null
40
+ // then node), which would momentarily detach the WebView from the session.
41
+ const setRef = useCallback((r: any) => {
42
+ voiceSession.setWebView(r);
43
+ }, []);
44
+
45
+ const s = voiceSession.getInternal();
46
+ if (!s.mounted || !s.webViewSource) return null;
47
+
48
+ return (
49
+ <View style={styles.hidden} pointerEvents="none">
50
+ <WebView
51
+ ref={setRef}
52
+ source={s.webViewSource}
53
+ style={styles.web}
54
+ onMessage={(e) => voiceSession.handleMessage(e)}
55
+ onError={(e: any) => console.log('[VoiceSessionHost] error:', e.nativeEvent?.description)}
56
+ injectedJavaScriptBeforeContentLoaded={voiceSession.injectedTrackHook}
57
+ mediaPlaybackRequiresUserAction={false}
58
+ allowsInlineMediaPlayback
59
+ mediaCapturePermissionGrantType="grant"
60
+ javaScriptEnabled
61
+ originWhitelist={['*']}
62
+ />
63
+ </View>
64
+ );
65
+ }
66
+
67
+ const styles = StyleSheet.create({
68
+ hidden: {
69
+ position: 'absolute',
70
+ width: 1,
71
+ height: 1,
72
+ top: -100,
73
+ left: -100,
74
+ opacity: 0,
75
+ },
76
+ web: { flex: 1, backgroundColor: 'transparent' },
77
+ });