@graineai/inapp-react-native 0.4.0 → 0.7.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/dist/client.d.ts CHANGED
@@ -37,6 +37,8 @@ export declare class GraineInAppClient {
37
37
  private bargedIn;
38
38
  private muted;
39
39
  private playoutClock;
40
+ private identity;
41
+ private recentEvents;
40
42
  constructor(opts: GraineInAppOptions);
41
43
  on(event: string, fn: Listener): () => void;
42
44
  private emit;
@@ -51,9 +53,11 @@ export declare class GraineInAppClient {
51
53
  get availableActions(): string[];
52
54
  setScreen(context: ScreenContext | null): void;
53
55
  reportEvent(event: AppEvent): void;
56
+ track(name: string, data?: Record<string, unknown>): void;
54
57
  private scheduleScreen;
55
58
  private flushScreen;
56
59
  private armStall;
60
+ identify(user: Record<string, unknown> | null): void;
57
61
  noteInteraction(): void;
58
62
  say(text: string): boolean;
59
63
  sendAudio(base64: string, sampleRate?: number): boolean;
package/dist/client.js CHANGED
@@ -21,6 +21,8 @@ export class GraineInAppClient {
21
21
  this.bargedIn = false;
22
22
  this.muted = false;
23
23
  this.playoutClock = null;
24
+ this.identity = {};
25
+ this.recentEvents = [];
24
26
  if (!opts?.publishableKey)
25
27
  throw new Error("[Graine] publishableKey is required.");
26
28
  if (!opts?.baseUrl)
@@ -126,7 +128,11 @@ export class GraineInAppClient {
126
128
  event: "start",
127
129
  meta_data: {
128
130
  client: "graine-inapp-sdk",
129
- context_data: { ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}) },
131
+ context_data: {
132
+ ...(this.config.variables ?? {}),
133
+ ...(this.opts.variables ?? {}),
134
+ ...this.identity,
135
+ },
130
136
  },
131
137
  });
132
138
  this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
@@ -263,6 +269,15 @@ export class GraineInAppClient {
263
269
  reportEvent(event) {
264
270
  this.send({ type: "app_event", event });
265
271
  }
272
+ track(name, data) {
273
+ if (!name)
274
+ return;
275
+ this.recentEvents.push({ name, at: Date.now(), ...(data ? { data } : {}) });
276
+ if (this.recentEvents.length > 10)
277
+ this.recentEvents.shift();
278
+ if (this.screen)
279
+ this.scheduleScreen();
280
+ }
266
281
  scheduleScreen() {
267
282
  if (this.contextTimer) {
268
283
  this.pendingScreen = true;
@@ -284,7 +299,12 @@ export class GraineInAppClient {
284
299
  const { availableActions, ...rest } = this.screen;
285
300
  return this.send({
286
301
  type: "app_context",
287
- context: maskDeep({ ...rest, idle_ms: Date.now() - this.screenSince }),
302
+ context: maskDeep({
303
+ ...rest,
304
+ idle_ms: Date.now() - this.screenSince,
305
+ ...(Object.keys(this.identity).length ? { user: this.identity } : {}),
306
+ ...(this.recentEvents.length ? { recent_events: this.recentEvents } : {}),
307
+ }),
288
308
  available_actions: (availableActions ?? this.availableActions).filter((a) => this.actions.has(a)),
289
309
  seq: ++this.screenSeq,
290
310
  });
@@ -310,6 +330,21 @@ export class GraineInAppClient {
310
330
  });
311
331
  }, after);
312
332
  }
