@graineai/inapp-react-native 0.34.0 → 0.35.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/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField, type AgentSurface } from "./protocol.js";
1
+ import { type ConnectionQuality, type QualitySample, type ActionHandler, type AppActionResult, type AppEvent, type ScreenContext, type ScreenField, type AgentSurface } from "./protocol.js";
2
2
  import { type FrictionConfig } from "./friction.js";
3
3
  export declare const SUBPROTOCOL = "graine.embed.v1";
4
4
  export interface GraineInAppOptions {
@@ -25,6 +25,7 @@ export interface SessionConfig {
25
25
  name?: string;
26
26
  }>;
27
27
  friction?: FrictionConfig;
28
+ pageVersion?: string;
28
29
  }
29
30
  export interface ActionDiagnosis {
30
31
  usable: string[];
@@ -56,6 +57,16 @@ export declare class GraineInAppClient {
56
57
  private muted;
57
58
  private playoutClock;
58
59
  private identity;
60
+ private runtimeVariables;
61
+ private quality;
62
+ private network;
63
+ allVariables(): Record<string, string>;
64
+ getVariables(): Record<string, string>;
65
+ setVariables(vars: Record<string, unknown> | null): void;
66
+ get connectionQuality(): ConnectionQuality;
67
+ get networkState(): "online" | "offline" | "unknown";
68
+ reportQuality(sample: QualitySample | null): ConnectionQuality;
69
+ reportNetwork(online: boolean): void;
59
70
  private appVersion;
60
71
  private recentEvents;
61
72
  constructor(opts: GraineInAppOptions);
package/dist/client.js CHANGED
@@ -1,9 +1,66 @@
1
- import { contextToken, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
1
+ import { contextToken, readAgentChunk, variablesForTicket, classifyQuality, ACTION_DEADLINE_MS, CONTEXT_THROTTLE_MS, } from "./protocol.js";
2
2
  import { maskDeep } from "./mask.js";
3
3
  import { FrictionDetector, resolveFriction } from "./friction.js";
4
4
  export const SUBPROTOCOL = "graine.embed.v1";
5
5
  const PING_MS = 25000;
6
6
  export class GraineInAppClient {
7
+ allVariables() {
8
+ return {
9
+ ...(this.config?.variables ?? {}),
10
+ ...(this.opts.variables ?? {}),
11
+ ...this.runtimeVariables,
12
+ ...this.identity,
13
+ };
14
+ }
15
+ getVariables() {
16
+ return this.allVariables();
17
+ }
18
+ setVariables(vars) {
19
+ if (!vars) {
20
+ this.runtimeVariables = {};
21
+ }
22
+ else {
23
+ const next = { ...this.runtimeVariables };
24
+ for (const [k, v] of Object.entries(vars)) {
25
+ if (!k)
26
+ continue;
27
+ if (v === null || v === undefined) {
28
+ delete next[k];
29
+ continue;
30
+ }
31
+ if (typeof v === "object")
32
+ continue;
33
+ next[k] = String(v);
34
+ }
35
+ this.runtimeVariables = maskDeep(next);
36
+ }
37
+ this.emit("variables", this.allVariables());
38
+ }
39
+ get connectionQuality() {
40
+ return this.quality;
41
+ }
42
+ get networkState() {
43
+ return this.network;
44
+ }
45
+ reportQuality(sample) {
46
+ const next = sample ? classifyQuality(sample) : "unknown";
47
+ if (next !== this.quality) {
48
+ this.quality = next;
49
+ this.emit("quality", { quality: next, ...(sample ?? {}) });
50
+ }
51
+ return next;
52
+ }
53
+ reportNetwork(online) {
54
+ const next = online ? "online" : "offline";
55
+ if (next === this.network)
56
+ return;
57
+ this.network = next;
58
+ if (!online && this.quality !== "lost") {
59
+ this.quality = "lost";
60
+ this.emit("quality", { quality: "lost" });
61
+ }
62
+ this.emit("network", { online });
63
+ }
7
64
  constructor(opts) {
8
65
  this.config = null;
9
66
  this.transport = null;
@@ -26,6 +83,9 @@ export class GraineInAppClient {
26
83
  this.muted = false;
27
84
  this.playoutClock = null;
28
85
  this.identity = {};
86
+ this.runtimeVariables = {};
87
+ this.quality = "unknown";
88
+ this.network = "unknown";
29
89
  this.appVersion = null;
30
90
  this.recentEvents = [];
31
91
  this.lastWidget = null;
@@ -161,6 +221,7 @@ export class GraineInAppClient {
161
221
  body: JSON.stringify({
162
222
  publishableKey: this.opts.publishableKey,
163
223
  mode: this.socketIsVoice ? "voice" : "text",
224
+ metadata: variablesForTicket(this.allVariables()),
164
225
  }),
165
226
  });
166
227
  if (!res.ok)
@@ -200,7 +261,7 @@ export class GraineInAppClient {
200
261
  url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
201
262
  this.transport = "direct";
202
263
  }
203
- const token = contextToken({ ...(this.config.variables ?? {}), ...(this.opts.variables ?? {}), ...this.identity }, this.socketIsVoice ? "webcall" : "text_chat");
264
+ const token = contextToken(this.allVariables(), this.socketIsVoice ? "webcall" : "text_chat");
204
265
  if (token)
205
266
  url += `${url.includes("?") ? "&" : "?"}context_token=${encodeURIComponent(token)}`;
206
267
  const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
@@ -227,11 +288,7 @@ export class GraineInAppClient {
227
288
  event: "start",
228
289
  meta_data: {
229
290
  client: "graine-inapp-sdk",
230
- context_data: {
231
- ...(this.config.variables ?? {}),
232
- ...(this.opts.variables ?? {}),
233
- ...this.identity,
234
- },
291
+ context_data: this.allVariables(),
235
292
  },
236
293
  });
237
294
  this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
@@ -260,9 +317,20 @@ export class GraineInAppClient {
260
317
  return;
261
318
  }
262
319
  switch (msg.type) {
263
- case "text":
264
- this.emit("chunk", msg.data);
320
+ case "text": {
321
+ const chunk = readAgentChunk(msg.data);
322
+ if (chunk.startsTurn && !this.agentSpeaking) {
323
+ this.agentSpeaking = true;
324
+ this.emit("agent_speaking", true);
325
+ }
326
+ if (chunk.text)
327
+ this.emit("chunk", chunk.text);
328
+ if (chunk.endsTurn && this.agentSpeaking) {
329
+ this.agentSpeaking = false;
330
+ this.emit("agent_speaking", false);
331
+ }
265
332
  break;
333
+ }
266
334
  case "widget":
267
335
  this.emit("widget", msg.widget);
268
336
  break;
@@ -554,6 +622,7 @@ export class GraineInAppClient {
554
622
  identify(user) {
555
623
  if (!user) {
556
624
  this.identity = {};
625
+ this.emit("variables", this.allVariables());
557
626
  return;
558
627
  }
559
628
  const flat = {};
@@ -563,6 +632,7 @@ export class GraineInAppClient {
563
632
  flat[k] = typeof v === "string" ? v : JSON.stringify(v);
564
633
  }
565
634
  this.identity = maskDeep(flat);
635
+ this.emit("variables", this.allVariables());
566
636
  this.scheduleScreen();
567
637
  }
568
638
  noteInteraction() {
package/dist/index.d.ts CHANGED
@@ -9,6 +9,8 @@ export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
9
9
  export { contextToken } from "./protocol.js";
10
10
  export { resolveSurface } from "./protocol.js";
11
11
  export type { AgentSurface, SurfacePolicy } from "./protocol.js";
12
+ export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
13
+ export type { ConnectionQuality, QualitySample, AgentChunk } from "./protocol.js";
12
14
  export type { WidgetSelect } from "./protocol.js";
13
15
  export type { WidgetSelectAction } from "./protocol.js";
14
16
  export { GraineWidget } from "./react-native/widget.js";
@@ -21,7 +23,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
21
23
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
22
24
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
23
25
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
24
- export { GraineProvider, useGraineAgent, useGraineSurface, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
26
+ export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, type GraineEvent, type GraineProviderProps, type Turn, type Caption, } from "./react-native/index.js";
25
27
  export { requestMicrophonePermission } from "./react-native/permissions.js";
26
28
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
27
29
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall,
5
5
  export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
6
6
  export { contextToken } from "./protocol.js";
7
7
  export { resolveSurface } from "./protocol.js";
8
+ export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
8
9
  export { GraineWidget } from "./react-native/widget.js";
9
10
  export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
10
11
  export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
@@ -13,7 +14,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
13
14
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
14
15
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
15
16
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
16
- export { GraineProvider, useGraineAgent, useGraineSurface, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
17
+ export { GraineProvider, useGraineAgent, useGraineSurface, useGraineConnection, useGraineVariables, useKeyboardLift, liftFor, useDraggablePlacement, clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX, useGraineScreen, useGraineAction, useGraineVoice, useGraineIdentify, useGraineEvents, useGraineField, useGraineClient, setDefaultClient, useGraineTrack, useGraineTap, useGraineHighlight, useGraineWidget, useGraineReady, useGraineInit, } from "./react-native/index.js";
17
18
  export { requestMicrophonePermission } from "./react-native/permissions.js";
18
19
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
19
20
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
@@ -187,3 +187,25 @@ export interface SurfacePolicy {
187
187
  canSwitch: boolean;
188
188
  }
189
189
  export declare function resolveSurface(appearance?: Record<string, any> | null, prefer?: AgentSurface | null): SurfacePolicy;
190
+ export declare const STREAM_START = "<beginning_of_stream>";
191
+ export declare const STREAM_END = "<end_of_stream>";
192
+ export interface AgentChunk {
193
+ text: string;
194
+ startsTurn: boolean;
195
+ endsTurn: boolean;
196
+ }
197
+ export declare function readAgentChunk(raw: unknown): AgentChunk;
198
+ export type ConnectionQuality = "excellent" | "good" | "poor" | "lost" | "unknown";
199
+ export interface QualitySample {
200
+ rtt?: number | null;
201
+ jitter?: number | null;
202
+ loss?: number | null;
203
+ }
204
+ export declare function classifyQuality(s: QualitySample | null | undefined): ConnectionQuality;
205
+ export declare const TICKET_VARIABLE_LIMITS: {
206
+ readonly keys: 12;
207
+ readonly keyLength: 40;
208
+ readonly valueLength: 200;
209
+ readonly totalBytes: 1024;
210
+ };
211
+ export declare function variablesForTicket(vars: Record<string, unknown> | null | undefined): Record<string, string> | undefined;
package/dist/protocol.js CHANGED
@@ -320,3 +320,72 @@ export function resolveSurface(appearance, prefer) {
320
320
  : allowed[0];
321
321
  return { allowed, start, canSwitch: allowed.length > 1 };
322
322
  }
323
+ export const STREAM_START = "<beginning_of_stream>";
324
+ export const STREAM_END = "<end_of_stream>";
325
+ export function readAgentChunk(raw) {
326
+ const text = raw == null ? "" : String(raw);
327
+ if (!text)
328
+ return { text: "", startsTurn: false, endsTurn: false };
329
+ if (text.indexOf("<") === -1)
330
+ return { text, startsTurn: false, endsTurn: false };
331
+ const lower = text.toLowerCase();
332
+ const startsTurn = lower.includes(STREAM_START);
333
+ const endsTurn = lower.includes(STREAM_END);
334
+ if (!startsTurn && !endsTurn)
335
+ return { text, startsTurn: false, endsTurn: false };
336
+ return {
337
+ text: text.replace(STREAM_START_RE, "").replace(STREAM_END_RE, ""),
338
+ startsTurn,
339
+ endsTurn,
340
+ };
341
+ }
342
+ const STREAM_START_RE = /<beginning_of_stream>/gi;
343
+ const STREAM_END_RE = /<end_of_stream>/gi;
344
+ export function classifyQuality(s) {
345
+ if (!s)
346
+ return "unknown";
347
+ const rtt = num(s.rtt), jitter = num(s.jitter), loss = num(s.loss);
348
+ if (rtt === null && jitter === null && loss === null)
349
+ return "unknown";
350
+ if ((rtt ?? 0) >= 400 || (jitter ?? 0) >= 80 || (loss ?? 0) >= 0.08)
351
+ return "poor";
352
+ if ((rtt ?? 0) >= 150 || (jitter ?? 0) >= 30 || (loss ?? 0) >= 0.02)
353
+ return "good";
354
+ return "excellent";
355
+ }
356
+ function num(v) {
357
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : null;
358
+ }
359
+ export const TICKET_VARIABLE_LIMITS = { keys: 12, keyLength: 40, valueLength: 200, totalBytes: 1024 };
360
+ export function variablesForTicket(vars) {
361
+ if (!vars || typeof vars !== "object" || Array.isArray(vars))
362
+ return undefined;
363
+ const out = {};
364
+ let n = 0;
365
+ for (const [k, v] of Object.entries(vars)) {
366
+ if (n >= TICKET_VARIABLE_LIMITS.keys)
367
+ break;
368
+ if (v === null || v === undefined || typeof v === "object")
369
+ continue;
370
+ const key = String(k).slice(0, TICKET_VARIABLE_LIMITS.keyLength);
371
+ const value = String(v).slice(0, TICKET_VARIABLE_LIMITS.valueLength);
372
+ if (!key || !value)
373
+ continue;
374
+ out[key] = value;
375
+ n++;
376
+ }
377
+ if (n === 0)
378
+ return undefined;
379
+ let bytes = 2;
380
+ for (const [k, v] of Object.entries(out))
381
+ bytes += utf8Length(k) + utf8Length(v) + 6;
382
+ return bytes > TICKET_VARIABLE_LIMITS.totalBytes ? undefined : out;
383
+ }
384
+ function utf8Length(s) {
385
+ let n = 0;
386
+ for (let i = 0; i < s.length; i++) {
387
+ const c = s.charCodeAt(i);
388
+ n += c < 0x80 ? 1 : c < 0x800 ? 2 : c >= 0xd800 && c <= 0xdbff ? (i++, 4) : 3;
389
+ }
390
+ return n;
391
+ }
@@ -5,11 +5,17 @@ import type { ActionHandler, ScreenContext, ScreenField } from "../protocol.js";
5
5
  import { type WidgetStyle, type WidgetSelectAction } from "../protocol.js";
6
6
  export { GraineFab, GraineTouchCapture } from "./fab.js";
7
7
  export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
8
+ export { useKeyboardLift, liftFor } from "./keyboard.js";
9
+ export { useDraggablePlacement } from "./placement.js";
10
+ export type { DraggablePlacement, DraggablePlacementOptions } from "./placement.js";
11
+ export { clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX } from "./placement-math.js";
12
+ export type { PlacementBounds } from "./placement-math.js";
8
13
  export { widgetSurface } from "../protocol.js";
9
14
  export type { WidgetSurface } from "../protocol.js";
10
15
  export { resolveSurface } from "../protocol.js";
11
16
  export type { AgentSurface, SurfacePolicy } from "../protocol.js";
12
- import { type AgentSurface } from "../protocol.js";
17
+ import { type AgentSurface, type ConnectionQuality } from "../protocol.js";
18
+ export type { ConnectionQuality, QualitySample } from "../protocol.js";
13
19
  import type { FrictionConfig } from "../friction.js";
14
20
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
15
21
  interface GraineContextValue {
@@ -38,6 +44,9 @@ interface GraineContextValue {
38
44
  surfaces: AgentSurface[];
39
45
  canSwitch: boolean;
40
46
  setSurface: (surface: AgentSurface) => void;
47
+ connectionQuality: ConnectionQuality;
48
+ network: "online" | "offline" | "unknown";
49
+ setVariables: (vars: Record<string, unknown> | null) => void;
41
50
  }
42
51
  export type GraineEvent = {
43
52
  type: "conversation_started";
@@ -75,6 +84,15 @@ export type GraineEvent = {
75
84
  name: string;
76
85
  screen: string;
77
86
  detail: string;
87
+ } | {
88
+ type: "connection_quality";
89
+ quality: ConnectionQuality;
90
+ rtt?: number;
91
+ jitter?: number;
92
+ loss?: number;
93
+ } | {
94
+ type: "network";
95
+ online: boolean;
78
96
  };
79
97
  export declare const PENDING_WIDGET_MS = 12000;
80
98
  export declare const AWAITING_REPLY_MS = 12000;
@@ -129,6 +147,12 @@ export declare function useGraineSurface(): {
129
147
  canSwitch: boolean;
130
148
  setSurface: (surface: AgentSurface) => void;
131
149
  };
150
+ export declare function useGraineVariables(): (vars: Record<string, unknown> | null) => void;
151
+ export declare function useGraineConnection(): {
152
+ quality: ConnectionQuality;
153
+ online: boolean | null;
154
+ network: "online" | "offline" | "unknown";
155
+ };
132
156
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
133
157
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
134
158
  export declare function useGraineTap<T extends (...args: any[]) => any>(name: string, handler?: T, data?: Record<string, unknown>): (...args: Parameters<T>) => ReturnType<T> | undefined;
@@ -6,6 +6,9 @@ import { VoiceSession } from "../voice.js";
6
6
  import { resolveWidgetImages, normaliseWidgetStyle, normaliseSelectAction, selectCall, fillSelectTemplate } from "../protocol.js";
7
7
  import { GraineFab, GraineTouchCapture } from "./fab.js";
8
8
  export { GraineFab, GraineTouchCapture } from "./fab.js";
9
+ export { useKeyboardLift, liftFor } from "./keyboard.js";
10
+ export { useDraggablePlacement } from "./placement.js";
11
+ export { clampOffset, offsetDuringDrag, isTap, DRAG_SLOP_PX } from "./placement-math.js";
9
12
  export { widgetSurface } from "../protocol.js";
10
13
  export { resolveSurface } from "../protocol.js";
11
14
  import { resolveSurface } from "../protocol.js";
@@ -59,6 +62,8 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
59
62
  }, []);
60
63
  const [appearance, setAppearance] = useState(null);
61
64
  const [sessionReady, setSessionReady] = useState(false);
65
+ const [connectionQuality, setConnectionQuality] = useState("unknown");
66
+ const [network, setNetwork] = useState("unknown");
62
67
  const [chosenSurface, setChosenSurface] = useState(null);
63
68
  const surfacePolicy = useMemo(() => resolveSurface(appearance, chosenSurface ?? surfaceProp ?? null), [appearance, chosenSurface, surfaceProp]);
64
69
  const surface = surfacePolicy.start;
@@ -186,6 +191,15 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
186
191
  })),
187
192
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
188
193
  client.on("mic_denied", (p) => emitEvent({ type: "mic_denied", reason: String(p?.reason || "NotAllowedError") })),
194
+ client.on("quality", (q) => {
195
+ const quality = (q?.quality ?? "unknown");
196
+ setConnectionQuality(quality);
197
+ emitEvent({ type: "connection_quality", quality, rtt: q?.rtt, jitter: q?.jitter, loss: q?.loss });
198
+ }),
199
+ client.on("network", (n) => {
200
+ setNetwork(n?.online ? "online" : "offline");
201
+ emitEvent({ type: "network", online: !!n?.online });
202
+ }),
189
203
  client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
190
204
  client.on("muted", (m) => {
191
205
  setMutedState(m);
@@ -328,9 +342,12 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
328
342
  surfaces: surfacePolicy.allowed,
329
343
  canSwitch: surfacePolicy.canSwitch,
330
344
  setSurface,
345
+ connectionQuality,
346
+ network,
347
+ setVariables: (vars) => client.setVariables(vars),
331
348
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
332
349
  launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
333
- surface, surfacePolicy, setSurface]);
350
+ surface, surfacePolicy, setSurface, connectionQuality, network]);
334
351
  const wantsCapture = captureTaps ?? !!fab;
335
352
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
336
353
  return _jsx(Ctx.Provider, { value: value, children: body });
@@ -348,6 +365,18 @@ export function useGraineSurface() {
348
365
  const { surface, surfaces, canSwitch, setSurface } = useGraine();
349
366
  return { surface, surfaces, canSwitch, setSurface };
350
367
  }
368
+ export function useGraineVariables() {
369
+ const { setVariables } = useGraine();
370
+ return setVariables;
371
+ }
372
+ export function useGraineConnection() {
373
+ const { connectionQuality, network } = useGraine();
374
+ return {
375
+ quality: connectionQuality,
376
+ online: network === "unknown" ? null : network === "online",
377
+ network,
378
+ };
379
+ }
351
380
  export function useGraineEvents(handler) {
352
381
  const { onEvent } = useGraine();
353
382
  const ref = useRef(handler);
@@ -0,0 +1 @@
1
+ export declare function liftFor(keyboardHeight: number, windowShrink: number): number;
@@ -0,0 +1,6 @@
1
+ export function liftFor(keyboardHeight, windowShrink) {
2
+ if (!(keyboardHeight > 0))
3
+ return 0;
4
+ const shrink = windowShrink > 0 ? windowShrink : 0;
5
+ return Math.max(0, Math.round(keyboardHeight - shrink));
6
+ }
@@ -0,0 +1,3 @@
1
+ import { liftFor } from "./keyboard-math.js";
2
+ export { liftFor };
3
+ export declare function useKeyboardLift(): number;
@@ -0,0 +1,21 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Keyboard, Platform, useWindowDimensions } from "react-native";
3
+ import { liftFor } from "./keyboard-math.js";
4
+ export { liftFor };
5
+ export function useKeyboardLift() {
6
+ const [keyboardHeight, setKeyboardHeight] = useState(0);
7
+ const { height: windowHeight } = useWindowDimensions();
8
+ const tallestRef = useRef(windowHeight);
9
+ if (windowHeight > tallestRef.current)
10
+ tallestRef.current = windowHeight;
11
+ useEffect(() => {
12
+ const show = Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
13
+ const hide = Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
14
+ const onShow = (e) => setKeyboardHeight(Number(e?.endCoordinates?.height) || 0);
15
+ const onHide = () => setKeyboardHeight(0);
16
+ const a = Keyboard.addListener(show, onShow);
17
+ const b = Keyboard.addListener(hide, onHide);
18
+ return () => { a.remove(); b.remove(); };
19
+ }, []);
20
+ return liftFor(keyboardHeight, tallestRef.current - windowHeight);
21
+ }
@@ -0,0 +1,10 @@
1
+ export declare const DRAG_SLOP_PX = 8;
2
+ export interface PlacementBounds {
3
+ windowHeight: number;
4
+ baseBottom: number;
5
+ barHeight: number;
6
+ topMargin?: number;
7
+ }
8
+ export declare function clampOffset(offset: number, b: PlacementBounds): number;
9
+ export declare function offsetDuringDrag(startOffset: number, dy: number, b: PlacementBounds): number;
10
+ export declare function isTap(dx: number, dy: number): boolean;
@@ -0,0 +1,15 @@
1
+ export const DRAG_SLOP_PX = 8;
2
+ export function clampOffset(offset, b) {
3
+ if (!Number.isFinite(offset))
4
+ return 0;
5
+ const top = b.topMargin ?? 48;
6
+ const height = b.barHeight > 0 ? b.barHeight : 64;
7
+ const maxOffset = Math.max(0, b.windowHeight - top - height - b.baseBottom);
8
+ return Math.round(Math.min(Math.max(offset, 0), maxOffset));
9
+ }
10
+ export function offsetDuringDrag(startOffset, dy, b) {
11
+ return clampOffset(startOffset - dy, b);
12
+ }
13
+ export function isTap(dx, dy) {
14
+ return Math.abs(dx) < DRAG_SLOP_PX && Math.abs(dy) < DRAG_SLOP_PX;
15
+ }
@@ -0,0 +1,16 @@
1
+ import { type GestureResponderHandlers } from "react-native";
2
+ export interface DraggablePlacementOptions {
3
+ baseBottom: number;
4
+ enabled?: boolean;
5
+ initialOffset?: number;
6
+ onChange?: (offset: number) => void;
7
+ topMargin?: number;
8
+ }
9
+ export interface DraggablePlacement {
10
+ offset: number;
11
+ panHandlers: GestureResponderHandlers;
12
+ onLayout: (e: any) => void;
13
+ dragging: boolean;
14
+ reset: () => void;
15
+ }
16
+ export declare function useDraggablePlacement(opts: DraggablePlacementOptions): DraggablePlacement;
@@ -0,0 +1,62 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { PanResponder, useWindowDimensions } from "react-native";
3
+ import { clampOffset, isTap, offsetDuringDrag } from "./placement-math.js";
4
+ export function useDraggablePlacement(opts) {
5
+ const { baseBottom, enabled = true, initialOffset, onChange, topMargin } = opts;
6
+ const { height: windowHeight } = useWindowDimensions();
7
+ const [offset, setOffset] = useState(() => initialOffset ?? 0);
8
+ const [barHeight, setBarHeight] = useState(0);
9
+ const [dragging, setDragging] = useState(false);
10
+ const boundsRef = useRef({ windowHeight, baseBottom, barHeight, topMargin });
11
+ boundsRef.current = { windowHeight, baseBottom, barHeight, topMargin };
12
+ const offsetRef = useRef(offset);
13
+ offsetRef.current = offset;
14
+ const changeRef = useRef(onChange);
15
+ changeRef.current = onChange;
16
+ const startRef = useRef(0);
17
+ useEffect(() => {
18
+ if (initialOffset !== undefined)
19
+ setOffset(clampOffset(initialOffset, boundsRef.current));
20
+ }, [initialOffset]);
21
+ useEffect(() => {
22
+ setOffset((cur) => clampOffset(cur, boundsRef.current));
23
+ }, [windowHeight, barHeight, baseBottom]);
24
+ const responder = useMemo(() => PanResponder.create({
25
+ onMoveShouldSetPanResponder: (_e, g) => enabled && !isTap(g.dx, g.dy),
26
+ onMoveShouldSetPanResponderCapture: (_e, g) => enabled && !isTap(g.dx, g.dy),
27
+ onPanResponderGrant: () => {
28
+ startRef.current = offsetRef.current;
29
+ setDragging(true);
30
+ },
31
+ onPanResponderMove: (_e, g) => {
32
+ setOffset(offsetDuringDrag(startRef.current, g.dy, boundsRef.current));
33
+ },
34
+ onPanResponderRelease: (_e, g) => {
35
+ const next = offsetDuringDrag(startRef.current, g.dy, boundsRef.current);
36
+ setOffset(next);
37
+ setDragging(false);
38
+ if (next !== startRef.current)
39
+ changeRef.current?.(next);
40
+ },
41
+ onPanResponderTerminate: () => {
42
+ setOffset(startRef.current);
43
+ setDragging(false);
44
+ },
45
+ }), [enabled]);
46
+ const onLayout = useCallback((e) => {
47
+ const h = Number(e?.nativeEvent?.layout?.height) || 0;
48
+ if (h > 0)
49
+ setBarHeight(h);
50
+ }, []);
51
+ const reset = useCallback(() => {
52
+ setOffset(0);
53
+ changeRef.current?.(0);
54
+ }, []);
55
+ return {
56
+ offset: enabled ? offset : 0,
57
+ panHandlers: enabled ? responder.panHandlers : {},
58
+ onLayout,
59
+ dragging,
60
+ reset,
61
+ };
62
+ }
@@ -17,7 +17,14 @@ export interface GraineAgentBarProps {
17
17
  captionsOn?: boolean;
18
18
  scheme?: "dark" | "light" | "auto";
19
19
  theme?: Partial<GraineBarTheme>;
20
+ draggable?: boolean;
21
+ placement?: {
22
+ bottomOffset?: number;
23
+ } | null;
24
+ onPlacementChange?: (placement: {
25
+ bottomOffset: number;
26
+ }) => void;
20
27
  }
21
- export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, }: GraineAgentBarProps): React.JSX.Element | null;
28
+ export declare function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset, hidden, captionsOn, scheme, theme: themeProp, draggable, placement, onPlacementChange, }: GraineAgentBarProps): React.JSX.Element | null;
22
29
  export declare const GraineLauncher: typeof GraineAgentBar;
