@graineai/inapp-react-native 0.27.1 → 0.27.4

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
@@ -518,6 +518,42 @@ your funnel.
518
518
 
519
519
  ---
520
520
 
521
+ ## Tap capture when your screens sit above the provider
522
+
523
+ `GraineProvider` wraps its children in `GraineTouchCapture`, so an app whose
524
+ whole tree hangs below the provider gets best-effort tap tracking for free
525
+ (`captureTaps`, on by default with `fab`).
526
+
527
+ That covers fewer apps than it sounds. An app that builds its client at startup
528
+ and registers it with `setDefaultClient` can mount the provider anywhere — often
529
+ deep, around a call bar or a single screen — while the screens the customer
530
+ actually taps sit *above* it. Capture inside the provider then wraps the bar's
531
+ own chrome and sees none of the taps that matter.
532
+
533
+ For that layout, mount capture at your own root. Both components are exported
534
+ and work without a provider above them: the client resolves through
535
+ `setDefaultClient`, and the launcher-visibility gate simply does not apply when
536
+ there is no provider context to read it from.
537
+
538
+ ```tsx
539
+ import { GraineTouchCapture } from "@graineai/inapp-react-native";
540
+
541
+ export default function App() {
542
+ return (
543
+ <SafeAreaProvider>
544
+ <GraineTouchCapture>
545
+ <YourNavigator />
546
+ </GraineTouchCapture>
547
+ </SafeAreaProvider>
548
+ );
549
+ }
550
+ ```
551
+
552
+ Taps land in the screen frame as `tap:screen` events with the tap's position;
553
+ the runtime reads them as "tapped the screen" and Call History shows them on the
554
+ call's timeline. Named taps from `useGraineTap` are unaffected and still carry
555
+ their names. (0.27.4 — earlier versions did not export `GraineTouchCapture`.)
556
+
521
557
  ## Controlling where the launcher appears
522
558
 
523
559
  `includeScreens` for a simple allowlist:
@@ -682,6 +718,42 @@ for you on unmount and on backgrounding.
682
718
 
683
719
  ---
684
720
 
721
+ ## How long "Connecting…" takes, and why
722
+
723
+ From the tap to the agent's first word there are four waits. Three of them
724
+ are paid before the tap on a current SDK; the fourth is bounded. If the
725
+ launcher sits on "Connecting…" for more than about two seconds, one of these
726
+ is not happening, and the table says which.
727
+
728
+ | Wait | What it is | When it is paid | Typical |
729
+ |---|---|---|---|
730
+ | Page load | The voice engine is a page in a WebView | **Before the tap** — `GraineFab` and the provider mount the engine on open and keep it warm (0.27.2+) | ~4s, hidden |
731
+ | Mint + REGISTER | A 120-second SIP credential, then registration on the SBC | **Before the tap**, re-warmed as it nears expiry | ~1.6s, hidden |
732
+ | Microphone | The OS permission prompt, first time only | At the tap | 0 once granted |
733
+ | INVITE → answer | The call itself | At the tap | ~0.3s |
734
+
735
+ **The INVITE is bounded, not left to the browser.** WebRTC gathers network
736
+ candidates before dialling, and Chrome does not report gathering complete
737
+ until STUN has been tried on *every* interface a device has — a VPN tunnel,
738
+ a link-local IPv6 address, a cellular path the app may not use. Those never
739
+ answer, and Chrome retries each for about forty seconds before giving up.
740
+ Measured on the embed page with the SIP frames timestamped: registered at
741
+ 8.2s, the usable candidate in hand at 8.3s, the INVITE not sent until 48.3s,
742
+ then answered in 300ms. On a phone with Wi-Fi and cellular that is the
743
+ ten-second "Connecting…" that was reported. The engine now dials the moment a
744
+ server-reflexive candidate exists, or 1.5 seconds after gathering starts if
745
+ none arrives — a network with no STUN at all still gets a call,
746
+ with host candidates, rather than the pause and then the same call.
747
+
748
+ **On an app that is still on 0.26.x** the first two rows are paid *after* the
749
+ tap: the page loads and registers only once the customer has pressed the
750
+ microphone, so "Connecting…" is honest and about six seconds long. Moving to
751
+ 0.27.4 is what hides them. The dial bound needs no app change — it lives in
752
+ the engine page, so every install gets it when the page is deployed.
753
+
754
+ What "Connecting…" should look like on a current app, then: a tap, the
755
+ microphone prompt once, and the greeting inside a second.
756
+
685
757
  ## Troubleshooting
686
758
 