333
+ identify(user) {
334
+ if (!user) {
335
+ this.identity = {};
336
+ return;
337
+ }
338
+ const flat = {};
339
+ for (const [k, v] of Object.entries(user)) {
340
+ if (v === undefined || v === null)
341
+ continue;
342
+ flat[k] = typeof v === "string" ? v : JSON.stringify(v);
343
+ }
344
+ this.identity = maskDeep(flat);
345
+ if (this.screen)
346
+ this.scheduleScreen();
347
+ }
313
348
  noteInteraction() {
314
349
  this.screenSince = Date.now();
315
350
  this.armStall();
package/dist/index.d.ts CHANGED
@@ -3,5 +3,6 @@ export { GraineInAppClient, type GraineInAppOptions, type SessionConfig } from "
3
3
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice";
4
4
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio";
5
5
  export { addMaskRule, maskDeep, maskString } from "./mask";
6
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
6
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index";
7
7
  export { GraineAgentBar, GraineLauncher, type GraineAgentBarProps, type GraineLauncherProps } from "./react-native/ui";
8
+ export { LauncherVisibilityTracker, activeRouteName, type LauncherInset, type LauncherContinuity, type LauncherDelayPolicy, type LauncherGroup, type LauncherVisibility, type VisibilityDecision, } from "./react-native/navigation";
package/dist/index.js CHANGED
@@ -2,5 +2,6 @@ export { GraineInAppClient } from "./client";
2
2
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice";
3
3
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio";
4
4
  export { addMaskRule, maskDeep, maskString } from "./mask";
5
- export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, } from "./react-native/index";
5
+ export { GraineProvider, useGraineAgent, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineTrack, } from "./react-native/index";
6
6
  export { GraineAgentBar, GraineLauncher } from "./react-native/ui";
7
+ export { LauncherVisibilityTracker, activeRouteName, } from "./react-native/navigation";
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { GraineInAppClient, type GraineInAppOptions } from "../client";
3
3
  import { type AudioAdapter, type VoiceSessionOptions } from "../voice";
4
4
  import type { ActionHandler, ScreenContext } from "../protocol";
