@graineai/inapp-react-native 0.11.0 → 0.12.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/INTEGRATION.md CHANGED
@@ -18,6 +18,24 @@ Package: `@graineai/inapp-react-native`
18
18
  | Peer install | `react-native-webview` — autolinks, no setup code |
19
19
  | From Graine | your **publishable key**, from Agent → Embed & Widgets |
20
20
 
21
+ ### Two dashboard steps first
22
+
23
+ Each takes a minute and each, skipped, produces a failure that looks like
24
+ something else.
25
+
26
+ **1. Give the app its own agent.** Clone the website agent rather than pointing
27
+ the app at it. Actions are declared per agent and every surface that agent serves
28
+ is offered all of them, so a shared agent hands your website tools that only
29
+ exist inside the app — and picks worse for having them. The clone carries the
30
+ prompt, knowledge base and voice across; change only what differs.
31
+
32
+ **2. Add `app://` to that agent's Allowed domains** (Agent → Embed & Widgets →
33
+ Install & domains). A native app has no `Origin` header, and there is no header a
34
+ client could send that we would trust instead — so `app://` is how you say "this
35
+ agent may also be reached from an app". Without it the connection is refused with
36
+ `no_origin` and **the launcher never renders**, which reads exactly like a bad
37
+ key. An empty allowlist refuses everything for the same reason.
38
+
21
39
  **There is no native setup.** No `MainApplication.kt` edit, no `AppDelegate.swift`
22
40
  edit, no ProGuard rules, no babel plugin ordering. The microphone and speaker run
23
41
  inside a WebView pointed at your hosted agent page, so every fix to the audio
@@ -140,9 +158,33 @@ screen; when it fires it names the field that is `invalid` or `empty`, so the
140
158
  agent opens with "the PAN isn't being accepted" rather than "need a hand?".
141
159
  Without `status` it can only manage the second one.
142
160
 
161
+ | `status` | Means | What the agent does |
162
+ |---|---|---|
163
+ | `empty` | Nothing entered | Offers to fill it |
164
+ | `filled` | Has a value | Leaves it alone |
165
+ | `invalid` | Rejected — pair with `error` | Names the problem, offers the fix |
166
+ | `pending` | In flight | Waits instead of claiming either outcome |
167
+ | `locked` | Not changeable from here | Routes elsewhere rather than offering to type |
168
+
169
+ `locked` is the one worth remembering. An OS permission the customer must grant
170
+ in system settings is not `empty` — nobody can fill it on this screen. Marked
171
+ `empty`, the agent offers to do it and fails; marked `locked`, it says where to go.
172
+
143
173
  Report the real value. Masking happens on the way out and again on arrival — a
144
174
  screen that pre-redacts just blinds the agent.
145
175
 
176
+ **Name every screen.** The route the SDK reports is an identifier, not a name.
177
+ Ship `kyc_documents` and the agent reads your routing table to your customers.
178
+ Keep a `Record<ScreenName, string>` of the words actually printed on each screen,
179
+ typed over your route union so the screen somebody adds next year fails the build
180
+ instead of reaching a customer as a slug.
181
+
182
+ **Where the hook goes.** Below every piece of state it reads — the object is
183
+ built during render, so a `const` declared under it is in its temporal dead zone
184
+ and throws — and above every early return, or the hook order changes between
185
+ renders. Action handlers have neither constraint: their bodies only run when the
186
+ agent calls them.
187
+
146
188
  ---
147
189
 
148
190
  ## Step 4 — Let it act
@@ -276,8 +318,111 @@ part. Everything else is unchanged.
276
318
 
277
319
  ---
278
320
 
