@graineai/inapp-react-native 0.33.6 → 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 } 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);
@@ -66,6 +77,12 @@ export declare class GraineInAppClient {
66
77
  private lastWidget;
67
78
  get pendingWidget(): any;
68
79
  clearPendingWidget(): void;
80
+ private voiceHost;
81
+ get hasVoiceHost(): boolean;
82
+ setVoiceHost(on: boolean): void;
83
+ private currentSurface;
84
+ get surface(): AgentSurface;
85
+ setSurface(next: AgentSurface): void;
69
86
  emit(event: string, payload?: any): void;
70
87
  private get fetch();
71
88
  session(): Promise<SessionConfig>;
@@ -73,6 +90,7 @@ export declare class GraineInAppClient {
73
90
  private reportActionMismatch;
74
91
  private ticket;
75
92
  connect(): Promise<void>;
93
+ private get socketIsVoice();
76
94
  private openSocket;
77
95
  private send;
78
96
  private onFrame;
package/dist/client.js CHANGED
@@ -1,9 +1,66 @@
1
- import { 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,9 +83,14 @@ 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;
92
+ this.voiceHost = false;
93
+ this.currentSurface = "voice";
32
94
  if (!opts?.publishableKey)
33
95
  throw new Error("[Graine] publishableKey is required.");
34
96
  if (!opts?.baseUrl)
@@ -64,6 +126,26 @@ export class GraineInAppClient {
64
126
  clearPendingWidget() {
65
127
  this.lastWidget = null;
66
128
  }
129
+ get hasVoiceHost() {
130
+ return this.voiceHost;
131
+ }
132
+ setVoiceHost(on) {
133
+ if (this.voiceHost === on)
134
+ return;
135
+ this.voiceHost = on;
136
+ this.emit("voice_host", { present: on });
137
+ }
138
+ get surface() {
139
+ return this.currentSurface;
140
+ }
141
+ setSurface(next) {
142
+ if (next !== "voice" && next !== "chat")
143
+ return;
144
+ if (this.currentSurface === next)
145
+ return;
146
+ this.currentSurface = next;
147
+ this.emit("surface", { surface: next });
148
+ }
67
149
  emit(event, payload) {
68
150
  if (event === "widget")
69
151
  this.lastWidget = payload ?? null;
@@ -138,7 +220,8 @@ export class GraineInAppClient {
138
220
  headers: { "Content-Type": "application/json" },
139
221
  body: JSON.stringify({
140
222
  publishableKey: this.opts.publishableKey,
141
- mode: this.opts.voice ? "voice" : "text",
223
+ mode: this.socketIsVoice ? "voice" : "text",
224
+ metadata: variablesForTicket(this.allVariables()),
142
225
  }),
143
226
  });
144
227
  if (!res.ok)
@@ -157,6 +240,9 @@ export class GraineInAppClient {
157
240
  this.connecting = this.openSocket().finally(() => { this.connecting = null; });
158
241
  return this.connecting;
159
242
  }
243
+ get socketIsVoice() {
244
+ return !!this.opts.voice && this.currentSurface !== "chat";
245
+ }
160
246
  async openSocket() {
161
247
  if (!this.config)
162
248
  await this.session();
@@ -171,10 +257,13 @@ export class GraineInAppClient {
171
257
  }
172
258
  else {
173
259
  const host = (this.config.wsHost || "wss://ws.graine.ai").replace(/\/$/, "");
174
- const mode = this.opts.voice ? "webcall=1" : "text_chat=1";
260
+ const mode = this.socketIsVoice ? "webcall=1" : "text_chat=1";
175
261
  url = `${host}/chat/v1/${encodeURIComponent(agentId)}?${mode}`;
176
262
  this.transport = "direct";
177
263
  }
264
+ const token = contextToken(this.allVariables(), this.socketIsVoice ? "webcall" : "text_chat");
265
+ if (token)
266
+ url += `${url.includes("?") ? "&" : "?"}context_token=${encodeURIComponent(token)}`;
178
267
  const WS = this.opts.WebSocketImpl ?? globalThis.WebSocket;
179
268
  const ws = new WS(url, protocols);
180
269
  this.ws = ws;
@@ -199,11 +288,7 @@ export class GraineInAppClient {
199
288
  event: "start",
200
289
  meta_data: {
201
290
  client: "graine-inapp-sdk",
202
- context_data: {
203
- ...(this.config.variables ?? {}),
204
- ...(this.opts.variables ?? {}),
205
- ...this.identity,
206
- },
291
+ context_data: this.allVariables(),
207
292
  },
208
293
  });
209
294
  this.pingTimer = setInterval(() => this.send({ type: "ping" }), PING_MS);
@@ -232,9 +317,20 @@ export class GraineInAppClient {
232
317
  return;
233
318
  }
234
319
  switch (msg.type) {
235
- case "text":
236
- 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
+ }
237
332
  break;
333
+ }
238
334
  case "widget":
239
335
  this.emit("widget", msg.widget);
240
336
  break;
@@ -526,6 +622,7 @@ export class GraineInAppClient {
526
622
  identify(user) {
527
623
  if (!user) {
528
624
  this.identity = {};
625
+ this.emit("variables", this.allVariables());
529
626
  return;
530
627
  }
531
628
  const flat = {};
@@ -535,6 +632,7 @@ export class GraineInAppClient {
535
632
  flat[k] = typeof v === "string" ? v : JSON.stringify(v);
536
633
  }
537
634
  this.identity = maskDeep(flat);
635
+ this.emit("variables", this.allVariables());
538
636
  this.scheduleScreen();
539
637
  }
540
638
  noteInteraction() {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,11 @@ export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOAD
6
6
  export type { LoaderKind, LoaderSize, LoaderSpeed, LoaderMoment, LoaderText, LoaderStyle, LoaderLook } from "./protocol.js";
7
7
  export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
8
8
  export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
9
+ export { contextToken } from "./protocol.js";
10
+ export { resolveSurface } from "./protocol.js";
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";
9
14
  export type { WidgetSelect } from "./protocol.js";
10
15
  export type { WidgetSelectAction } from "./protocol.js";
11
16
  export { GraineWidget } from "./react-native/widget.js";
@@ -18,7 +23,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type Fri
18
23
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, type AudioAdapter, type VoiceSessionOptions, } from "./voice.js";
19
24
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, type AudioFormat, type DecodedAudio, } from "./audio.js";
20
25
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
21
- export { GraineProvider, useGraineAgent, 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";
22
27
  export { requestMicrophonePermission } from "./react-native/permissions.js";
23
28
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, type GraineFabProps, type GraineTouchCaptureProps, } from "./react-native/fab.js";
24
29
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
package/dist/index.js CHANGED
@@ -3,6 +3,9 @@ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, W
3
3
  export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOADER_KIND, DEFAULT_LOADER_TEXT, LOADER_TEXT_MAX, normaliseLoaderStyle, resolveLoader, } from "./protocol.js";
4
4
  export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
5
5
  export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
6
+ export { contextToken } from "./protocol.js";
7
+ export { resolveSurface } from "./protocol.js";
8
+ export { classifyQuality, variablesForTicket, TICKET_VARIABLE_LIMITS, readAgentChunk, STREAM_START, STREAM_END } from "./protocol.js";
6
9
  export { GraineWidget } from "./react-native/widget.js";
7
10
  export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
8
11
  export { PENDING_WIDGET_MS, AWAITING_REPLY_MS, MIN_LOADER_MS } from "./react-native/index.js";
@@ -11,7 +14,7 @@ export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "
11
14
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
12
15
  export { EchoGuard, decodeAgentAudio, decodeMuLaw, decodePcm16, base64ToBytes, } from "./audio.js";
13
16
  export { addMaskRule, maskDeep, maskString } from "./mask.js";
14
- export { GraineProvider, useGraineAgent, 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";
15
18
  export { requestMicrophonePermission } from "./react-native/permissions.js";
16
19
  export { GraineFab, GraineTouchCapture, TAP_MAX_MOVE_PX, TAP_MAX_MS, } from "./react-native/fab.js";
17
20
  export { UNNAMED_TAP, resolveWidgetImages } from "./protocol.js";
@@ -179,3 +179,33 @@ export declare function selectCall(onSelect: WidgetSelectAction | undefined | nu
179
179
  args: Record<string, string>;
180
180
  } | null;
181
181
  export declare function fillSelectArgs(args: Record<string, string> | undefined, item: Record<string, unknown>): Record<string, string>;
182
+ export declare function contextToken(variables: Record<string, unknown> | null | undefined, callType?: string): string | null;
183
+ export type AgentSurface = "voice" | "chat";
184
+ export interface SurfacePolicy {
185
+ allowed: AgentSurface[];
186
+ start: AgentSurface;
187
+ canSwitch: boolean;
188
+ }
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
@@ -261,3 +261,131 @@ export function fillSelectArgs(args, item) {
261
261
  out[k] = fillSelectTemplate(v, item);
262
262
  return out;
263
263
  }
264
+ export function contextToken(variables, callType = "webcall") {
265
+ const vals = {};
266
+ for (const [k, v] of Object.entries(variables ?? {})) {
267
+ if (v === null || v === undefined || typeof v === "object")
268
+ continue;
269
+ const key = String(k).trim();
270
+ if (key)
271
+ vals[key] = typeof v === "string" ? v : String(v);
272
+ }
273
+ if (!Object.keys(vals).length)
274
+ return null;
275
+ const payload = { ...vals, call_type: callType, recipient_data: { ...vals, call_type: callType } };
276
+ try {
277
+ return base64(JSON.stringify(payload));
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }
283
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
284
+ function base64(text) {
285
+ const bytes = [];
286
+ for (let i = 0; i < text.length; i++) {
287
+ let c = text.charCodeAt(i);
288
+ if (c < 0x80)
289
+ bytes.push(c);
290
+ else if (c < 0x800)
291
+ bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
292
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) {
293
+ const lo = text.charCodeAt(++i);
294
+ c = 0x10000 + ((c - 0xd800) << 10) + (lo - 0xdc00);
295
+ bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
296
+ }
297
+ else
298
+ bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
299
+ }
300
+ let out = "";
301
+ for (let i = 0; i < bytes.length; i += 3) {
302
+ const b0 = bytes[i], b1 = bytes[i + 1], b2 = bytes[i + 2];
303
+ out += B64[b0 >> 2];
304
+ out += B64[((b0 & 3) << 4) | ((b1 ?? 0) >> 4)];
305
+ out += b1 === undefined ? "=" : B64[((b1 & 15) << 2) | ((b2 ?? 0) >> 6)];
306
+ out += b2 === undefined ? "=" : B64[b2 & 63];
307
+ }
308
+ return out;
309
+ }
310
+ export function resolveSurface(appearance, prefer) {
311
+ const mode = String(appearance?.mode ?? "both").toLowerCase();
312
+ const allowed = mode === "voice" ? ["voice"] : mode === "chat" ? ["chat"] : ["voice", "chat"];
313
+ const configured = String(appearance?.start ?? "").toLowerCase();
314
+ const start = prefer && allowed.includes(prefer)
315
+ ? prefer
316
+ : configured === "chat" || configured === "voice"
317
+ ? allowed.includes(configured)
318
+ ? configured
319
+ : allowed[0]
320
+ : allowed[0];
321
+ return { allowed, start, canSwitch: allowed.length > 1 };
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,8 +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";
15
+ export { resolveSurface } from "../protocol.js";
16
+ export type { AgentSurface, SurfacePolicy } from "../protocol.js";
17
+ import { type AgentSurface, type ConnectionQuality } from "../protocol.js";
18
+ export type { ConnectionQuality, QualitySample } from "../protocol.js";
10
19
  import type { FrictionConfig } from "../friction.js";
11
20
  import { type LauncherInset, type LauncherVisibility } from "./navigation.js";
12
21
  interface GraineContextValue {
@@ -31,6 +40,13 @@ interface GraineContextValue {
31
40
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
32
41
  track: (name: string, data?: Record<string, unknown>) => void;
33
42
  identify: (user: Record<string, unknown> | null) => void;
43
+ surface: AgentSurface;
44
+ surfaces: AgentSurface[];
45
+ canSwitch: boolean;
46
+ setSurface: (surface: AgentSurface) => void;
47
+ connectionQuality: ConnectionQuality;
48
+ network: "online" | "offline" | "unknown";
49
+ setVariables: (vars: Record<string, unknown> | null) => void;
34
50
  }
35
51
  export type GraineEvent = {
36
52
  type: "conversation_started";
@@ -68,6 +84,15 @@ export type GraineEvent = {
68
84
  name: string;
69
85
  screen: string;
70
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;
71
96
  };
72
97
  export declare const PENDING_WIDGET_MS = 12000;
73
98
  export declare const AWAITING_REPLY_MS = 12000;
@@ -97,6 +122,8 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
97
122
  children: React.ReactNode;
98
123
  client?: GraineInAppClient;
99
124
  autoConnect?: boolean;
125
+ surface?: AgentSurface;
126
+ onSurfaceChange?: (surface: AgentSurface) => void;
100
127
  onProactive?: (text: string) => void;
101
128
  endOnBackground?: boolean;
102
129
  navigationRef?: {
@@ -112,8 +139,20 @@ export interface GraineProviderProps extends Partial<GraineInAppOptions> {
112
139
  launcherDelayMs?: number;
113
140
  visibility?: LauncherVisibility;
114
141
  }
115
- export declare function GraineProvider({ children, autoConnect, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
142
+ export declare function GraineProvider({ children, autoConnect, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs, visibility, client: providedClient, ...options }: GraineProviderProps): React.JSX.Element;
116
143
  export declare function useGraineAgent(): GraineContextValue;
144
+ export declare function useGraineSurface(): {
145
+ surface: AgentSurface;
146
+ surfaces: AgentSurface[];
147
+ canSwitch: boolean;
148
+ setSurface: (surface: AgentSurface) => void;
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
+ };
117
156
  export declare function useGraineEvents(handler: (e: GraineEvent) => void): void;
118
157
  export declare function useGraineTrack(): (name: string, data?: Record<string, unknown>) => void;
119
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,7 +6,12 @@ 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";
13
+ export { resolveSurface } from "../protocol.js";
14
+ import { resolveSurface } from "../protocol.js";
10
15
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
11
16
  export const PENDING_WIDGET_MS = 12000;
12
17
  export const AWAITING_REPLY_MS = 12000;
@@ -24,7 +29,7 @@ export function useGraineClient() {
24
29
  }
25
30
  return client;
26
31
  }
27
- export function GraineProvider({ children, autoConnect = true, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
32
+ export function GraineProvider({ children, autoConnect = true, surface: surfaceProp, onSurfaceChange, onProactive, endOnBackground = true, navigationRef, fab, captureTaps, appVersion, friction, includeScreens, launcherDelayMs = 0, visibility, client: providedClient, ...options }) {
28
33
  const clientRef = useRef(null);
29
34
  if (!clientRef.current) {
30
35
  clientRef.current =
@@ -57,6 +62,30 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
57
62
  }, []);
58
63
  const [appearance, setAppearance] = useState(null);
59
64
  const [sessionReady, setSessionReady] = useState(false);
65
+ const [connectionQuality, setConnectionQuality] = useState("unknown");
66
+ const [network, setNetwork] = useState("unknown");
67
+ const [chosenSurface, setChosenSurface] = useState(null);
68
+ const surfacePolicy = useMemo(() => resolveSurface(appearance, chosenSurface ?? surfaceProp ?? null), [appearance, chosenSurface, surfaceProp]);
69
+ const surface = surfacePolicy.start;
70
+ const surfaceRef = useRef(surface);
71
+ surfaceRef.current = surface;
72
+ const surfaceChangeRef = useRef(onSurfaceChange);
73
+ surfaceChangeRef.current = onSurfaceChange;
74
+ const setSurface = useCallback((next) => {
75
+ if (next !== "voice" && next !== "chat")
76
+ return;
77
+ setChosenSurface((cur) => (cur === next ? cur : next));
78
+ }, []);
79
+ const announcedSurfaceRef = useRef(null);
80
+ useEffect(() => { client.setSurface(surface); }, [client, surface]);
81
+ useEffect(() => {
82
+ if (announcedSurfaceRef.current === surface)
83
+ return;
84
+ const first = announcedSurfaceRef.current === null;
85
+ announcedSurfaceRef.current = surface;
86
+ if (!first)
87
+ surfaceChangeRef.current?.(surface);
88
+ }, [surface]);
60
89
  const [currentScreen, setCurrentScreen] = useState(null);
61
90
  const [launcherVisible, setLauncherVisible] = useState(false);
62
91
  const [launcherInset, setLauncherInset] = useState({ right: 16, bottom: 20 });
@@ -162,6 +191,15 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
162
191
  })),
163
192
  client.on("widget", (widget) => setWidgets((prev) => [...prev, widget])),
164
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
+ }),
165
203
  client.on("friction", (s) => emitEvent({ type: "friction_detected", name: String(s?.name ?? ""), screen: String(s?.screen ?? ""), detail: String(s?.detail ?? "") })),
166
204
  client.on("muted", (m) => {
167
205
  setMutedState(m);
@@ -212,7 +250,7 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
212
250
  client.highlight(name, Number(args?.ttl_ms) || 6000);
213
251
  return { status: "ok", message: `Highlighted ${name} for the customer.` };
214
252
  }));
215
- if (autoConnect) {
253
+ if (autoConnect && !(client.hasVoiceHost && surfaceRef.current !== "chat")) {
216
254
  setConnecting(true);
217
255
  client.connect().catch((err) => {
218
256
  setConnecting(false);
@@ -225,6 +263,27 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
225
263
  client.close();
226
264
  };
227
265
  }, [client, autoConnect]);
266
+ useEffect(() => {
267
+ const apply = () => {
268
+ if (!client.hasVoiceHost)
269
+ return;
270
+ if (surface === "chat") {
271
+ if (client.isConnected)
272
+ return;
273
+ setConnecting(true);
274
+ client.connect().catch((err) => {
275
+ setConnecting(false);
276
+ console.error("[Graine]", err?.message ?? err);
277
+ setError(err?.message ?? "Could not start the chat.");
278
+ });
279
+ return;
280
+ }
281
+ client.close();
282
+ setConnecting(false);
283
+ };
284
+ apply();
285
+ return client.on("voice_host", apply);
286
+ }, [client, surface]);
228
287
  useEffect(() => {
229
288
  if (!endOnBackground)
230
289
  return;
@@ -279,8 +338,16 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
279
338
  onEvent,
280
339
  track: (name, data) => client.track(name, data),
281
340
  identify: (user) => client.identify(user),
341
+ surface,
342
+ surfaces: surfacePolicy.allowed,
343
+ canSwitch: surfacePolicy.canSwitch,
344
+ setSurface,
345
+ connectionQuality,
346
+ network,
347
+ setVariables: (vars) => client.setVariables(vars),
282
348
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
283
- launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady]);
349
+ launcherVisible, launcherInset, currentScreen, onEvent, appearance, sessionReady,
350
+ surface, surfacePolicy, setSurface, connectionQuality, network]);
284
351
  const wantsCapture = captureTaps ?? !!fab;
285
352
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
286
353
  return _jsx(Ctx.Provider, { value: value, children: body });
@@ -294,6 +361,22 @@ function useGraine() {
294
361
  export function useGraineAgent() {
295
362
  return useGraine();
296
363
  }
364
+ export function useGraineSurface() {
365
+ const { surface, surfaces, canSwitch, setSurface } = useGraine();
366
+ return { surface, surfaces, canSwitch, setSurface };
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
+ }
297
380
  export function useGraineEvents(handler) {
298
381
  const { onEvent } = useGraine();
299
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,9 @@ 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, } = 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();
29
+ const isChat = surface === "chat";
27
30
  const dashboard = resolveNativeAppearance(appearance, {
28
31
  accent: "#318CE7",
29
32
  heading: "Assistant",
@@ -36,7 +39,15 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
36
39
  const base = (scheme === "auto" ? deviceScheme === "light" : scheme === "light") ? LIGHT : DARK;
37
40
  return makeStyles(themeProp ? { ...base, ...themeProp } : base);
38
41
  }, [scheme, deviceScheme, themeProp]);
39
- const [expanded, setExpanded] = useState(false);
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
+ });
40
51
  const [captions, setCaptions] = useState(captionsOn);
41
52
  const [draft, setDraft] = useState("");
42
53
  const scrollRef = useRef(null);
@@ -45,15 +56,19 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
45
56
  const waitingMoment = "connecting";
46
57
  const status = error
47
58
  ? "unavailable"
48
- : connecting ? "connecting…"
49
- : muted ? "muted"
50
- : agentSpeaking || (last?.role === "agent" && last.live) ? "speaking"
51
- : connected ? "listening"
52
- : "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";
53
66
  useEffect(() => {
54
67
  if (expanded)
55
68
  requestAnimationFrame(() => scrollRef.current?.scrollToEnd({ animated: true }));
56
69
  }, [messages, expanded]);
70
+ useEffect(() => { if (surface === "chat")
71
+ setExpanded(true); }, [surface]);
57
72
  useEffect(() => {
58
73
  if (!expanded && last?.role === "agent")
59
74
  setExpanded(true);
@@ -67,17 +82,20 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
67
82
  }, [draft, send]);
68
83
  if (hidden || error || !launcherVisible)
69
84
  return null;
70
- return (_jsxs(View, { pointerEvents: "box-none", style: [
85
+ return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: [
71
86
  styles.root,
72
87
  {
73
- bottom: launcherInset.bottom ?? bottomInset,
88
+ bottom: baseBottom + drag.offset + keyboardLift,
74
89
  right: launcherInset.right ?? 12,
75
90
  left: launcherInset.left ?? 12,
76
91
  },
77
- ], 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: [
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
93
+ ? "I can see what you\u2019re working on \u2014 type a question, or I\u2019ll step in if you get stuck."
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: [
78
95
  styles.bubble,
79
96
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
80
- ], 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 && 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 }))] }), waiting ? 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 ? "⌄" : "⌃" }) })] }))] })] }));
81
99
  }