5
+ import { type LauncherInset, type LauncherVisibility } from "./navigation";
5
6
  interface GraineContextValue {
6
7
  client: GraineInAppClient;
7
8
  connected: boolean;
@@ -16,7 +17,38 @@ interface GraineContextValue {
16
17
  setMuted: (muted: boolean) => void;
17
18
  agentSpeaking: boolean;
18
19
  caption: Caption | null;
20
+ launcherVisible: boolean;
21
+ launcherInset: LauncherInset;
22
+ currentScreen: string | null;
23
+ onEvent: (fn: (e: GraineEvent) => void) => () => void;
24
+ track: (name: string, data?: Record<string, unknown>) => void;
25
+ identify: (user: Record<string, unknown> | null) => void;
19
26
  }
27
+ export type GraineEvent = {
28
+ type: "conversation_started";
29
+ } | {
30
+ type: "conversation_ended";
31
+ reason: string;
32
+ } | {
33
+ type: "agent_speaking";
34
+ speaking: boolean;
35
+ } | {
36
+ type: "agent_volunteered";
37
+ text: string;
38
+ } | {
39
+ type: "action_requested";
40
+ name: string;
41
+ } | {
42
+ type: "action_completed";
43
+ name: string;
44
+ status: string;
45
+ } | {
46
+ type: "muted";
47
+ muted: boolean;
48
+ } | {
49
+ type: "error";
50
+ message: string;
51
+ };
20
52
  export interface Caption {
21
53
  role: "agent" | "customer";
22
54
  text: string;
@@ -31,9 +63,18 @@ export interface GraineProviderProps extends GraineInAppOptions {
31
63
  children: React.ReactNode;
32
64
  autoConnect?: boolean;
33
65
  onProactive?: (text: string) => void;
66
+ navigationRef?: {
67
+ current: any;
68
+ } | null;
69
+ includeScreens?: string[];
70
+ launcherDelayMs?: number;
71
+ visibility?: LauncherVisibility;
34
72
  }
35
- export declare function GraineProvider({ children, autoConnect, onProactive, ...options }: GraineProviderProps): React.JSX.Element;
73
+ export declare function GraineProvider({ children, autoConnect, onProactive, navigationRef, includeScreens, launcherDelayMs, visibility, ...options }: GraineProviderProps): React.JSX.Element;
36
74
  export declare function useGraineAgent(): GraineContextValue;
75
+ export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
76
+ export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
77
+ export declare function useGraineIdentify(): (user: Record<string, unknown> | null) => void;
37
78
  export declare function useGraineVoice(adapter: AudioAdapter | null, options?: VoiceSessionOptions): {
38
79
  listening: boolean;
39
80
  agentSpeaking: boolean;
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { createContext, useContext, useEffect, useMemo, useRef, useState, } from "react";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
3
3
  import { GraineInAppClient } from "../client";
4
4
  import { VoiceSession } from "../voice";
5
+ import { LauncherVisibilityTracker, activeRouteName, } from "./navigation";
5
6
  const Ctx = createContext(null);
6
- export function GraineProvider({ children, autoConnect = true, onProactive, ...options }) {
7
+ export function GraineProvider({ children, autoConnect = true, onProactive, navigationRef, includeScreens, launcherDelayMs = 0, visibility, ...options }) {
7
8
  const clientRef = useRef(null);
8
9
  if (!clientRef.current)
9
10
  clientRef.current = new GraineInAppClient(options);
@@ -19,6 +20,81 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
19
20
  const [caption, setCaption] = useState(null);
20
21
  const proactiveRef = useRef(onProactive);
21
22
  proactiveRef.current = onProactive;
23
+ const listenersRef = useRef(new Set());
24
+ const emitEvent = useCallback((e) => {
25
+ listenersRef.current.forEach((fn) => {
26
+ try {
27
+ fn(e);
28
+ }
29
+ catch { }
30
+ });
31
+ }, []);
32
+ const onEvent = useCallback((fn) => {
33
+ listenersRef.current.add(fn);
34
+ return () => { listenersRef.current.delete(fn); };
35
+ }, []);
36
+ const [currentScreen, setCurrentScreen] = useState(null);
37
+ const [launcherVisible, setLauncherVisible] = useState(false);
38
+ const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
39
+ const trackerRef = useRef(null);
40
+ if (!trackerRef.current) {
41
+ trackerRef.current = new LauncherVisibilityTracker(includeScreens, visibility, launcherDelayMs);
42
+ }
43
+ const showTimerRef = useRef(null);
44
+ useEffect(() => {
45
+ const nav = navigationRef?.current;
46
+ if (!nav || typeof nav.addListener !== "function")
47
+ return;
48
+ const apply = () => {
49
+ let route = null;
50
+ try {
51
+ route = activeRouteName(nav.getRootState?.());
52
+ }
53
+ catch {
54
+ route = null;
55
+ }
56
+ if (!route)
57
+ return;
58
+ setCurrentScreen(route);
59
+ try {
60
+ client.setScreen({ screen: route, title: route });
61
+ }
62
+ catch { }
63
+ const decision = trackerRef.current.onRoute(route);
64
+ setLauncherInset(decision.inset);
65
+ if (showTimerRef.current) {
66
+ clearTimeout(showTimerRef.current);
67
+ showTimerRef.current = null;
68
+ }
69
+ if (!decision.eligible) {
70
+ setLauncherVisible(false);
71
+ return;
72
+ }
73
+ if (decision.delayMs <= 0) {
74
+ setLauncherVisible(true);
75
+ return;
76
+ }
77
+ setLauncherVisible(false);
78
+ showTimerRef.current = setTimeout(() => {
79
+ showTimerRef.current = null;
80
+ setLauncherVisible(true);
81
+ }, decision.delayMs);
82
+ };
83
+ apply();
84
+ const off = nav.addListener("state", apply);
85
+ return () => {
86
+ if (showTimerRef.current)
87
+ clearTimeout(showTimerRef.current);
88
+ if (typeof off === "function")
89
+ off();
90
+ else if (typeof nav.removeListener === "function")
91
+ nav.removeListener("state", apply);
92
+ };
93
+ }, [navigationRef, client]);
94
+ useEffect(() => {
95
+ if (!navigationRef?.current)
96
+ setLauncherVisible(true);
97
+ }, [navigationRef]);
22
98
  const openRef = useRef(open);
23
99
  openRef.current = open;
24
100
  useEffect(() => {
@@ -27,8 +103,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
27
103
  setConnected(true);
28
104
  setConnecting(false);
29
105
  setError(null);
106
+ emitEvent({ type: "conversation_started" });
107
+ }),
108
+ client.on("close", (info) => {
109
+ setConnected(false);
110
+ emitEvent({ type: "conversation_ended", reason: info?.reason || String(info?.code ?? "closed") });
30
111
  }),
31
- client.on("close", () => setConnected(false)),
32
112
  client.on("chunk", (text) => setMessages((prev) => {
33
113
  const last = prev[prev.length - 1];
34
114
  if (last?.role === "agent" && last.live) {
@@ -36,18 +116,24 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
36
116
  }
37
117
  if (prev[prev.length - 1]?.role !== "customer") {
38
118
  proactiveRef.current?.(text);
119
+ emitEvent({ type: "agent_volunteered", text });
39
120
  if (!openRef.current)
40
121
  setOpen(true);
41
122
  }
42
123
  return [...prev, { role: "agent", text, live: true }];
43
124
  })),
44
125
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
45
- client.on("muted", (m) => setMutedState(m)),
126
+ client.on("muted", (m) => {
127
+ setMutedState(m);
128
+ emitEvent({ type: "muted", muted: m });
129
+ }),
46
130
  client.on("agent_speaking", (speaking) => {
47
131
  setAgentSpeaking(speaking);
48
132
  if (speaking)
49
133
  setCaption(null);
134
+ emitEvent({ type: "agent_speaking", speaking });
50
135
  }),
136
+ client.on("action", (req) => emitEvent({ type: "action_requested", name: req?.name ?? "" })),
51
137
  client.on("transcript", ({ text, final }) => {
52
138
  if (text)
53
139
  setCaption({ role: "customer", text, live: !final });
@@ -92,7 +178,14 @@ export function GraineProvider({ children, autoConnect = true, onProactive, ...o
92
178
  client.noteInteraction();
93
179
  client.say(text);
94
180
  },
95
- }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption]);
181
+ launcherVisible,
182
+ launcherInset,
183
+ currentScreen,
184
+ onEvent,
185
+ track: (name, data) => client.track(name, data),
186
+ identify: (user) => client.identify(user),
187
+ }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
188
+ launcherVisible, launcherInset, currentScreen, onEvent]);
96
189
  return _jsx(Ctx.Provider, { value: value, children: children });
97
190
  }
98
191
  function useGraine() {
@@ -104,6 +197,20 @@ function useGraine() {
104
197
  export function useGraineAgent() {
105
198
  return useGraine();
106
199
  }
200
+ export function useGraineEvents(handler) {
201
+ const { onEvent } = useGraine();
202
+ const ref = useRef(handler);
203
+ ref.current = handler;
204
+ useEffect(() => onEvent((e) => ref.current(e)), [onEvent]);
205
+ }
206
+ export function useGraineTrack() {
207
+ const { track } = useGraine();
208
+ return track;
209
+ }
210
+ export function useGraineIdentify() {
211
+ const { client } = useGraine();
212
+ return useCallback((user) => client.identify(user), [client]);
213
+ }
107
214
  export function useGraineVoice(adapter, options = {}) {
108
215
  const { client } = useGraine();
109
216
  const [listening, setListening] = useState(false);
@@ -0,0 +1,44 @@
1
+ export interface LauncherInset {
2
+ top?: number;
3
+ right?: number;
4
+ bottom?: number;
5
+ left?: number;
6
+ }
7
+ export type LauncherContinuity = "continuous" | "perScreen";
8
+ export type LauncherDelayPolicy = "perScreen" | "oncePerGroupEntry" | "oncePerAppSession";
9
+ export interface LauncherGroup {
10
+ id: string;
11
+ screens: string[];
12
+ continuity: LauncherContinuity;
13
+ inset?: LauncherInset;
14
+ delayMs?: number;
15
+ delayPolicy?: LauncherDelayPolicy;
16
+ }
17
+ export interface LauncherVisibility {
18
+ defaultDelayMs?: number;
19
+ defaultInset?: LauncherInset;
20
+ groups?: LauncherGroup[];
21
+ }
22
+ export interface VisibilityDecision {
23
+ eligible: boolean;
24
+ delayMs: number;
25
+ inset: LauncherInset;
26
+ groupId: string | null;
27
+ continuous: boolean;
28
+ }
29
+ export declare class LauncherVisibilityTracker {
30
+ private includeScreens;
31
+ private visibility;
32
+ private defaultDelayMs;
33
+ private enteredGroups;
34
+ private sessionDelayedGroups;
35
+ private lastGroupId;
36
+ private lastRoute;
37
+ constructor(includeScreens: string[] | undefined, visibility: LauncherVisibility | undefined, defaultDelayMs?: number);
38
+ reset(): void;
39
+ private groupFor;
40
+ private isEligible;
41
+ onRoute(route: string): VisibilityDecision;
42
+ }
43
+ export declare function activeRouteName(state: any): string | null;
44
+ export declare function routeParams(state: any): Record<string, unknown> | undefined;
@@ -0,0 +1,98 @@
1
+ const DEFAULT_INSET = { right: 16, bottom: 20 };
2
+ export class LauncherVisibilityTracker {
3
+ constructor(includeScreens, visibility, defaultDelayMs = 0) {
4
+ this.includeScreens = includeScreens;
5
+ this.visibility = visibility;
6
+ this.defaultDelayMs = defaultDelayMs;
7
+ this.enteredGroups = new Set();
8
+ this.sessionDelayedGroups = new Set();
9
+ this.lastGroupId = null;
10
+ this.lastRoute = null;
11
+ }
12
+ reset() {
13
+ this.enteredGroups.clear();
14
+ this.sessionDelayedGroups.clear();
15
+ this.lastGroupId = null;
16
+ this.lastRoute = null;
17
+ }
18
+ groupFor(route) {
19
+ for (const g of this.visibility?.groups || []) {
20
+ if (g.screens.includes(route))
21
+ return g;
22
+ }
23
+ return null;
24
+ }
25
+ isEligible(route) {
26
+ const groups = this.visibility?.groups || [];
27
+ const hasAllowlist = Array.isArray(this.includeScreens) && this.includeScreens.length > 0;
28
+ if (!hasAllowlist && groups.length === 0)
29
+ return true;
30
+ if (hasAllowlist && this.includeScreens.includes(route))
31
+ return true;
32
+ return groups.some((g) => g.screens.includes(route));
33
+ }
34
+ onRoute(route) {
35
+ const eligible = this.isEligible(route);
36
+ const group = this.groupFor(route);
37
+ const inset = group?.inset || this.visibility?.defaultInset || DEFAULT_INSET;
38
+ if (!eligible) {
39
+ this.lastGroupId = null;
40
+ this.lastRoute = route;
41
+ return { eligible: false, delayMs: 0, inset, groupId: group?.id ?? null, continuous: false };
42
+ }
43
+ const configuredDelay = group?.delayMs ?? this.visibility?.defaultDelayMs ?? this.defaultDelayMs;
44
+ const policy = group?.delayPolicy ?? "perScreen";
45
+ const continuity = group?.continuity ?? "perScreen";
46
+ const stayingInGroup = group != null && this.lastGroupId === group.id;
47
+ const movedScreen = this.lastRoute !== route;
48
+ const continuous = continuity === "continuous" && stayingInGroup && movedScreen;
49
+ let delayMs = configuredDelay;
50
+ if (continuous) {
51
+ delayMs = 0;
52
+ }
53
+ else if (group) {
54
+ if (policy === "oncePerGroupEntry" && stayingInGroup) {
55
+ delayMs = 0;
56
+ }
57
+ else if (policy === "oncePerAppSession") {
58
+ delayMs = this.sessionDelayedGroups.has(group.id) ? 0 : configuredDelay;
59
+ this.sessionDelayedGroups.add(group.id);
60
+ }
61
+ if (!stayingInGroup)
62
+ this.enteredGroups.add(group.id);
63
+ }
64
+ this.lastGroupId = group?.id ?? null;
65
+ this.lastRoute = route;
66
+ return { eligible: true, delayMs, inset, groupId: group?.id ?? null, continuous };
67
+ }
68
+ }
69
+ export function activeRouteName(state) {
70
+ if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
71
+ return null;
72
+ let route = state.routes[state.index];
73
+ while (route?.state) {
74
+ const child = route.state;
75
+ if (!Array.isArray(child.routes))
76
+ break;
77
+ const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
78
+ const next = child.routes[idx];
79
+ if (!next)
80
+ break;
81
+ route = next;
82
+ }
83
+ return route?.name ?? null;
84
+ }
85
+ export function routeParams(state) {
86
+ if (!state || !Array.isArray(state.routes) || typeof state.index !== "number")
87
+ return undefined;
88
+ let route = state.routes[state.index];
89
+ while (route?.state?.routes) {
90
+ const child = route.state;
91
+ const idx = typeof child.index === "number" ? child.index : child.routes.length - 1;
92
+ const next = child.routes[idx];
93
+ if (!next)
94
+ break;
95
+ route = next;
96
+ }
97
+ return route?.params && typeof route.params === "object" ? route.params : undefined;
98
+ }
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from "react-native";
4
4
  import { useGraineAgent } from "./index";
5
5
  export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", avatarUrl, bottomInset = 96, hidden = false, captionsOn = false, }) {
6
- const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking } = useGraineAgent();
6
+ const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, } = useGraineAgent();
7
7
  const [expanded, setExpanded] = useState(false);
8
8
  const [captions, setCaptions] = useState(captionsOn);
9
9
  const [draft, setDraft] = useState("");
@@ -31,9 +31,16 @@ export function GraineAgentBar({ accent = "#318CE7", name = "Assistant", avatarU
31
31
  setDraft("");
32
32
  send(text);
33
33
  }, [draft, send]);
34
- if (hidden || error)
34
+ if (hidden || error || !launcherVisible)
35
35
  return null;
36
- return (_jsxs(View, { pointerEvents: "box-none", style: [styles.root, { bottom: bottomInset }], 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: [
36
+ return (_jsxs(View, { pointerEvents: "box-none", style: [
37
+ styles.root,
38
+ {
39
+ bottom: launcherInset.bottom ?? bottomInset,
40
+ right: launcherInset.right ?? 12,
41
+ left: launcherInset.left ?? 12,
42
+ },
43
+ ], 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: [
37
44
  styles.bubble,
38
45
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
39
46
  ], 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 ? "⌄" : "⌃" }) })] })] }));