687
759
  **Nothing renders at all, and the console says `no_origin`.**
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
  }
@@ -19,5 +19,6 @@ export interface GraineFabProps {
19
19
  }>;
20
20
  onOpen?: () => void;
21
21
  onClose?: () => void;
22
+ prewarm?: boolean;
22
23
  }
23
- export declare function GraineFab({ webView, position, size, accent: accentProp, label, overlay, onOpen, onClose }: GraineFabProps): React.JSX.Element | null;
24
+ export declare function GraineFab({ webView, position, size, accent: accentProp, label, overlay, prewarm, onOpen, onClose }: GraineFabProps): React.JSX.Element;
@@ -53,23 +53,26 @@ export function GraineTouchCapture({ children, enabled = true, style }) {
53
53
  }, [client, ctx, enabled]);
54
54
  return (_jsx(View, { style: [{ flex: 1 }, style], onTouchStart: onTouchStart, onTouchEnd: onTouchEnd, children: children }));
55
55
  }
56
- export function GraineFab({ webView, position = "bottom-right", size = 56, accent: accentProp, label, overlay, onOpen, onClose }) {
56
+ export function GraineFab({ webView, position = "bottom-right", size = 56, accent: accentProp, label, overlay, prewarm = true, onOpen, onClose }) {
57
57
  const { launcherVisible, launcherInset, appearance, error } = useGraineAgent();
58
58
  const [open, setOpen] = useState(false);
59
59
  const dashboard = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "Assistant", radius: 18 });
60
60
  const accent = accentProp ?? dashboard.accent;
61
61
  const name = dashboard.heading;
62
- if (error || !launcherVisible)
63
- return null;
64
- if (open) {
65
- return (_jsx(AboveNativeScreens, { overlay: overlay, children: _jsx(GraineVoiceLauncher, { webView: webView, autoStart: true, onEnded: () => { setOpen(false); onClose?.(); }, children: () => _jsx(GraineAgentBar, { accent: accent, name: name }) }) }));
66
- }
67
- const side = position === "bottom-left" ? { left: launcherInset.right } : { right: launcherInset.right };
68
- return (_jsx(AboveNativeScreens, { overlay: overlay, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label ?? `Talk to ${name}`, onPress: () => { setOpen(true); onOpen?.(); }, style: ({ pressed }) => ({
62
+ const dashboardAccent = accentProp ?? dashboard.accent;
63
+ const button = (api) => {
64
+ if (error || !launcherVisible)
65
+ return null;
66
+ const side = position === "bottom-left" ? { left: launcherInset.right } : { right: launcherInset.right };
67
+ return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label ?? `Talk to ${name}`, onPress: () => { setOpen(true); api.start(); onOpen?.(); }, style: ({ pressed }) => ({
69
68
  position: "absolute", bottom: launcherInset.bottom, ...side,
70
- width: size, height: size, borderRadius: size / 2, backgroundColor: accent,
69
+ width: size, height: size, borderRadius: size / 2, backgroundColor: dashboardAccent,
71
70
  alignItems: "center", justifyContent: "center",
72
71
  opacity: pressed ? 0.85 : 1,
73
72
  elevation: 6, shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 4 },
74
- }), children: dashboard.logoUrl ? (_jsx(Image, { source: { uri: dashboard.logoUrl }, style: { width: size * 0.6, height: size * 0.6, borderRadius: size * 0.3 } })) : (_jsx(Text, { style: { color: "#fff", fontSize: size * 0.4, fontWeight: "700" }, children: name.trim().charAt(0).toUpperCase() || "•" })) }) }));
73
+ }), children: dashboard.logoUrl ? (_jsx(Image, { source: { uri: dashboard.logoUrl }, style: { width: size * 0.6, height: size * 0.6, borderRadius: size * 0.3 } })) : (_jsx(Text, { style: { color: "#fff", fontSize: size * 0.4, fontWeight: "700" }, children: name.trim().charAt(0).toUpperCase() || "•" })) }));
74
+ };
75
+ if (!prewarm && !open)
76
+ return _jsx(AboveNativeScreens, { overlay: overlay, children: button({ start: () => { } }) });
77
+ return (_jsx(GraineVoiceLauncher, { webView: webView, onEnded: () => { setOpen(false); onClose?.(); }, children: (api) => (_jsx(AboveNativeScreens, { overlay: overlay, children: open ? _jsx(GraineAgentBar, { accent: dashboardAccent, name: name }) : button(api) })) }));
75
78
  }
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.27.1",
3
+ "version": "0.27.4",
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",