@graineai/inapp-react-native 0.27.2 → 0.27.5

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
@@ -389,6 +389,46 @@ nowhere. Save is what pushes the catalogue to the agent.
389
389
 
390
390
  ---
391
391
 
392
+ ### The contract, exactly
393
+
394
+ Four rules decide what the agent can do and what the customer is told. Each
395
+ one has bitten a real integration.
396
+
397
+ **Registration is per mounted screen.** `useGraineAction` registers the
398
+ handler when the component mounts and unregisters it when it unmounts, so the
399
+ agent is offered only what the screen in front of the customer can actually
400
+ do. Register in the screen that performs the action, not at the root — an
401
+ action registered at the root is offered on every screen, including the ones
402
+ where it makes no sense, and the agent will call it there.
403
+
404
+ **`availableActions` narrows; omitting it is the default you want.**
405
+ On `useGraineScreen`, leave it out and every action registered on the mounted
406
+ screen is offered. Pass a list to offer a subset. Pass `[]` to offer
407
+ *nothing* — say that deliberately, for a read-only screen. It can only ever
408
+ narrow: an action the dashboard never declared is not callable whatever
409
+ appears here, and the server enforces that, not this field. (Before 0.27.3
410
+ the SDK could send `[]` by accident while handlers were still registering,
411
+ which disarmed the agent for the whole screen; it now sends `[]` only when
412
+ you wrote `[]`.)
413
+
414
+ **A handler has six seconds.** `ACTION_DEADLINE_MS`. Return promptly — do
415
+ the slow part after you have answered. A handler that has not resolved by
416
+ then is answered as a timeout on the runtime side, and the agent tells the
417
+ customer it was *not* done rather than guessing. A handler that throws is
418
+ answered as `error` for the same reason.
419
+
420
+ **The status is what the customer is told.** `{ status: "ok" }` makes the
421
+ agent say it is done, so return it only when it is. `{ status: "refused",
422
+ message }` is often the most useful answer you can give — "this field is
423
+ locked after submission" — and the agent passes the reason on. `message` is
424
+ for the agent, which decides how to phrase it, not text read to the customer.
425
+
426
+ **Where it all ends up.** Every call the agent makes lands in Call History
427
+ with the app's answer beside it — "Ran set_ring (state: off) → ok" or
428
+ "→ refused: …" — on the timeline against the turn it happened in, and in the
429
+ execution log. A refusal there is the moment a customer was told something
430
+ that did not happen, which is exactly the row a product team wants to find.
431
+
392
432
  ## Step 4b — Show them where, and draw them things
393
433
 
394
434
  ### Pointing at something on screen
@@ -518,6 +558,42 @@ your funnel.
518
558
 
519
559
  ---
520
560
 
561
+ ## Tap capture when your screens sit above the provider
562
+
563
+ `GraineProvider` wraps its children in `GraineTouchCapture`, so an app whose
564
+ whole tree hangs below the provider gets best-effort tap tracking for free
565
+ (`captureTaps`, on by default with `fab`).
566
+
567
+ That covers fewer apps than it sounds. An app that builds its client at startup
568
+ and registers it with `setDefaultClient` can mount the provider anywhere — often
569
+ deep, around a call bar or a single screen — while the screens the customer
570
+ actually taps sit *above* it. Capture inside the provider then wraps the bar's
571
+ own chrome and sees none of the taps that matter.
572
+
573
+ For that layout, mount capture at your own root. Both components are exported
574
+ and work without a provider above them: the client resolves through
575
+ `setDefaultClient`, and the launcher-visibility gate simply does not apply when
576
+ there is no provider context to read it from.
577
+
578
+ ```tsx
579
+ import { GraineTouchCapture } from "@graineai/inapp-react-native";
580
+
581
+ export default function App() {
582
+ return (
583
+ <SafeAreaProvider>
584
+ <GraineTouchCapture>
585
+ <YourNavigator />
586
+ </GraineTouchCapture>
587
+ </SafeAreaProvider>
588
+ );
589
+ }
590
+ ```
591
+
592
+ Taps land in the screen frame as `tap:screen` events with the tap's position;
593
+ the runtime reads them as "tapped the screen" and Call History shows them on the
594
+ call's timeline. Named taps from `useGraineTap` are unaffected and still carry
595
+ their names. (0.27.4 — earlier versions did not export `GraineTouchCapture`.)
596
+
521
597
  ## Controlling where the launcher appears
522
598
 
523
599
  `includeScreens` for a simple allowlist:
@@ -682,6 +758,42 @@ for you on unmount and on backgrounding.
682
758
 
683
759
  ---
684
760
 