321
+ ## Making the bar yours
322
+
323
+ Three levels, and most apps stop at the first.
324
+
325
+ **1. The dashboard.** Accent, title and logo come from Appearance on the agent
326
+ and reach every surface without an app release. Set the colour in Graine and it
327
+ changes here. This is the level a brand team should be working at.
328
+
329
+ **2. Props, when the dashboard cannot express it.** Any prop passed wins over
330
+ the dashboard, so an app that needs something per-build still can.
331
+
332
+ ```tsx
333
+ <GraineAgentBar
334
+ scheme="auto" // "dark" | "light" | "auto" (default) — follows the device
335
+ accent="#318CE7" // overrides the dashboard
336
+ name="Ring"
337
+ bottomInset={110} // clear of your tab bar
338
+ captionsOn
339
+ />
340
+ ```
341
+
342
+ `scheme` defaults to `auto` rather than dark: an agent that ignores the system
343
+ setting is the one piece of the screen that looks like it came from somewhere
344
+ else. Pin it only if your app pins its own.
345
+
346
+ **3. The palette, when your brand is not one of the two.** `theme` is merged
347
+ over the scheme's colours, so pass the one you care about:
348
+
349
+ ```tsx
350
+ import type { GraineBarTheme } from "@graineai/inapp-react-native";
351
+
352
+ <GraineAgentBar theme={{ surface: "#0E1116", border: "#232A33" }} />
353
+ ```
354
+
355
+ | Key | Where it lands |
356
+ |---|---|
357
+ | `surface` | The bar, the expanded panel, the caption bubble |
358
+ | `border` | Hairline around all three |
359
+ | `text` | The agent's name, its replies, what the customer types |
360
+ | `sub` | Status line, placeholder, empty state |
361
+ | `chip` | Quiet round buttons, and the agent's message bubbles |
362
+ | `onAccent` | Text and glyphs sitting on the accent, and on `danger` |
363
+ | `danger` | Muted — the one state worth its own colour |
364
+
365
+ **4. Or draw your own.** Everything the bar does is built on the public hooks,
366
+ so an app with a design system should ignore the component entirely:
367
+
368
+ ```tsx
369
+ const { connected, muted, setMuted, messages, send, caption } = useGraineAgent();
370
+ ```
371
+
372
+ That is the expected path for anyone with a brand team, and nothing else in the
373
+ SDK depends on this component.
374
+
375
+ ---
376
+
377
+ ## Session lifecycle
378
+
379
+ One session per client, and it follows the app.
380
+
381
+ **Backgrounding ends the call.** Switching apps does not unmount a React tree,
382
+ so without this the WebView stays mounted with the microphone open and the
383
+ conversation still billing while nobody is listening. iOS makes it worse rather
384
+ than better: it suspends the WebView's audio without telling the socket, so the
385
+ call survives as a leg that can no longer hear anything and bills for silence.
386
+
387
+ The session ends on `background`, never on `inactive` — iOS reports `inactive`
388
+ for the app switcher, a pulled-down notification, and the moment a permission
389
+ dialog appears, so ending on it would mean the microphone prompt hangs up the
390
+ call that asked for it.
391
+
392
+ ```tsx
393
+ <GraineProvider … endOnBackground={false}> {/* default: true */}
394
+ ```
395
+
396
+ Turn it off only if a conversation should genuinely survive a switch to another
397
+ app, and you are prepared to explain the open microphone.
398
+
399
+ Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
400
+ call is something a person chose to start, and starting one again because they
401
+ came back to the app is a microphone opening unasked. `connected` goes false;
402
+ draw from that.
403
+
404
+ **`connect()` is idempotent.** Calling it twice returns the same session rather
405
+ than opening a second socket. This matters under React 18 strict mode, which
406
+ mounts effects twice: previously the first socket was orphaned with no reference
407
+ to close it, and the runtime kept it open and billing until its own timeout.
408
+
409
+ **`close()` sends a stop frame** before dropping the socket, so the conversation
410
+ is filed as ended rather than as a customer who vanished mid-turn. It is called
411
+ for you on unmount and on backgrounding.
412
+
413
+ ---
414
+
279
415
  ## Troubleshooting
280
416
 
417
+ **Nothing renders at all, and the console says `no_origin`.**
418
+ The agent's Allowed domains has no `app://` entry. `no_domains_configured` means
419
+ the allowlist is empty, which denies everything. Both are dashboard fixes, not
420
+ code.
421
+
422
+ **The agent offers to do things this app cannot.**
423
+ The website agent is being reused. Actions are declared per agent and offered on
424
+ every surface it serves — clone it and give the app its own.
425
+
281
426
  **The agent talks but does not know what screen I am on.**
282
427
  `navigationRef` must be the same ref you pass to `NavigationContainer`, and
283
428
  `GraineProvider` must wrap it.