package/dist/voice.d.ts CHANGED
@@ -22,9 +22,11 @@ export declare class VoiceSession {
22
22
  private format;
23
23
  private echo;
24
24
  private agentAudible;
25
+ private lastAudioAt;
25
26
  constructor(client: GraineInAppClient, adapter: AudioAdapter, options?: VoiceSessionOptions);
26
27
  get active(): boolean;
27
28
  start(): Promise<void>;
29
+ private agentIsAudible;
28
30
  stop(): Promise<void>;
29
31
  }
30
32
  export declare function liveAudioStreamAdapter(LiveAudioStream: any, sink: Pick<AudioAdapter, "play" | "clear">): AudioAdapter;
package/dist/voice.js CHANGED
@@ -14,6 +14,7 @@ export class VoiceSession {
14
14
  this.format = null;
15
15
  this.echo = new EchoGuard();
16
16
  this.agentAudible = false;
17
+ this.lastAudioAt = 0;
17
18
  }
18
19
  get active() {
19
20
  return this.capturing;
@@ -29,6 +30,7 @@ export class VoiceSession {
29
30
  return;
30
31
  if (decoded.latch)
31
32
  this.format = decoded.latch;
33
+ this.lastAudioAt = Date.now();
32
34
  this.adapter.play(decoded.pcm16, decoded.sampleRate);
33
35
  }),
