@graineai/inapp-react-native 0.11.0 → 0.11.1

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,55 @@ part. Everything else is unchanged.
276
318
 
277
319
  ---
278
320
 
321
+ ## Session lifecycle
322
+
323
+ One session per client, and it follows the app.
324
+
325
+ **Backgrounding ends the call.** Switching apps does not unmount a React tree,
326
+ so without this the WebView stays mounted with the microphone open and the
327
+ conversation still billing while nobody is listening. iOS makes it worse rather
328
+ than better: it suspends the WebView's audio without telling the socket, so the
329
+ call survives as a leg that can no longer hear anything and bills for silence.
330
+
331
+ The session ends on `background`, never on `inactive` — iOS reports `inactive`
332
+ for the app switcher, a pulled-down notification, and the moment a permission
333
+ dialog appears, so ending on it would mean the microphone prompt hangs up the
334
+ call that asked for it.
335
+
336
+ ```tsx
337
+ <GraineProvider … endOnBackground={false}> {/* default: true */}
338
+ ```
339
+
340
+ Turn it off only if a conversation should genuinely survive a switch to another
341
+ app, and you are prepared to explain the open microphone.
342
+
343
+ Text sessions reconnect when the app returns. **Voice does not auto-resume** — a
344
+ call is something a person chose to start, and starting one again because they
345
+ came back to the app is a microphone opening unasked. `connected` goes false;
346
+ draw from that.
347
+
348
+ **`connect()` is idempotent.** Calling it twice returns the same session rather
349
+ than opening a second socket. This matters under React 18 strict mode, which
350
+ mounts effects twice: previously the first socket was orphaned with no reference
351
+ to close it, and the runtime kept it open and billing until its own timeout.
352
+
353
+ **`close()` sends a stop frame** before dropping the socket, so the conversation
354
+ is filed as ended rather than as a customer who vanished mid-turn. It is called
355
+ for you on unmount and on backgrounding.
356
+
357
+ ---
358
+
279
359
  ## Troubleshooting
280
360
 
361
+ **Nothing renders at all, and the console says `no_origin`.**
362
+ The agent's Allowed domains has no `app://` entry. `no_domains_configured` means
363
+ the allowlist is empty, which denies everything. Both are dashboard fixes, not
364
+ code.
365
+
366
+ **The agent offers to do things this app cannot.**
367
+ The website agent is being reused. Actions are declared per agent and offered on
368
+ every surface it serves — clone it and give the app its own.
369
+
281
370
  **The agent talks but does not know what screen I am on.**
282
371
  `navigationRef` must be the same ref you pass to `NavigationContainer`, and
283
372
  `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)
@@ -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,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.11.1",
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,