package/dist/client.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class GraineInAppClient {
23
23
  config: SessionConfig | null;
24
24
  transport: "gateway" | "direct" | null;
25
25
  private ws;
26
+ private connecting;
26
27
  private listeners;
27
28
  private actions;
28
29
  private screen;
@@ -46,6 +47,7 @@ export declare class GraineInAppClient {
46
47
  session(): Promise<SessionConfig>;
47
48
  private ticket;
48
49
  connect(): Promise<void>;
50
+ private openSocket;
49
51
  private send;
50
52
  private onFrame;
51
53
  executeAction(name: string, args?: Record<string, unknown>): Promise<AppActionResult>;
package/dist/client.js CHANGED
@@ -7,6 +7,7 @@ export class GraineInAppClient {
7
7
  this.config = null;
8
8
  this.transport = null;
9
9
  this.ws = null;
10
+ this.connecting = null;
10
11
  this.listeners = new Map();
11
12
  this.actions = new Map();
12
13
  this.screen = null;
@@ -87,6 +88,14 @@ export class GraineInAppClient {
87
88
  }
88
89
  }
89
90
  async connect() {
91
+ if (this.ws && (this.ws.readyState === 0 || this.ws.readyState === 1))
92
+ return;
93
+ if (this.connecting)
94
+ return this.connecting;
95
+ this.connecting = this.openSocket().finally(() => { this.connecting = null; });
96
+ return this.connecting;
97
+ }
98
+ async openSocket() {
90
99
  if (!this.config)
91
100
  await this.session();
92
101
  const agentId = this.config.webcallAgentId || this.config.agentId;
@@ -410,6 +419,7 @@ export class GraineInAppClient {
410
419
  }
411
420
  close() {
412
421
  this.stopTimers();
422
+ this.connecting = null;
413
423
  const sock = this.ws;
414
424
  this.ws = null;
415
425
  if (!sock)
package/dist/index.d.ts CHANGED
@@ -4,6 +4,6 @@ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RAT
4
4
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
5
5
  export { addMaskRule, maskDeep, maskString } from "./mask";
6
6
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
7
- export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
7
+ export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps, type GraineBarTheme, } from "./react-native/ui";
8
8
  export { GraineVoiceLauncher, type GraineVoiceLauncherProps, type GraineVoiceApi, } from "./react-native/voice-launcher";
9
9
  export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
package/dist/index.js CHANGED
@@ -3,6 +3,6 @@ export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RAT
3
3
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
4
4
  export { addMaskRule, maskDeep, maskString } from "./mask";
5
5
  export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, useGraineTap, } from "./react-native/index";
6
- export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
6
+ export { GraineAgentBar, GraineLauncher, } from "./react-native/ui";
7
7
  export { GraineVoiceLauncher, } from "./react-native/voice-launcher";
8
8
  export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
@@ -64,6 +64,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
64
64
  children: React.ReactNode;
65
65
  autoConnect?: boolean;
66
66
  onProactive?: (text: string) => void;
67
+ endOnBackground?: boolean;
67
68
  navigationRef?: {
68
69
  current: any;
69
70
  } | null;
@@ -71,7 +72,7 @@ export interface GraineProviderProps extends GraineInAppOptions {
71
72
  launcherDelayMs?: number;
72
73
  visibility?: LauncherVisibility;
73
74
  }
74
- export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
75
+ export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
75
76
  export declare function useGraineAgent(): GraineContextValue;
76
77
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
77
78
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
@@ -1,10 +1,11 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
+ import { AppState } from "react-native";
3
4
  import { GraineInAppClient } from "../client";
4
5
  import { VoiceSession } from "../voice";
5
6
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
6
7
  const Ctx = createContext(null);
7
- export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
8
+ export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
8
9
  const clientRef = useRef(null);
9
10
  if (!clientRef.current)
10
11
  clientRef.current = new GraineInAppClient(options);
@@ -158,6 +159,29 @@ export function GraineProvider({ children, autoConnect = true, onProactive, navi
158
159
  client.close();
159
160
  };
160
161
  }, [client, autoConnect]);
162
+ useEffect(() => {
163
+ if (!endOnBackground)
164
+ return;
165
+ let last = AppState.currentState;
166
+ const sub = AppState.addEventListener("change", (next) => {
167
+ const wasActive = last === "active";
168
+ last = next;
169
+ if (next === "background" && wasActive) {
170
+ client.close();
171
+ setConnected(false);
172
+ setConnecting(false);
173
+ return;
174
+ }
175
+ if (next === "active" && !wasActive && autoConnect) {
176
+ setConnecting(true);
177
+ client.connect().catch((err) => {
178
+ setConnecting(false);
179
+ console.error("[Graine]", err?.message ?? err);
180
+ });
181
+ }
182
+ });
183
+ return () => sub.remove();
184
+ }, [client, endOnBackground, autoConnect]);
161
185
  const value = useMemo(() => ({
162
186
  client,
163
187
  connected,
@@ -1,4 +1,13 @@
1
1
  import React from "react";
2
+ export interface GraineBarTheme {
3
+ surface: string;
4
+ border: string;
5
+ text: string;
6
+ sub: string;
7
+ chip: string;
8
+ onAccent: string;
9
+ danger: string;
10
+ }
2
11
  export interface GraineAgentBarProps {
3
12
  accent?: string;
4
13
  name?: string;
@@ -6,7 +15,9 @@ export interface GraineAgentBarProps {
6
15
  bottomInset?: number;
7
16
  hidden?: boolean;
8
17
  captionsOn?: boolean;
18
+ scheme?: "dark" | "light" | "auto";
19
+ theme?: Partial<GraineBarTheme>;
9
20
  }
10
- export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, }: GraineAgentBarProps): React.JSX.Element | null;
21
+ export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, }: GraineAgentBarProps): React.JSX.Element | null;
11
22
  export declare const GraineLauncher: typeof GraineAgentBar;
12
23
  export type GraineLauncherProps = GraineAgentBarProps;
@@ -1,12 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
3
- import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
4
  import { useGraineAgent } from "./index";
5
- export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, }) {
5
+ const DARK = {
6
+ surface: "#17161A",
7
+ border: "rgba(255,255,255,0.09)",
8
+ text: "#F2F3F5",
9
+ sub: "#9aa0a6",
10
+ chip: "rgba(255,255,255,0.08)",
11
+ onAccent: "#FFFFFF",
12
+ danger: "#E5484D",
13
+ };
14
+ const LIGHT = {
15
+ surface: "#FFFFFF",
16
+ border: "rgba(0,0,0,0.10)",
17
+ text: "#16181C",
18
+ sub: "#61666D",
19
+ chip: "rgba(0,0,0,0.05)",
20
+ onAccent: "#FFFFFF",
21
+ danger: "#D32F2F",
22
+ };
23
+ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, }) {
6
24
  const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, } = useGraineAgent();