23
30
  export type GraineLauncherProps = GraineAgentBarProps;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
3
+ import { Image, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
+ import { useKeyboardLift } from "./keyboard.js";
5
+ import { useDraggablePlacement } from "./placement.js";
4
6
  import { useGraineAgent } from "./index.js";
5
7
  import { GraineLoader } from "./loader.js";
6
8
  import { resolveNativeAppearance } from "./appearance.js";
@@ -22,8 +24,8 @@ const LIGHT = {
22
24
  onAccent: "#FFFFFF",
23
25
  danger: "#D32F2F",
24
26
  };
25
- export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, }) {
26
- const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, surface, canSwitch, setSurface, } = useGraineAgent();
27
+ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl: avatarProp, bottomInset = 96, hidden = false, captionsOn = false, scheme = "auto", theme: themeProp, draggable = true, placement, onPlacementChange, }) {
28
+ const { connected, connecting, error, messages, send, muted, setMuted, caption, agentSpeaking, launcherVisible, launcherInset, appearance, surface, canSwitch, setSurface, connectionQuality, network, } = useGraineAgent();
27
29
  const isChat = surface === "chat";
28
30
  const dashboard = resolveNativeAppearance(appearance, {
29
31
  accent: "#318CE7",
@@ -38,6 +40,14 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
38
40
  return makeStyles(themeProp ? { ...base, ...themeProp } : base);
39
41
  }, [scheme, deviceScheme, themeProp]);
40
42
  const [expanded, setExpanded] = useState(surface === "chat");
43
+ const keyboardLift = useKeyboardLift();
44
+ const baseBottom = launcherInset.bottom ?? bottomInset;
45
+ const drag = useDraggablePlacement({
46
+ baseBottom,
47
+ enabled: draggable,
48
+ initialOffset: placement?.bottomOffset,
49
+ onChange: (bottomOffset) => onPlacementChange?.({ bottomOffset }),
50
+ });
41
51
  const [captions, setCaptions] = useState(captionsOn);
42
52
  const [draft, setDraft] = useState("");
43
53
  const scrollRef = useRef(null);
@@ -46,11 +56,13 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
46
56
  const waitingMoment = "connecting";
47
57
  const status = error
48
58
  ? "unavailable"
49
- : connecting ? "connecting…"
50
- : muted ? "muted"
51
- : agentSpeaking || (last?.role === "agent" && last.live) ? (isChat ? "replying" : "speaking")
52
- : connected ? (isChat ? "ready" : "listening")
53
- : "offline";
59
+ : network === "offline" ? "no connection"
60
+ : connecting ? "connecting…"
61
+ : muted ? "muted"
62
+ : agentSpeaking || (last?.role === "agent" && last.live) ? (isChat ? "replying" : "speaking")
63
+ : connected && connectionQuality === "poor" ? "weak connection"
64
+ : connected ? (isChat ? "ready" : "listening")
65
+ : "offline";
54
66
  useEffect(() => {
55
67
  if (expanded)
56
68
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
@@ -70,19 +82,20 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
70
82
  }, [draft, send]);
71
83
  if (hidden || error || !launcherVisible)
72
84
  return null;
73
- return (_jsxs(View, { pointerEvents: "box-none", style: [
85
+ return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: [
74
86
  styles.root,
75
87
  {
76
- bottom: launcherInset.bottom ?? bottomInset,
88
+ bottom: baseBottom + drag.offset + keyboardLift,
77
89
  right: launcherInset.right ?? 12,
78
90
  left: launcherInset.left ?? 12,
79
91
  },
80
- ], 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: isChat
92
+ ], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: [styles.transcript, keyboardLift ? styles.transcriptLifted : null], contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: isChat
81
93
  ? "I can see what you\u2019re working on \u2014 type a question, or I\u2019ll step in if you get stuck."
82
94
  : "I can see what you\u2019re working on \u2014 ask me anything, or I\u2019ll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
83
95
  styles.bubble,
84
96
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
85
- ], 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 && !isChat && 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 }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), canSwitch ? (_jsx(Pressable, { onPress: () => setSurface(isChat ? "voice" : "chat"), hitSlop: 8, accessibilityRole: "button", accessibilityLabel: isChat ? "Switch to a call" : "Switch to chat", style: styles.round, children: _jsx(Text, { style: styles.glyph, children: isChat ? "📞" : "💬" }) })) : null, waiting ? null : (_jsxs(_Fragment, { children: [isChat ? null : (_jsxs(_Fragment, { children: [_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 ? "⌄" : "⌃" }) })] }))] })] }));
97
+ ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(View, { 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 && !isChat && 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: () => { if (!drag.dragging)
98
+ setExpanded((v) => !v); }, style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), canSwitch ? (_jsx(Pressable, { onPress: () => setSurface(isChat ? "voice" : "chat"), hitSlop: 8, accessibilityRole: "button", accessibilityLabel: isChat ? "Switch to a call" : "Switch to chat", style: styles.round, children: _jsx(Text, { style: styles.glyph, children: isChat ? "📞" : "💬" }) })) : null, waiting ? null : (_jsxs(_Fragment, { children: [isChat ? null : (_jsxs(_Fragment, { children: [_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 ? "⌄" : "⌃" }) })] }))] })] }));
86
99
  }