761
+ ## How long "Connecting…" takes, and why
762
+
763
+ From the tap to the agent's first word there are four waits. Three of them
764
+ are paid before the tap on a current SDK; the fourth is bounded. If the
765
+ launcher sits on "Connecting…" for more than about two seconds, one of these
766
+ is not happening, and the table says which.
767
+
768
+ | Wait | What it is | When it is paid | Typical |
769
+ |---|---|---|---|
770
+ | Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` mounts the engine on open; a custom bar mounts `GraineVoiceLauncher` at launch with `active={false}` and flips it on open (0.27.5) | ~4s, hidden |
771
+ | Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry | ~1.6s, hidden |
772
+ | Microphone | The OS permission prompt, first time only | At the tap | 0 once granted |
773
+ | INVITE → answer | The call itself | At the tap | ~0.3s |
774
+
775
+ **The INVITE is bounded, not left to the browser.** WebRTC gathers network
776
+ candidates before dialling, and Chrome does not report gathering complete
777
+ until STUN has been tried on *every* interface a device has — a VPN tunnel,
778
+ a link-local IPv6 address, a cellular path the app may not use. Those never
779
+ answer, and Chrome retries each for about forty seconds before giving up.
780
+ Measured on the embed page with the SIP frames timestamped: registered at
781
+ 8.2s, the usable candidate in hand at 8.3s, the INVITE not sent until 48.3s,
782
+ then answered in 300ms. On a phone with Wi-Fi and cellular that is the
783
+ ten-second "Connecting…" that was reported. The engine now dials the moment a
784
+ server-reflexive candidate exists, or 1.5 seconds after gathering starts if
785
+ none arrives — a network with no STUN at all still gets a call,
786
+ with host candidates, rather than the pause and then the same call.
787
+
788
+ **On an app that is still on 0.26.x** the first two rows are paid *after* the
789
+ tap: the page loads and registers only once the customer has pressed the
790
+ microphone, so "Connecting…" is honest and about six seconds long. Moving to
791
+ 0.27.4 is what hides them. The dial bound needs no app change — it lives in
792
+ the engine page, so every install gets it when the page is deployed.
793
+
794
+ What "Connecting…" should look like on a current app, then: a tap, the
795
+ microphone prompt once, and the greeting inside a second.
796
+
685
797
  ## Troubleshooting
686
798
 
687
799
  **Nothing renders at all, and the console says `no_origin`.**
@@ -965,6 +1077,7 @@ told to ignore).
965
1077
  | `onMicDenied` | `(reason) => void` | `NotAllowedError` is a refusal; `NotFoundError` is a device with no usable microphone. Different sentences for a customer. |
966
1078
  | `onError` | `(message) => void` | Voice could not be set up at all — key rejected, agent not enabled for apps, no network. Without this the launcher renders nothing and you cannot tell why. |
967
1079
  | `children` | `(api) => ReactNode` | Receives `{ connected, connecting, muted, start, end, setMuted }`. |
1080
+ | `active` | `boolean` | Default `true`. Mount early with `false` so the engine page loads before the customer needs it (it registers nothing while idle); flip to `true` when your UI opens — with `autoStart` that places the call, without it it warms the registration so the tap is instant. Back to `false` ends a live call. (0.27.5) |
968
1081
 
969
1082
  **The launcher owns the connection.** It asks for the microphone, resolves the
970
1083
  session, holds the socket and relays your screen, actions and events onto it. So
package/dist/client.d.ts CHANGED
@@ -79,6 +79,7 @@ export declare class GraineInAppClient {
79
79
  private performAction;
80
80
  get isConnected(): boolean;
81
81
  getScreen(): ScreenContext | null;
82
+ private availableActionsFrameField;
82
83
  getIdentity(): Record<string, string>;
83
84
  getRecentEvents(): Array<{
84
85
  name: string;
@@ -106,7 +107,7 @@ export declare class GraineInAppClient {
106
107
  buildContextFrame(): {
107
108
  type: "app_context";
108
109
  context: Record<string, unknown>;
109
- available_actions: string[];
110
+ available_actions?: string[];
110
111
  seq: number;
111
112
  } | null;
112
113
  private flushScreen;
package/dist/client.js CHANGED
@@ -327,6 +327,18 @@ export class GraineInAppClient {
327
327
  getScreen() {
328
328
  return this.screen;
329
329
  }
330
+ availableActionsFrameField(declared) {
331
+ const usable = (declared ?? this.availableActions).filter((a) => this.actions.has(a));
332
+ if (usable.length)
333
+ return { available_actions: usable };
334
+ if (Array.isArray(declared))
335
+ return { available_actions: [] };
336
+ if (this.actions.size === 0) {
337
+ console.warn("[Graine] No in-app action handlers are registered. Falling back to the dashboard's " +
338
+ "catalogue so the agent is not silently disarmed — register handlers with useGraineAction.");
339
+ }
340
+ return {};
341
+ }
330
342
  getIdentity() {
331
343
  return { ...this.identity };
332
344
  }
@@ -478,7 +490,7 @@ export class GraineInAppClient {
478
490
  ...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
479
491
  ...(this.highlightables.size ? { highlightable: [...this.highlightables] } : {}),
480
492
  }),
481
- available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
493
+ ...this.availableActionsFrameField(availableActions),
482
494
  seq: ++this.screenSeq,
483
495
  };
484
496
  }
@@ -2,6 +2,8 @@ import React from "react";
2
2
  import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
3
  import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
4
  import type { ActionHandler, ScreenContext, ScreenField } from "../protocol.js";
5
+ export { GraineFab, GraineTouchCapture } from "./fab.js";
6
+ export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
5
7
  import type { FrictionConfig } from "../friction.js";
6
8
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
7
9
  interface GraineContextValue {
@@ -133,4 +135,3 @@ export declare function useGraineReady(): {
133
135
  ready: boolean;
134
136
  error: string | null;
135
137
  };
136
- export {};
@@ -4,6 +4,7 @@ import { AppState } from "react-native";
4
4
  import { GraineInAppClient } from "../client.js";
5
5
  import { VoiceSession } from "../voice.js";
6
6
  import { GraineFab, GraineTouchCapture } from "./fab.js";
7
+ export { GraineFab, GraineTouchCapture } from "./fab.js";
7
8
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
8
9
  export const Ctx = createContext(null);
9
10
  let defaultClient = null;
@@ -9,6 +9,7 @@ export interface GraineVoiceLauncherProps {
9
9
  }) => void;
10
10
  webView: React.ComponentType<any>;
11
11
  autoStart?: boolean;
12
+ active?: boolean;
12
13
  onCaption?: (c: {
13
14
  role: string;
14
15
  text: string;
@@ -32,4 +33,4 @@ export interface GraineVoiceApi {
32
33
  end: () => void;
33
34
  setMuted: (m: boolean) => void;
34
35
  }
35
- export declare function GraineVoiceLauncher({ webView: WebView, autoStart, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
36
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -3,10 +3,13 @@ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { AppState, View } from "react-native";
4
4
  import { useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
- export function GraineVoiceLauncher({ webView: WebView, autoStart = false, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
6
+ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }) {
7
7
  const { client, appearance } = useGraineAgent();
8
8
  const ref = useRef(null);
9
9
  const startedRef = useRef(false);
10
+ const activeRef = useRef(active);
11
+ activeRef.current = active;
12
+ const mountedIdleRef = useRef(!active);
10
13
  const [connected, setConnected] = useState(false);
11
14
  const [connecting, setConnecting] = useState(false);
12
15
  const [muted, setMutedState] = useState(false);
@@ -113,7 +116,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
113
116
  case "graine:ready":
114
117
  setReady(true);
115
118
  pushContext();
116
- if ((autoStart || pendingStartRef.current) && !startedRef.current) {
119
+ if ((autoStart || pendingStartRef.current) && !startedRef.current && activeRef.current) {
117
120
  pendingStartRef.current = false;
118
121
  startedRef.current = true;
119
122
  void beginCall();
@@ -180,6 +183,33 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
180
183
  }, [post]),
181
184
  };
182
185
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
186
+ const activeSeenRef = useRef(null);
187
+ useEffect(() => {
188
+ const first = activeSeenRef.current === null;
189
+ activeSeenRef.current = active;
190
+ if (active) {
191
+ if (autoStart) {
192
+ if (startedRef.current)
193
+ return;
194
+ if (ready) {
195
+ startedRef.current = true;
196
+ void beginCall();
197
+ }
198
+ else
199
+ pendingStartRef.current = true;
200
+ }
201
+ else if (!first) {
202
+ post({ type: "graine:warm" });
203
+ }
204
+ return;
205
+ }
206
+ if (first)
207
+ return;
208
+ if (startedRef.current || pendingStartRef.current)
209
+ post({ type: "graine:end-call" });
210
+ startedRef.current = false;
211
+ pendingStartRef.current = false;
212
+ }, [active]);
183
213
  useEffect(() => {
184
214
  let last = AppState.currentState;
185
215
  let pending = null;
@@ -211,6 +241,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, trans
211
241
  const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
212
242
  const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
213
243
  `?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
244
+ (mountedIdleRef.current ? "&idle=1" : "") +
214
245
  `&pk=${encodeURIComponent(client.opts.publishableKey)}` +
215
246
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
216
247
  return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.27.2",
3
+ "version": "0.27.5",
4
4
  "type": "module",
5
5
  "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
6
6
  "license": "MIT",