7
25
  const accent = accentProp ?? appearance?.accent ?? "#318CE7";
8
26
  const name = nameProp ?? appearance?.title ?? "Assistant";
9
27
  const avatarUrl = avatarProp ?? appearance?.logo ?? undefined;
28
+ const deviceScheme = useColorScheme();
29
+ const styles = useMemo(() => {
30
+ const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
31
+ return makeStyles(themeProp ? { ...base, ...themeProp } : base);
32
+ }, [scheme, deviceScheme, themeProp]);
10
33
  const [expanded, setExpanded] = useState(false);
11
34
  const [captions, setCaptions] = useState(captionsOn);
12
35
  const [draft, setDraft] = useState("");
@@ -18,7 +41,7 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
18
41
  : muted ? "muted"
19
42
  : agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
20
43
  : connected ? "listening"
21
- : "starting…";
44
+ : "offline";
22
45
  useEffect(() => {
23
46
  if (expanded)
24
47
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
@@ -46,51 +69,55 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
46
69
  ], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
47
70
  styles.bubble,
48
71
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
49
- ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: "#8a8f98", onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && { color: "#fff" }], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && { color: "#fff" }], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
72
+ ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
50
73
  }
51
74
  export const GraineLauncher = GraineAgentBar;
52
- const styles = StyleSheet.create({
75
+ const makeStyles = (t) => StyleSheet.create({
53
76
  root: { position: "absolute", left: 12, right: 12, zIndex: 900 },
54
77
  panel: {
55
- backgroundColor: "#17161A", borderRadius: 22, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
78
+ backgroundColor: t.surface, borderRadius: 22, borderWidth: 1, borderColor: t.border,
56
79
  marginBottom: 8, overflow: "hidden",
57
80
  },
58
81
  transcript: { maxHeight: 240 },
59
82
  transcriptInner: { padding: 14, gap: 8 },
60
- empty: { color: "#9aa0a6", fontSize: 14, lineHeight: 21 },
83
+ empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
61
84
  bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
62
- agentBubble: { alignSelf: "flex-start", backgroundColor: "rgba(255,255,255,0.08)", borderBottomLeftRadius: 5 },
85
+ agentBubble: { alignSelf: "flex-start", backgroundColor: t.chip, borderBottomLeftRadius: 5 },
63
86
  customerBubble: { alignSelf: "flex-end", borderBottomRightRadius: 5 },
64
- agentText: { color: "#F2F3F5", fontSize: 14.5, lineHeight: 21 },
65
- customerText: { color: "#fff", fontSize: 14.5, lineHeight: 21 },
87
+ agentText: { color: t.text, fontSize: 14.5, lineHeight: 21 },
88
+ customerText: { color: t.onAccent, fontSize: 14.5, lineHeight: 21 },
66
89
  composer: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 12, paddingBottom: 12 },
67
90
  input: {
68
- flex: 1, borderWidth: 1, borderColor: "rgba(255,255,255,0.12)", borderRadius: 16,
69
- paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: "#F2F3F5", fontSize: 14.5,
91
+ flex: 1, borderWidth: 1, borderColor: t.border, borderRadius: 16,
92
+ paddingHorizontal: 12, paddingVertical: Platform.OS === "ios" ? 10 : 7, color: t.text, fontSize: 14.5,
70
93
  },
71
94
  send: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
72
- sendLabel: { color: "#fff", fontSize: 17, lineHeight: 20 },
95
+ sendLabel: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
73
96
  bar: {
74
97
  flexDirection: "row", alignItems: "center", gap: 11,
75
- backgroundColor: "#17161A", borderRadius: 26, borderWidth: 1, borderColor: "rgba(255,255,255,0.09)",
98
+ backgroundColor: t.surface, borderRadius: 26, borderWidth: 1, borderColor: t.border,
76
99
  paddingHorizontal: 8, paddingVertical: 8,
77
100
  shadowColor: "#000", shadowOpacity: 0.3, shadowRadius: 16, shadowOffset: { width: 0, height: 6 }, elevation: 12,
78
101
  },
79
102
  avatar: { width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center" },
80
- avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: "rgba(255,255,255,0.08)" },
103
+ avatarImage: { width: 38, height: 38, borderRadius: 19, backgroundColor: t.chip },
81
104
  captionWrap: {
82
- backgroundColor: "rgba(0,0,0,0.72)", borderRadius: 16, paddingHorizontal: 14, paddingVertical: 10, marginBottom: 8,
105
+ backgroundColor: t.surface, borderRadius: 18, borderWidth: 1, borderColor: t.border,
106
+ paddingHorizontal: 14, paddingVertical: 11, marginBottom: 8,
107
+ shadowColor: "#000", shadowOpacity: 0.18, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 6,
83
108
  },
84
- caption: { color: "#fff", fontSize: 15, lineHeight: 21, fontWeight: "600" },
109
+ caption: { color: t.text, fontSize: 15, lineHeight: 21, fontWeight: "600" },
85
110
  captionLive: { opacity: 0.8 },
86
- glyph: { color: "#F2F3F5", fontSize: 13, fontWeight: "800" },
87
- muted: { backgroundColor: "#E5484D" },
88
- avatarGlyph: { color: "#fff", fontSize: 17, lineHeight: 20 },
89
- name: { color: "#F2F3F5", fontSize: 15, fontWeight: "800" },
90
- status: { color: "#9aa0a6", fontSize: 12.5, marginTop: 1 },
111
+ glyph: { color: t.text, fontSize: 13, fontWeight: "800" },
112
+ glyphOnAccent: { color: t.onAccent },
113
+ placeholder: { color: t.sub },
114
+ muted: { backgroundColor: t.danger },
115
+ avatarGlyph: { color: t.onAccent, fontSize: 17, lineHeight: 20 },
116
+ name: { color: t.text, fontSize: 15, fontWeight: "800" },
117
+ status: { color: t.sub, fontSize: 12.5, marginTop: 1 },
91
118
  round: {
92
119
  width: 38, height: 38, borderRadius: 19, alignItems: "center", justifyContent: "center",
93
- backgroundColor: "rgba(255,255,255,0.08)",
120
+ backgroundColor: t.chip,
94
121
  },
95
- chevron: { color: "#F2F3F5", fontSize: 16, lineHeight: 18 },
122
+ chevron: { color: t.text, fontSize: 16, lineHeight: 18 },
96
123
  });
@@ -1,6 +1,6 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
- import { View } from "react-native";
3
+ import { AppState, View } from "react-native";
4
4
  import { useGraineAgent } from "./index";
5
5
  export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCaption, onCallState, onMicDenied, onEnded, children, }) {
6
6
  const { client, appearance } = useGraineAgent();
@@ -91,6 +91,16 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, onCap
91
91
  }, [post]),
92
92
  };
93
93
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
94
+ useEffect(() => {
95
+ let last = AppState.currentState;
96
+ const sub = AppState.addEventListener("change", (next) => {
97
+ const wasActive = last === "active";
98
+ last = next;
99
+ if (next === "background" && wasActive)
100
+ post({ type: "graine:end-call" });
101
+ });
102
+ return () => sub.remove();
103
+ }, [post]);
94
104
  const agentId = client.config?.agentId;
95
105
  if (!agentId)
96
106
  return _jsx(_Fragment, { children: children?.(api) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
5
5
  "license": "MIT",
6
6
  "private": false,