82
100
  export const GraineLauncher = GraineAgentBar;
83
101
  const makeStyles = (t) => StyleSheet.create({
@@ -87,6 +105,7 @@ const makeStyles = (t) => StyleSheet.create({
87
105
  marginBottom: 8, overflow: "hidden",
88
106
  },
89
107
  transcript: { maxHeight: 240 },
108
+ transcriptLifted: { maxHeight: 150 },
90
109
  transcriptInner: { padding: 14, gap: 8 },
91
110
  empty: { color: t.sub, fontSize: 14, lineHeight: 21 },
92
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
- export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
64
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -4,9 +4,14 @@ import { AppState, View } from "react-native";
4
4
  import { useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
6
  export const CAPTION_HISTORY = 12;
7
- export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
8
- const { client, appearance } = useGraineAgent();
7
+ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active: activeProp = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
8
+ const { client, appearance, surface, surfaces } = useGraineAgent();
9
+ const active = activeProp && surface !== "chat";
9
10
  const ref = useRef(null);
11
+ useEffect(() => {
12
+ client.setVoiceHost(true);
13
+ return () => client.setVoiceHost(false);
14
+ }, [client]);
10
15
  const startedRef = useRef(false);
11
16
  const activeRef = useRef(active);
12
17
  activeRef.current = active;
@@ -40,9 +45,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
40
45
  const onErrorRef = useRef(onError);
41
46
  onErrorRef.current = onError;
42
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;
43
51
  const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
44
52
  const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
45
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);
46
57
  const attemptRef = useRef(0);
47
58
  const retryTimerRef = useRef(null);
48
59
  const readyTimerRef = useRef(null);
@@ -84,13 +95,47 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
84
95
  catch { }
85
96
  }, delay);
86
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
+ }, []);
87
114
  const armReadyTimer = useCallback(() => {
88
115
  clearTimer(readyTimerRef);
89
- readyTimerRef.current = setTimeout(() => {
90
- readyTimerRef.current = null;
91
- scheduleRetry("ready-timeout");
92
- }, READY_TIMEOUT_MS);
93
- }, [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]);
94
139
  const restartLoad = useCallback(() => {
95
140
  clearTimer(retryTimerRef);
96
141
  attemptRef.current = 0;
@@ -216,6 +261,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
216
261
  }
217
262
  return;
218
263
  case "graine:ready":
264
+ post({
265
+ type: "graine:variables",
266
+ variables: client.getVariables(),
267
+ });
219
268
  clearTimer(readyTimerRef);
220
269
  clearTimer(retryTimerRef);
221
270
  attemptRef.current = 0;
@@ -232,8 +281,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
232
281
  }