34
36
  this.client.on("clear", () => {
@@ -51,19 +53,24 @@ export class VoiceSession {
51
53
  await this.adapter.startCapture((base64) => {
52
54
  const pcm = base64ToPcm16(base64);
53
55
  const frameMs = (pcm.length / CAPTURE_SAMPLE_RATE) * 1000;
54
- const buffered = this.adapter.bufferedSeconds?.();
55
- const audible = buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
56
- for (const frame of this.echo.filter(base64, pcm, audible, frameMs)) {
56
+ for (const frame of this.echo.filter(base64, pcm, this.agentIsAudible(), frameMs)) {
57
57
  this.client.sendAudio(frame, CAPTURE_SAMPLE_RATE);
58
58
  }
59
59
  });
60
60
  }
61
+ agentIsAudible() {
62
+ if (Date.now() - this.lastAudioAt > 1500)
63
+ return false;
64
+ const buffered = this.adapter.bufferedSeconds?.();
65
+ return buffered !== undefined ? buffered > 0.02 || this.agentAudible : this.agentAudible;
66
+ }
61
67
  async stop() {
62
68
  if (!this.capturing)
63
69
  return;
64
70
  this.capturing = false;
65
71
  this.format = null;
66
72
  this.agentAudible = false;
73
+ this.lastAudioAt = 0;
67
74
  this.echo.reset();
68
75
  this.offs.forEach((off) => off());
69
76
  this.offs = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.4.0",
3
+ "version": "0.7.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,