87
100
  export const GraineLauncher = GraineAgentBar;
88
101
  const makeStyles = (t) => StyleSheet.create({
@@ -92,6 +105,7 @@ const makeStyles = (t) => StyleSheet.create({
92
105
  marginBottom: 8, overflow: "hidden",
93
106
  },
94
107
  transcript: { maxHeight: 240 },
108
+ transcriptLifted: { maxHeight: 150 },
95
109
  transcriptInner: { padding: 14, gap: 8 },
96
110
  empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
97
111
  bubble: { maxWidth: "88%", paddingHorizontal: 12, paddingVertical: 9, borderRadius: 16 },
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { type Caption } from "./index.js";
3
+ import type { ConnectionQuality } from "../protocol.js";
3
4
  export interface GraineVoiceLauncherProps {
4
5
  transport?: "ws" | "rtc";
5
6
  onTransport?: (info: {
@@ -26,6 +27,9 @@ export interface GraineVoiceLauncherProps {
26
27
  timing?: {
27
28
  readyTimeoutMs?: number;
28
29
  retryDelaysMs?: number[];
30
+ stallMs?: number;
31
+ bootMs?: number;
32
+ hardCapMs?: number;
29
33
  endWatchdogMs?: number;
30
34
  };
31
35
  children?: (api: GraineVoiceApi) => React.ReactNode;
@@ -52,6 +56,9 @@ export interface GraineVoiceApi {
52
56
  captionsOn: boolean;
53
57
  setCaptionsOn: (on: boolean) => void;
54
58
  clearCaptions: () => void;
59
+ quality: ConnectionQuality;
60
+ network: "online" | "offline" | "unknown";
61
+ reconnecting: boolean;
55
62
  }
56
63
  export declare const CAPTION_HISTORY = 12;
57
64
  export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -45,9 +45,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
45
45
  const onErrorRef = useRef(onError);
46
46
  onErrorRef.current = onError;
47
47
  const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
48
+ const STALL_TIMEOUT_MS = timing?.stallMs ?? READY_TIMEOUT_MS;
49
+ const BOOT_TIMEOUT_MS = timing?.bootMs ?? READY_TIMEOUT_MS;
50
+ const HARD_CAP_MS = timing?.hardCapMs ?? 120000;
48
51
  const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
49
52
  const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
50
53
  const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
54
+ const [quality, setQuality] = useState("unknown");
55
+ const [network, setNetwork] = useState("unknown");
56
+ const [reconnecting, setReconnecting] = useState(false);
51
57
  const attemptRef = useRef(0);
52
58
  const retryTimerRef = useRef(null);
53
59
  const readyTimerRef = useRef(null);
@@ -89,13 +95,47 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
89
95
  catch { }
90
96
  }, delay);
91
97
  }, []);
98
+ const progressRef = useRef(0);
99
+ const movedAtRef = useRef(0);
100
+ const loadStartedAtRef = useRef(0);
101
+ const downloadedRef = useRef(false);
102
+ const noteDownloaded = useCallback(() => {
103
+ downloadedRef.current = true;
104
+ movedAtRef.current = Date.now();
105
+ }, []);
106
+ const noteProgress = useCallback((p) => {
107
+ if (p <= progressRef.current)
108
+ return;
109
+ progressRef.current = p;
110
+ movedAtRef.current = Date.now();
111
+ if (p >= 1)
112
+ downloadedRef.current = true;
113
+ }, []);
92
114
  const armReadyTimer = useCallback(() => {
93
115
  clearTimer(readyTimerRef);
94
- readyTimerRef.current = setTimeout(() => {
95
- readyTimerRef.current = null;
96
- scheduleRetry("ready-timeout");
97
- }, READY_TIMEOUT_MS);
98
- }, [scheduleRetry]);
116
+ progressRef.current = 0;
117
+ downloadedRef.current = false;
118
+ const now = Date.now();
119
+ loadStartedAtRef.current = now;
120
+ movedAtRef.current = now;
121
+ const tick = () => {
122
+ const since = Date.now() - movedAtRef.current;
123
+ const total = Date.now() - loadStartedAtRef.current;
124
+ const limit = downloadedRef.current ? BOOT_TIMEOUT_MS : STALL_TIMEOUT_MS;
125
+ if (since >= limit) {
126
+ readyTimerRef.current = null;
127
+ scheduleRetry(downloadedRef.current ? "boot-timeout" : "stalled");
128
+ return;
129
+ }
130
+ if (total >= HARD_CAP_MS) {
131
+ readyTimerRef.current = null;
132
+ scheduleRetry("load-cap");
133
+ return;
134
+ }
135
+ readyTimerRef.current = setTimeout(tick, Math.max(250, Math.min(limit - since, HARD_CAP_MS - total)));
136
+ };
137
+ readyTimerRef.current = setTimeout(tick, Math.min(STALL_TIMEOUT_MS, 1000));
138
+ }, [scheduleRetry, STALL_TIMEOUT_MS, BOOT_TIMEOUT_MS, HARD_CAP_MS]);
99
139
  const restartLoad = useCallback(() => {
100
140
  clearTimer(retryTimerRef);
101
141
  attemptRef.current = 0;
@@ -223,7 +263,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
223
263
  case "graine:ready":
224
264
  post({
225
265
  type: "graine:variables",
226
- variables: { ...(client.opts.variables ?? {}), ...client.getIdentity() },
266
+ variables: client.getVariables(),
227
267
  });
228
268
  clearTimer(readyTimerRef);
229
269
  clearTimer(retryTimerRef);
@@ -241,8 +281,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
241
281
  }
242
282
  return;
243
283
  case "graine:call":
244
- if (msg.connected)
284
+ if (msg.connected) {
245
285
  endedNotifiedRef.current = false;
286
+ setReconnecting(false);
287
+ }
246
288
  setConnected(!!msg.connected);
247
289
  setConnecting(!!msg.connecting);
248
290
  client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
@@ -278,12 +320,27 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
278
320
  clearTimer(endWatchdogRef);
279
321
  setConnected(false);
280
322
  setConnecting(false);
323
+ setReconnecting(false);
324
+ setQuality(client.reportQuality(null));
281
325
  connectedRef.current = false;
282
326
  if (!endedNotifiedRef.current) {
283
327
  endedNotifiedRef.current = true;
284
328
  onEnded?.();
285
329
  }
286
330
  return;
331
+ case "graine:quality":
332
+ setQuality(client.reportQuality(msg.sample && typeof msg.sample === "object" ? msg.sample : null));
333
+ return;
334
+ case "graine:network":
335
+ setNetwork(msg.online ? "online" : "offline");
336
+ client.reportNetwork(!!msg.online);
337
+ setQuality(client.connectionQuality);
338
+ return;
339
+ case "graine:reconnecting":
340
+ setReconnecting(!!msg.active);
341
+ if (msg.active)
342
+ setConnecting(true);
343
+ return;
287
344
  case "graine:mic-denied":
288
345
  client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
289
346
  onMicDenied?.(String(msg.reason || "denied"));
@@ -308,7 +365,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
308
365
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
309
366
  const pendingStartRef = useRef(false);
310
367
  const lastCaption = captions.length ? captions[captions.length - 1] : null;
311
- const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
368
+ const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
312
369
  : connecting || (!ready && !!voiceAgentId) ? "connecting"
313
370
  : !connected ? "idle"
314
371
  : lastCaption?.role === "agent" && lastCaption.live ? "speaking"
@@ -339,11 +396,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
339
396
  end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
340
397
  connectivity,
341
398
  retry: restartLoad,
399
+ quality,
400
+ network,
401
+ reconnecting,
342
402
  setMuted: useCallback((m) => {
343
403
  setMutedState(m);
344
404
  post({ type: "graine:set-muted", muted: m });
345
405
  }, [post]),
346
406
  };
407
+ useEffect(() => {
408
+ if (!ready)
409
+ return;
410
+ return client.on("variables", (vars) => {
411
+ post({ type: "graine:variables", variables: vars });
412
+ });
413
+ }, [client, ready, post]);
347
414
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
348
415
  const activeSeenRef = useRef(null);
349
416
  useEffect(() => {
@@ -413,9 +480,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
413
480
  const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
414
481
  const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
415
482
  `?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
416
- `&b=${mountStampRef.current}` +
483
+ `&b=${client.config?.pageVersion ?? mountStampRef.current}` +
417
484
  (mountedIdleRef.current ? "&idle=1" : "") +
418
485
  `&pk=${encodeURIComponent(client.opts.publishableKey)}` +
419
486
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
420
- 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, onLoadStart: armReadyTimer, onError: (e) => scheduleRetry(`load-error:${e?.nativeEvent?.code ?? ""}`), onHttpError: (e) => scheduleRetry(`http-${e?.nativeEvent?.statusCode ?? ""}`), onRenderProcessGone: () => scheduleRetry("render-process-gone"), style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
487
+ 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, onLoadStart: armReadyTimer, onLoadProgress: (e) => noteProgress(Number(e?.nativeEvent?.progress) || 0), onLoadEnd: noteDownloaded, onError: (e) => scheduleRetry(`load-error:${e?.nativeEvent?.code ?? ""}`), onHttpError: (e) => scheduleRetry(`http-${e?.nativeEvent?.statusCode ?? ""}`), onRenderProcessGone: () => scheduleRetry("render-process-gone"), style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
421
488
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
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",