233
282
  return;
234
283
  case "graine:call":
235
- if (msg.connected)
284
+ if (msg.connected) {
236
285
  endedNotifiedRef.current = false;
286
+ setReconnecting(false);
287
+ }
237
288
  setConnected(!!msg.connected);
238
289
  setConnecting(!!msg.connecting);
239
290
  client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
@@ -269,12 +320,27 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
269
320
  clearTimer(endWatchdogRef);
270
321
  setConnected(false);
271
322
  setConnecting(false);
323
+ setReconnecting(false);
324
+ setQuality(client.reportQuality(null));
272
325
  connectedRef.current = false;
273
326
  if (!endedNotifiedRef.current) {
274
327
  endedNotifiedRef.current = true;
275
328
  onEnded?.();
276
329
  }
277
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;
278
344
  case "graine:mic-denied":
279
345
  client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
280
346
  onMicDenied?.(String(msg.reason || "denied"));
@@ -299,7 +365,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
299
365
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
300
366
  const pendingStartRef = useRef(false);
301
367
  const lastCaption = captions.length ? captions[captions.length - 1] : null;
302
- const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
368
+ const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
303
369
  : connecting || (!ready && !!voiceAgentId) ? "connecting"
304
370
  : !connected ? "idle"
305
371
  : lastCaption?.role === "agent" && lastCaption.live ? "speaking"
@@ -330,11 +396,21 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
330
396
  end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
331
397
  connectivity,
332
398
  retry: restartLoad,
399
+ quality,
400
+ network,
401
+ reconnecting,
333
402
  setMuted: useCallback((m) => {
334
403
  setMutedState(m);
335
404
  post({ type: "graine:set-muted", muted: m });
336
405
  }, [post]),
337
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]);
338
414
  useEffect(() => () => { post({ type: "graine:end-call" }); }, [post]);
339
415
  const activeSeenRef = useRef(null);
340
416
  useEffect(() => {
@@ -399,14 +475,14 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
399
475
  return () => { if (pending)
400
476
  clearTimeout(pending); sub.remove(); };
401
477
  }, [post]);
402
- if (!voiceAgentId)
478
+ if (!voiceAgentId || !surfaces.includes("voice"))
403
479
  return _jsx(_Fragment, { children: children?.(api) });
404
480
  const effectiveTransport = transport ?? (appearance?.audioTransport === "rtc" ? "rtc" : "ws");
405
481
  const src = `${client.opts.baseUrl.replace(/\/$/, "")}/embed/${voiceAgentId}` +
406
482
  `?mode=voice&branding=0&embed=1&transport=${effectiveTransport}` +
407
- `&b=${mountStampRef.current}` +
483
+ `&b=${client.config?.pageVersion ?? mountStampRef.current}` +
408
484
  (mountedIdleRef.current ? "&idle=1" : "") +
409
485
  `&pk=${encodeURIComponent(client.opts.publishableKey)}` +
410
486
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
411
- 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)] }));
412
488
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.33.6",
3
+ "version": "0.35.0",
4
4
  "type": "module",
5
- "description": "Graine in-app agent for React Native \u2014 an agent that sees the screen your customer is on and can act on it.",
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",
7
7
  "private": false,
8
8
  "main": "dist/index.js",