@graineai/inapp-react-native 0.33.3 → 0.33.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -238,6 +238,9 @@ export class GraineInAppClient {
238
238
  case "widget":
239
239
  this.emit("widget", msg.widget);
240
240
  break;
241
+ case "widget_pending":
242
+ this.emit("widget_pending", msg.widget || {});
243
+ break;
241
244
  case "app_action":
242
245
  void this.runAction(msg.action);
243
246
  break;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, ScreenContext, ScreenField, WidgetSurface, } from "./protocol.js";
2
2
  export { widgetSurface } from "./protocol.js";
3
+ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, WIDGET_SURFACE_CHOICES, DEFAULT_WIDGET_PRESET, WIDGET_PLATFORMS, PLATFORM_GRAMMAR, normaliseWidgetStyle, resolveWidgetLook, resolvePlatform, } from "./protocol.js";
4
+ export type { WidgetStyle, WidgetLook, WidgetPreset, WidgetImageSize, WidgetDensity, WidgetButton, WidgetPlatform, PlatformGrammar } from "./protocol.js";
5
+ export { LOADER_KINDS, LOADER_SIZES, LOADER_SPEEDS, LOADER_MOMENTS, DEFAULT_LOADER_KIND, DEFAULT_LOADER_TEXT, LOADER_TEXT_MAX, normaliseLoaderStyle, resolveLoader, } from "./protocol.js";
6
+ export type { LoaderKind, LoaderSize, LoaderSpeed, LoaderMoment, LoaderText, LoaderStyle, LoaderLook } from "./protocol.js";
7
+ export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
8
+ export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
9
+ export type { WidgetSelect } from "./protocol.js";
10
+ export type { WidgetSelectAction } from "./protocol.js";
3
11
  export { GraineWidget } from "./react-native/widget.js";
12
+ export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
13
+ export type { GraineLoaderProps } from "./react-native/loader.js";
14
+ export { PENDING_WIDGET_MS } from "./react-native/index.js";
4
15
  export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
5
16
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
6
17
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, type FrictionConfig, type FrictionScreenRules, type FrictionNudge, type FrictionSignal, type FrictionKind, } from "./friction.js";
package/dist/index.js CHANGED
@@ -1,5 +1,11 @@
1
1
  export { widgetSurface } from "./protocol.js";
2
+ export { WIDGET_PRESETS, WIDGET_IMAGE_SIZES, WIDGET_DENSITIES, WIDGET_BUTTONS, WIDGET_SURFACE_CHOICES, DEFAULT_WIDGET_PRESET, WIDGET_PLATFORMS, PLATFORM_GRAMMAR, normaliseWidgetStyle, resolveWidgetLook, resolvePlatform, } from "./protocol.js";
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
+ export { normaliseSelectAction, fillSelectArgs, fillSelectTemplate, selectCall, NAVIGATE_ACTION, SELECT_ARG_MAX, SELECT_VALUE_MAX } from "./protocol.js";
5
+ export { WIDGET_SELECTS, MAX_SELECT_DEFAULT, manySummary } from "./protocol.js";
2
6
  export { GraineWidget } from "./react-native/widget.js";
7
+ export { GraineLoader, useGraineLoader } from "./react-native/loader.js";
8
+ export { PENDING_WIDGET_MS } from "./react-native/index.js";
3
9
  export { GraineInAppClient, } from "./client.js";
4
10
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
5
11
  export { VoiceSession, liveAudioStreamAdapter, base64ToPcm16, CAPTURE_SAMPLE_RATE, } from "./voice.js";
@@ -33,6 +33,8 @@ export interface AppEvent {
33
33
  detail?: string;
34
34
  cooldown_seconds?: number;
35
35
  max_per_session?: number;
36
+ quiet?: boolean;
37
+ data?: Record<string, unknown>;
36
38
  }
37
39
  export interface AppEventFrame {
38
40
  type: "app_event";
@@ -64,3 +66,116 @@ export declare const UNNAMED_TAP = "tap:screen";
64
66
  export declare function resolveWidgetImages(data: Record<string, unknown>, baseUrl: string): Record<string, unknown>;
65
67
  export type WidgetSurface = "screen" | "sheet" | "card";
66
68
  export declare function widgetSurface(kind: string | null | undefined): WidgetSurface;
69
+ export type WidgetPreset = "gallery" | "cards" | "list" | "spotlight" | "compact";
70
+ export type WidgetImageSize = "small" | "medium" | "large";
71
+ export type WidgetDensity = "comfortable" | "compact";
72
+ export type WidgetButton = "filled" | "outline" | "row";
73
+ export type WidgetSelect = "one" | "many";
74
+ export type WidgetPlatform = "auto" | "ios" | "material" | "oneui";
75
+ export interface WidgetStyle {
76
+ preset?: WidgetPreset;
77
+ imageSize?: WidgetImageSize;
78
+ density?: WidgetDensity;
79
+ button?: WidgetButton;
80
+ facts?: boolean;
81
+ columns?: 1 | 2;
82
+ surface?: "auto" | WidgetSurface;
83
+ radius?: number;
84
+ accent?: string;
85
+ platform?: WidgetPlatform;
86
+ select?: WidgetSelect;
87
+ maxSelect?: number;
88
+ }
89
+ export declare const WIDGET_PRESETS: ReadonlyArray<WidgetPreset>;
90
+ export declare const WIDGET_IMAGE_SIZES: ReadonlyArray<WidgetImageSize>;
91
+ export declare const WIDGET_DENSITIES: ReadonlyArray<WidgetDensity>;
92
+ export declare const WIDGET_BUTTONS: ReadonlyArray<WidgetButton>;
93
+ export declare const WIDGET_SURFACE_CHOICES: ReadonlyArray<"auto" | WidgetSurface>;
94
+ export declare const WIDGET_PLATFORMS: ReadonlyArray<WidgetPlatform>;
95
+ export declare const WIDGET_SELECTS: ReadonlyArray<WidgetSelect>;
96
+ export declare const MAX_SELECT_DEFAULT = 3;
97
+ export declare const DEFAULT_WIDGET_PRESET: WidgetPreset;
98
+ export declare function normaliseWidgetStyle(raw: unknown): WidgetStyle;
99
+ export interface WidgetLook {
100
+ preset: WidgetPreset;
101
+ imageSize: WidgetImageSize;
102
+ density: WidgetDensity;
103
+ button: WidgetButton;
104
+ facts: boolean;
105
+ columns: 1 | 2;
106
+ surface: WidgetSurface;
107
+ radius?: number;
108
+ accent?: string;
109
+ platform: WidgetPlatform;
110
+ select: WidgetSelect;
111
+ maxSelect: number;
112
+ }
113
+ export declare function resolveWidgetLook(style: WidgetStyle | null | undefined, kind: string | null | undefined): WidgetLook;
114
+ export declare function manySummary(titles: string[], verb?: string): string;
115
+ export interface PlatformGrammar {
116
+ radius: number;
117
+ chipRadius: number;
118
+ buttonRadius: number;
119
+ pillButtons: boolean;
120
+ separatorInset: number;
121
+ grabber: {
122
+ width: number;
123
+ height: number;
124
+ };
125
+ titleWeight: "600" | "700" | "500";
126
+ padBoost: number;
127
+ chipOutlined: boolean;
128
+ }
129
+ export declare const PLATFORM_GRAMMAR: Record<Exclude<WidgetPlatform, "auto">, PlatformGrammar>;
130
+ export declare function resolvePlatform(platform: WidgetPlatform | undefined, os: string | undefined, manufacturer?: string | null): Exclude<WidgetPlatform, "auto">;
131
+ export type LoaderKind = "dots" | "pulse" | "ring" | "bars" | "mark";
132
+ export type LoaderSize = "small" | "medium" | "large";
133
+ export type LoaderSpeed = "calm" | "normal" | "quick";
134
+ export type LoaderMoment = "connecting" | "reconnecting" | "thinking" | "component";
135
+ export interface LoaderText {
136
+ connecting?: string;
137
+ reconnecting?: string;
138
+ thinking?: string;
139
+ component?: string;
140
+ }
141
+ export interface LoaderStyle {
142
+ kind?: LoaderKind;
143
+ color?: string;
144
+ size?: LoaderSize;
145
+ speed?: LoaderSpeed;
146
+ text?: LoaderText;
147
+ skeleton?: boolean;
148
+ }
149
+ export declare const LOADER_KINDS: ReadonlyArray<LoaderKind>;
150
+ export declare const LOADER_SIZES: ReadonlyArray<LoaderSize>;
151
+ export declare const LOADER_SPEEDS: ReadonlyArray<LoaderSpeed>;
152
+ export declare const LOADER_MOMENTS: ReadonlyArray<LoaderMoment>;
153
+ export declare const DEFAULT_LOADER_KIND: LoaderKind;
154
+ export declare const DEFAULT_LOADER_TEXT: Required<LoaderText>;
155
+ export declare const LOADER_TEXT_MAX = 40;
156
+ export declare function normaliseLoaderStyle(raw: unknown): LoaderStyle;
157
+ export interface LoaderLook {
158
+ kind: LoaderKind;
159
+ color: string | null;
160
+ px: number;
161
+ periodMs: number;
162
+ text: Required<LoaderText>;
163
+ skeleton: boolean;
164
+ }
165
+ export declare function resolveLoader(style: LoaderStyle | null | undefined): LoaderLook;
166
+ export interface WidgetSelectAction {
167
+ path?: string;
168
+ action?: string;
169
+ args?: Record<string, string>;
170
+ say?: string;
171
+ }
172
+ export declare const NAVIGATE_ACTION = "open_screen";
173
+ export declare const SELECT_ARG_MAX = 10;
174
+ export declare const SELECT_VALUE_MAX = 200;
175
+ export declare function normaliseSelectAction(raw: unknown): WidgetSelectAction;
176
+ export declare function fillSelectTemplate(template: string, item: Record<string, unknown>): string;
177
+ export declare function selectCall(onSelect: WidgetSelectAction | undefined | null, item: Record<string, unknown>): {
178
+ name: string;
179
+ args: Record<string, string>;
180
+ } | null;
181
+ export declare function fillSelectArgs(args: Record<string, string> | undefined, item: Record<string, unknown>): Record<string, string>;
package/dist/protocol.js CHANGED
@@ -41,3 +41,223 @@ const SURFACE = {
41
41
  export function widgetSurface(kind) {
42
42
  return SURFACE[String(kind ?? "").trim().toLowerCase()] ?? "card";
43
43
  }
44
+ export const WIDGET_PRESETS = ["gallery", "cards", "list", "spotlight", "compact"];
45
+ export const WIDGET_IMAGE_SIZES = ["small", "medium", "large"];
46
+ export const WIDGET_DENSITIES = ["comfortable", "compact"];
47
+ export const WIDGET_BUTTONS = ["filled", "outline", "row"];
48
+ export const WIDGET_SURFACE_CHOICES = ["auto", "screen", "sheet", "card"];
49
+ export const WIDGET_PLATFORMS = ["auto", "ios", "material", "oneui"];
50
+ export const WIDGET_SELECTS = ["one", "many"];
51
+ export const MAX_SELECT_DEFAULT = 3;
52
+ export const DEFAULT_WIDGET_PRESET = "cards";
53
+ export function normaliseWidgetStyle(raw) {
54
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
55
+ return {};
56
+ const r = raw;
57
+ const out = {};
58
+ const pick = (v, allowed) => {
59
+ const s = String(v ?? "").trim().toLowerCase();
60
+ return allowed.includes(s) ? s : undefined;
61
+ };
62
+ const preset = pick(r.preset, WIDGET_PRESETS);
63
+ if (preset)
64
+ out.preset = preset;
65
+ const imageSize = pick(r.imageSize, WIDGET_IMAGE_SIZES);
66
+ if (imageSize)
67
+ out.imageSize = imageSize;
68
+ const density = pick(r.density, WIDGET_DENSITIES);
69
+ if (density)
70
+ out.density = density;
71
+ const button = pick(r.button, WIDGET_BUTTONS);
72
+ if (button)
73
+ out.button = button;
74
+ if (typeof r.facts === "boolean")
75
+ out.facts = r.facts;
76
+ if (r.columns === 2 || r.columns === "2")
77
+ out.columns = 2;
78
+ else if (r.columns === 1 || r.columns === "1")
79
+ out.columns = 1;
80
+ const surface = pick(r.surface, WIDGET_SURFACE_CHOICES);
81
+ if (surface)
82
+ out.surface = surface;
83
+ if (typeof r.radius === "number" && Number.isFinite(r.radius))
84
+ out.radius = Math.max(0, Math.min(24, Math.round(r.radius)));
85
+ if (typeof r.accent === "string" && /^#[0-9a-f]{6}$/i.test(r.accent.trim()))
86
+ out.accent = r.accent.trim().toUpperCase();
87
+ const platform = pick(r.platform, WIDGET_PLATFORMS);
88
+ if (platform)
89
+ out.platform = platform;
90
+ const select = pick(r.select, WIDGET_SELECTS);
91
+ if (select)
92
+ out.select = select;
93
+ if (typeof r.maxSelect === "number" && Number.isFinite(r.maxSelect)) {
94
+ out.maxSelect = Math.max(2, Math.min(8, Math.round(r.maxSelect)));
95
+ }
96
+ return out;
97
+ }
98
+ const PRESET_DEFAULTS = {
99
+ gallery: { imageSize: "large", button: "filled" },
100
+ cards: { imageSize: "medium", button: "filled" },
101
+ list: { imageSize: "small", button: "row" },
102
+ spotlight: { imageSize: "large", button: "filled" },
103
+ compact: { imageSize: "small", button: "outline" },
104
+ };
105
+ export function resolveWidgetLook(style, kind) {
106
+ const s = normaliseWidgetStyle(style);
107
+ const preset = s.preset ?? DEFAULT_WIDGET_PRESET;
108
+ const d = PRESET_DEFAULTS[preset];
109
+ const surface = s.surface && s.surface !== "auto" ? s.surface : widgetSurface(kind);
110
+ return {
111
+ preset,
112
+ imageSize: s.imageSize ?? d.imageSize,
113
+ density: s.density ?? "comfortable",
114
+ button: s.button ?? d.button,
115
+ facts: s.facts ?? true,
116
+ columns: (preset === "gallery" || preset === "cards") && s.columns === 2 ? 2 : 1,
117
+ surface,
118
+ ...(s.radius !== undefined ? { radius: s.radius } : {}),
119
+ ...(s.accent ? { accent: s.accent } : {}),
120
+ platform: s.platform ?? "auto",
121
+ select: s.select ?? "one",
122
+ maxSelect: s.maxSelect ?? MAX_SELECT_DEFAULT,
123
+ };
124
+ }
125
+ export function manySummary(titles, verb = "compare") {
126
+ const names = titles.map((t) => String(t).trim()).filter(Boolean);
127
+ if (!names.length)
128
+ return "";
129
+ const list = names.length === 1 ? names[0]
130
+ : `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
131
+ return `Let's ${verb} ${list}.`;
132
+ }
133
+ export const PLATFORM_GRAMMAR = {
134
+ ios: { radius: 12, chipRadius: 8, buttonRadius: 12, pillButtons: false, separatorInset: 16, grabber: { width: 36, height: 5 }, titleWeight: "600", padBoost: 0, chipOutlined: false },
135
+ material: { radius: 16, chipRadius: 8, buttonRadius: 20, pillButtons: true, separatorInset: 0, grabber: { width: 32, height: 4 }, titleWeight: "500", padBoost: 0, chipOutlined: true },
136
+ oneui: { radius: 26, chipRadius: 999, buttonRadius: 999, pillButtons: true, separatorInset: 0, grabber: { width: 40, height: 4 }, titleWeight: "700", padBoost: 2, chipOutlined: false },
137
+ };
138
+ export function resolvePlatform(platform, os, manufacturer) {
139
+ if (platform && platform !== "auto")
140
+ return platform;
141
+ if (os === "android")
142
+ return /samsung/i.test(String(manufacturer ?? "")) ? "oneui" : "material";
143
+ return "ios";
144
+ }
145
+ export const LOADER_KINDS = ["dots", "pulse", "ring", "bars", "mark"];
146
+ export const LOADER_SIZES = ["small", "medium", "large"];
147
+ export const LOADER_SPEEDS = ["calm", "normal", "quick"];
148
+ export const LOADER_MOMENTS = ["connecting", "reconnecting", "thinking", "component"];
149
+ export const DEFAULT_LOADER_KIND = "dots";
150
+ export const DEFAULT_LOADER_TEXT = {
151
+ connecting: "Connecting…",
152
+ reconnecting: "Reconnecting…",
153
+ thinking: "Thinking…",
154
+ component: "Loading…",
155
+ };
156
+ export const LOADER_TEXT_MAX = 40;
157
+ export function normaliseLoaderStyle(raw) {
158
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
159
+ return {};
160
+ const r = raw;
161
+ const out = {};
162
+ const pick = (v, allowed) => {
163
+ const s = String(v ?? "").trim().toLowerCase();
164
+ return allowed.includes(s) ? s : undefined;
165
+ };
166
+ const kind = pick(r.kind, LOADER_KINDS);
167
+ if (kind)
168
+ out.kind = kind;
169
+ const size = pick(r.size, LOADER_SIZES);
170
+ if (size)
171
+ out.size = size;
172
+ const speed = pick(r.speed, LOADER_SPEEDS);
173
+ if (speed)
174
+ out.speed = speed;
175
+ if (typeof r.color === "string") {
176
+ const c = r.color.trim();
177
+ if (c.toLowerCase() === "accent")
178
+ out.color = "accent";
179
+ else if (/^#[0-9a-f]{6}$/i.test(c))
180
+ out.color = c.toUpperCase();
181
+ }
182
+ if (typeof r.skeleton === "boolean")
183
+ out.skeleton = r.skeleton;
184
+ if (r.text && typeof r.text === "object" && !Array.isArray(r.text)) {
185
+ const t = r.text;
186
+ const text = {};
187
+ for (const m of LOADER_MOMENTS) {
188
+ const v = typeof t[m] === "string" ? t[m].trim().slice(0, LOADER_TEXT_MAX) : "";
189
+ if (v)
190
+ text[m] = v;
191
+ }
192
+ if (Object.keys(text).length)
193
+ out.text = text;
194
+ }
195
+ return out;
196
+ }
197
+ const LOADER_PX = { small: 14, medium: 20, large: 28 };
198
+ const LOADER_PERIOD = { calm: 1600, normal: 1100, quick: 700 };
199
+ export function resolveLoader(style) {
200
+ const s = normaliseLoaderStyle(style);
201
+ return {
202
+ kind: s.kind ?? DEFAULT_LOADER_KIND,
203
+ color: s.color && s.color !== "accent" ? s.color : null,
204
+ px: LOADER_PX[s.size ?? "medium"],
205
+ periodMs: LOADER_PERIOD[s.speed ?? "normal"],
206
+ text: { ...DEFAULT_LOADER_TEXT, ...(s.text ?? {}) },
207
+ skeleton: s.skeleton ?? true,
208
+ };
209
+ }
210
+ const ACTION_NAME = /^[a-z][a-z0-9_]{0,63}$/;
211
+ export const NAVIGATE_ACTION = "open_screen";
212
+ export const SELECT_ARG_MAX = 10;
213
+ export const SELECT_VALUE_MAX = 200;
214
+ export function normaliseSelectAction(raw) {
215
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
216
+ return {};
217
+ const r = raw;
218
+ const out = {};
219
+ const name = String(r.action ?? "").trim().toLowerCase();
220
+ if (ACTION_NAME.test(name))
221
+ out.action = name;
222
+ const path = typeof r.path === "string" ? r.path.trim().slice(0, SELECT_VALUE_MAX) : "";
223
+ if (path)
224
+ out.path = path;
225
+ if (r.args && typeof r.args === "object" && !Array.isArray(r.args)) {
226
+ const args = {};
227
+ for (const [k, v] of Object.entries(r.args)) {
228
+ const key = String(k).trim();
229
+ if (!key || Object.keys(args).length >= SELECT_ARG_MAX)
230
+ continue;
231
+ if (v === null || v === undefined || typeof v === "object")
232
+ continue;
233
+ args[key] = String(v).slice(0, SELECT_VALUE_MAX);
234
+ }
235
+ if (Object.keys(args).length)
236
+ out.args = args;
237
+ }
238
+ const say = typeof r.say === "string" ? r.say.trim().slice(0, SELECT_VALUE_MAX) : "";
239
+ if (say)
240
+ out.say = say;
241
+ return out.action || out.path ? out : {};
242
+ }
243
+ export function fillSelectTemplate(template, item) {
244
+ return String(template).replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_, key) => {
245
+ const v = item[key];
246
+ return v === null || v === undefined ? "" : String(v);
247
+ });
248
+ }
249
+ export function selectCall(onSelect, item) {
250
+ const s = onSelect ?? {};
251
+ if (!s.action && !s.path)
252
+ return null;
253
+ const args = fillSelectArgs(s.args, item);
254
+ if (s.path)
255
+ args.path = fillSelectTemplate(s.path, item);
256
+ return { name: s.action || NAVIGATE_ACTION, args };
257
+ }
258
+ export function fillSelectArgs(args, item) {
259
+ const out = {};
260
+ for (const [k, v] of Object.entries(args ?? {}))
261
+ out[k] = fillSelectTemplate(v, item);
262
+ return out;
263
+ }
@@ -2,6 +2,7 @@ import React from "react";
2
2
  import { GraineInAppClient, type GraineInAppOptions } from "../client.js";
3
3
  import { type AudioAdapter, type VoiceSessionOptions } from "../voice.js";
4
4
  import type { ActionHandler, ScreenContext, ScreenField } from "../protocol.js";
5
+ import { type WidgetStyle, type WidgetSelectAction } from "../protocol.js";
5
6
  export { GraineFab, GraineTouchCapture } from "./fab.js";
6
7
  export type { GraineFabProps, GraineTouchCaptureProps } from "./fab.js";
7
8
  export { widgetSurface } from "../protocol.js";
@@ -68,11 +69,15 @@ export type GraineEvent = {
68
69
  screen: string;
69
70
  detail: string;
70
71
  };
72
+ export declare const PENDING_WIDGET_MS = 12000;
73
+ export declare const AWAITING_REPLY_MS = 12000;
71
74
  export interface GraineWidget {
72
75
  id: string;
73
76
  kind: string;
74
77
  data: Record<string, unknown>;
75
78
  fallbackText: string;
79
+ style?: WidgetStyle;
80
+ onSelect?: WidgetSelectAction;
76
81
  }
77
82
  export interface Caption {
78
83
  role: "agent" | "customer";
@@ -130,6 +135,13 @@ export declare function useGraineWidget(): {
130
135
  data: Record<string, unknown>;
131
136
  summary: string;
132
137
  } | null;
138
+ loading: {
139
+ id: string;
140
+ kind: string;
141
+ } | null;
142
+ awaitingReply: boolean;
143
+ closed: GraineWidget | null;
144
+ reopen: () => void;
133
145
  submit: (data: Record<string, unknown>, summary: string) => void;
134
146
  dismiss: () => void;
135
147
  };
@@ -3,11 +3,13 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
3
3
  import { AppState } from "react-native";
4
4
  import { GraineInAppClient } from "../client.js";
5
5
  import { VoiceSession } from "../voice.js";
6
- import { resolveWidgetImages } from "../protocol.js";
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
9
  export { widgetSurface } from "../protocol.js";
10
10
  import { LauncherVisibilityTracker, activeRouteName, } from "./navigation.js";
11
+ export const PENDING_WIDGET_MS = 12000;
12
+ export const AWAITING_REPLY_MS = 12000;
11
13
  export const Ctx = createContext(null);
12
14
  let defaultClient = null;
13
15
  export function setDefaultClient(client) {
@@ -396,36 +398,126 @@ export function useGraineWidget() {
396
398
  kind: String(w.kind || ""),
397
399
  data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
398
400
  fallbackText: String(w.fallback_text || ""),
401
+ style: normaliseWidgetStyle(w.style),
402
+ onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
399
403
  }
400
404
  : null, [client]);
401
405
  const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
402
406
  const [answered, setAnswered] = useState(null);
407
+ const [loading, setLoading] = useState(null);
408
+ const loadingTimer = useRef(null);
409
+ const [awaitingReply, setAwaitingReply] = useState(false);
410
+ const [closed, setClosed] = useState(null);
411
+ const awaitingTimer = useRef(null);
412
+ useEffect(() => () => {
413
+ if (loadingTimer.current)
414
+ clearTimeout(loadingTimer.current);
415
+ if (awaitingTimer.current)
416
+ clearTimeout(awaitingTimer.current);
417
+ }, []);
418
+ useEffect(() => {
419
+ const done = () => {
420
+ if (awaitingTimer.current) {
421
+ clearTimeout(awaitingTimer.current);
422
+ awaitingTimer.current = null;
423
+ }
424
+ setAwaitingReply(false);
425
+ };
426
+ const offs = [
427
+ client.on("agent_speaking", (speaking) => { if (speaking !== false)
428
+ done(); }),
429
+ client.on("chunk", done),
430
+ client.on("caption", (c) => { if (c?.role === "agent")
431
+ done(); }),
432
+ client.on("widget", done),
433
+ ];
434
+ return () => { for (const off of offs)
435
+ off(); };
436
+ }, [client]);
403
437
  useEffect(() => {
404
438
  const pending = adopt(client.pendingWidget);
405
439
  if (pending)
406
440
  setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
407
- return client.on("widget", (w) => {
441
+ const offPending = client.on("widget_pending", (hint) => {
442
+ setLoading({ id: String(hint?.widget_id ?? ""), kind: String(hint?.kind ?? "") });
443
+ if (loadingTimer.current)
444
+ clearTimeout(loadingTimer.current);
445
+ loadingTimer.current = setTimeout(() => setLoading(null), PENDING_WIDGET_MS);
446
+ });
447
+ const offWidget = client.on("widget", (w) => {
408
448
  const next = adopt(w);
409
449
  if (!next)
410
450
  return;
451
+ if (loadingTimer.current) {
452
+ clearTimeout(loadingTimer.current);
453
+ loadingTimer.current = null;
454
+ }
455
+ setLoading(null);
456
+ setClosed(null);
411
457
  setWidget(next);
412
458
  setAnswered(null);
413
459
  });
460
+ return () => { offPending(); offWidget(); };
414
461
  }, [client, adopt]);
415
462
  const submit = useCallback((data, summary) => {
416
463
  if (!widget)
417
464
  return;
465
+ const onSelect = widget.onSelect;
466
+ const item = { ...data, summary };
467
+ const call = selectCall(onSelect, item);
468
+ if (call) {
469
+ void client
470
+ .executeAction(call.name, call.args)
471
+ .then((res) => {
472
+ if (res?.status && res.status !== "ok") {
473
+ client.reportEvent({
474
+ name: "in_app_action_failed",
475
+ detail: `"${call.name}" did not run: ${res?.message || res.status}. Do not describe a screen the customer cannot see.`,
476
+ });
477
+ }
478
+ })
479
+ .catch(() => { });
480
+ if (onSelect?.say)
481
+ summary = fillSelectTemplate(onSelect.say, item);
482
+ }
418
483
  client.submitWidget(widget.id, data, summary);
419
484
  client.clearPendingWidget();
420
485
  setWidget(null);
421
486
  setAnswered({ widget, data, summary });
487
+ setAwaitingReply(true);
488
+ if (awaitingTimer.current)
489
+ clearTimeout(awaitingTimer.current);
490
+ awaitingTimer.current = setTimeout(() => setAwaitingReply(false), AWAITING_REPLY_MS);
422
491
  }, [client, widget]);
423
492
  const dismiss = useCallback(() => {
493
+ if (widget) {
494
+ client.reportEvent({
495
+ name: "component_dismissed",
496
+ detail: "The customer closed the component without choosing. Do not refer to options they cannot see; offer to show it again if they want it.",
497
+ quiet: true,
498
+ data: {
499
+ tool_name: widget.id.split(":")[0] || "",
500
+ kind: widget.kind,
501
+ title: String(widget.data?.title ?? ""),
502
+ answered: false,
503
+ },
504
+ });
505
+ }
506
+ setClosed(widget);
424
507
  client.clearPendingWidget();
425
508
  setWidget(null);
426
509
  setAnswered(null);
427
- }, [client]);
428
- return { widget, answered, submit, dismiss };
510
+ setLoading(null);
511
+ setAwaitingReply(false);
512
+ }, [client, widget]);
513
+ const reopen = useCallback(() => {
514
+ if (!closed)
515
+ return;
516
+ setWidget(closed);
517
+ setClosed(null);
518
+ setAnswered(null);
519
+ }, [closed]);
520
+ return { widget, answered, loading, awaitingReply, closed, reopen, submit, dismiss };
429
521
  }
430
522
  export function useGraineAction(name, handler) {
431
523
  const client = useGraineClient();
@@ -0,0 +1,14 @@
1
+ import React from "react";
2
+ import { type LoaderKind, type LoaderLook, type LoaderMoment } from "../protocol.js";
3
+ export interface GraineLoaderProps {
4
+ moment?: LoaderMoment;
5
+ label?: string;
6
+ kind?: LoaderKind;
7
+ color?: string;
8
+ size?: number;
9
+ stacked?: boolean;
10
+ }
11
+ export declare function useGraineLoader(): LoaderLook & {
12
+ accent: string;
13
+ };
14
+ export declare function GraineLoader(props: GraineLoaderProps): React.JSX.Element;
@@ -0,0 +1,73 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useContext, useEffect, useRef } from "react";
3
+ import { Animated, Easing, Text, View, useColorScheme } from "react-native";
4
+ import { Ctx } from "./index.js";
5
+ import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
6
+ import { resolveLoader } from "../protocol.js";
7
+ export function useGraineLoader() {
8
+ const appearance = useContext(Ctx)?.appearance ?? null;
9
+ const look = resolveLoader(appearance?.loader);
10
+ const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
11
+ return { ...look, accent: look.color ?? dash.accent };
12
+ }
13
+ function useLoop(periodMs) {
14
+ const v = useRef(new Animated.Value(0)).current;
15
+ useEffect(() => {
16
+ const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: periodMs, easing: Easing.linear, useNativeDriver: true }));
17
+ loop.start();
18
+ return () => loop.stop();
19
+ }, [v, periodMs]);
20
+ return v;
21
+ }
22
+ function Dots({ px, color, t }) {
23
+ const d = Math.max(4, Math.round(px * 0.32));
24
+ return (_jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: Math.round(d * 0.7), height: px }, children: [0, 1, 2].map((i) => (_jsx(Animated.View, { style: {
25
+ width: d, height: d, borderRadius: 999, backgroundColor: color,
26
+ opacity: t.interpolate({ inputRange: [0, 0.33, 0.66, 1], outputRange: [i === 0 ? 1 : 0.3, i === 1 ? 1 : 0.3, i === 2 ? 1 : 0.3, i === 0 ? 1 : 0.3] }),
27
+ } }, i))) }));
28
+ }
29
+ function Pulse({ px, color, t }) {
30
+ return (_jsxs(View, { style: { width: px, height: px, alignItems: "center", justifyContent: "center" }, children: [_jsx(Animated.View, { style: {
31
+ position: "absolute", width: px, height: px, borderRadius: 999, backgroundColor: color,
32
+ opacity: t.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] }),
33
+ transform: [{ scale: t.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1.4] }) }],
34
+ } }), _jsx(View, { style: { width: Math.round(px * 0.5), height: Math.round(px * 0.5), borderRadius: 999, backgroundColor: color } })] }));
35
+ }
36
+ function Ring({ px, color, t, track }) {
37
+ const w = Math.max(2, Math.round(px * 0.14));
38
+ return (_jsx(Animated.View, { style: {
39
+ width: px, height: px, borderRadius: 999, borderWidth: w,
40
+ borderColor: track, borderTopColor: color,
41
+ transform: [{ rotate: t.interpolate({ inputRange: [0, 1], outputRange: ["0deg", "360deg"] }) }],
42
+ } }));
43
+ }
44
+ function Bars({ px, color, t }) {
45
+ const w = Math.max(3, Math.round(px * 0.18));
46
+ return (_jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: Math.round(w * 0.8), height: px }, children: [0, 1, 2].map((i) => (_jsx(Animated.View, { style: {
47
+ width: w, borderRadius: 999, backgroundColor: color, height: px,
48
+ transform: [{ scaleY: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [i === 1 ? 0.4 : 1, i === 1 ? 1 : 0.4, i === 1 ? 0.4 : 1] }) }],
49
+ } }, i))) }));
50
+ }
51
+ function Mark({ px, color, t, logoUrl }) {
52
+ if (!logoUrl)
53
+ return _jsx(Pulse, { px: px, color: color, t: t });
54
+ return (_jsx(Animated.Image, { source: { uri: logoUrl }, accessibilityIgnoresInvertColors: true, resizeMode: "contain", style: { width: px, height: px, opacity: t.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 0.45, 1] }) } }));
55
+ }
56
+ export function GraineLoader(props) {
57
+ const appearance = useContext(Ctx)?.appearance ?? null;
58
+ const scheme = useColorScheme();
59
+ const look = resolveLoader(appearance?.loader);
60
+ const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
61
+ const color = props.color ?? look.color ?? dash.accent;
62
+ const px = props.size ?? look.px;
63
+ const kind = props.kind ?? look.kind;
64
+ const palette = resolveWidgetPalette(appearance, { scheme, accent: color });
65
+ const t = useLoop(look.periodMs);
66
+ const label = props.label ?? (props.moment ? look.text[props.moment] : "");
67
+ const mark = kind === "pulse" ? _jsx(Pulse, { px: px, color: color, t: t }) :
68
+ kind === "ring" ? _jsx(Ring, { px: px, color: color, t: t, track: palette.border }) :
69
+ kind === "bars" ? _jsx(Bars, { px: px, color: color, t: t }) :
70
+ kind === "mark" ? _jsx(Mark, { px: px, color: color, t: t, logoUrl: dash.logoUrl ?? "" }) :
71
+ _jsx(Dots, { px: px, color: color, t: t });
72
+ return (_jsxs(View, { accessibilityRole: "progressbar", accessibilityLabel: label || "Loading", style: { flexDirection: props.stacked ? "column" : "row", alignItems: "center", gap: props.stacked ? 8 : 10 }, children: [mark, label ? (_jsx(Text, { style: { color: palette.muted, fontSize: Math.max(12, Math.round(px * 0.65)), fontWeight: "600" }, children: label })) : null] }));
73
+ }
@@ -23,9 +23,23 @@ export interface GraineVoiceLauncherProps {
23
23
  onEnded?: () => void;
24
24
  onMicDenied?: (reason: string) => void;
25
25
  onError?: (message: string) => void;
26
+ timing?: {
27
+ readyTimeoutMs?: number;
28
+ retryDelaysMs?: number[];
29
+ endWatchdogMs?: number;
30
+ };
26
31
  children?: (api: GraineVoiceApi) => React.ReactNode;
27
32
  }
33
+ export interface GraineConnectivity {
34
+ state: "loading" | "ready" | "retrying" | "failed";
35
+ attempt: number;
36
+ reason?: string;
37
+ }
38
+ export type GrainePhase = "idle" | "connecting" | "reconnecting" | "thinking" | "speaking" | "listening";
28
39
  export interface GraineVoiceApi {
40
+ phase: GrainePhase;
41
+ connectivity: GraineConnectivity;
42
+ retry: () => void;
29
43
  connected: boolean;
30
44
  connecting: boolean;
31
45
  muted: boolean;
@@ -40,4 +54,4 @@ export interface GraineVoiceApi {
40
54
  clearCaptions: () => void;
41
55
  }
42
56
  export declare const CAPTION_HISTORY = 12;
43
- export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
57
+ export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -4,7 +4,7 @@ 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, onEnded, children, }) {
7
+ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
8
8
  const { client, appearance } = useGraineAgent();
9
9
  const ref = useRef(null);
10
10
  const startedRef = useRef(false);
@@ -17,6 +17,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
17
17
  const [muted, setMutedState] = useState(false);
18
18
  const [ready, setReady] = useState(false);
19
19
  const [captions, setCaptions] = useState([]);
20
+ const [awaitingAgent, setAwaitingAgent] = useState(false);
20
21
  const [captionsOn, setCaptionsOn] = useState(true);
21
22
  const foldCaption = useCallback((next) => {
22
23
  setCaptions((prev) => {
@@ -38,6 +39,88 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
38
39
  const [voiceAgentId, setVoiceAgentId] = useState(client.config ? client.config.webcallAgentId || client.config.agentId : null);
39
40
  const onErrorRef = useRef(onError);
40
41
  onErrorRef.current = onError;
42
+ const READY_TIMEOUT_MS = timing?.readyTimeoutMs ?? 15000;
43
+ const RETRY_DELAYS_MS = timing?.retryDelaysMs ?? [1000, 2000, 4000, 8000, 15000, 15000];
44
+ const END_WATCHDOG_MS = timing?.endWatchdogMs ?? 4000;
45
+ const [connectivity, setConnectivity] = useState({ state: "loading", attempt: 0 });
46
+ const attemptRef = useRef(0);
47
+ const retryTimerRef = useRef(null);
48
+ const readyTimerRef = useRef(null);
49
+ const endWatchdogRef = useRef(null);
50
+ const connectedRef = useRef(false);
51
+ useEffect(() => { connectedRef.current = connected; }, [connected]);
52
+ const endedNotifiedRef = useRef(true);
53
+ const onEndedRef = useRef(onEnded);
54
+ onEndedRef.current = onEnded;
55
+ const clearTimer = (r) => {
56
+ if (r.current) {
57
+ clearTimeout(r.current);
58
+ r.current = null;
59
+ }
60
+ };
61
+ const scheduleRetry = useCallback((reason) => {
62
+ clearTimer(readyTimerRef);
63
+ if (connectedRef.current)
64
+ return;
65
+ const attempt = attemptRef.current;
66
+ if (attempt >= RETRY_DELAYS_MS.length) {
67
+ setConnectivity({ state: "failed", attempt, reason });
68
+ setConnecting(false);
69
+ pendingStartRef.current = false;
70
+ onErrorRef.current?.("The assistant could not load. Check your connection and try again.");
71
+ return;
72
+ }
73
+ const base = RETRY_DELAYS_MS[attempt];
74
+ const delay = Math.round(base * (0.75 + Math.random() * 0.5));
75
+ attemptRef.current = attempt + 1;
76
+ setConnectivity({ state: "retrying", attempt: attempt + 1, reason });
77
+ setReady(false);
78
+ clearTimer(retryTimerRef);
79
+ retryTimerRef.current = setTimeout(() => {
80
+ retryTimerRef.current = null;
81
+ try {
82
+ ref.current?.reload();
83
+ }
84
+ catch { }
85
+ }, delay);
86
+ }, []);
87
+ const armReadyTimer = useCallback(() => {
88
+ clearTimer(readyTimerRef);
89
+ readyTimerRef.current = setTimeout(() => {
90
+ readyTimerRef.current = null;
91
+ scheduleRetry("ready-timeout");
92
+ }, READY_TIMEOUT_MS);
93
+ }, [scheduleRetry]);
94
+ const restartLoad = useCallback(() => {
95
+ clearTimer(retryTimerRef);
96
+ attemptRef.current = 0;
97
+ setConnectivity({ state: "loading", attempt: 0 });
98
+ setReady(false);
99
+ try {
100
+ ref.current?.reload();
101
+ }
102
+ catch { }
103
+ armReadyTimer();
104
+ }, [armReadyTimer]);
105
+ const forceEnd = useCallback((why) => {
106
+ clearTimer(endWatchdogRef);
107
+ setConnected(false);
108
+ setConnecting(false);
109
+ connectedRef.current = false;
110
+ if (!endedNotifiedRef.current) {
111
+ endedNotifiedRef.current = true;
112
+ console.warn(`[graine] the page did not confirm the call ended (${why}); ending it locally`);
113
+ onEndedRef.current?.();
114
+ }
115
+ restartLoad();
116
+ }, [restartLoad]);
117
+ const armEndWatchdog = useCallback(() => {
118
+ clearTimer(endWatchdogRef);
119
+ if (!connectedRef.current)
120
+ return;
121
+ endWatchdogRef.current = setTimeout(() => forceEnd("watchdog"), END_WATCHDOG_MS);
122
+ }, [forceEnd]);
123
+ useEffect(() => () => { clearTimer(retryTimerRef); clearTimer(readyTimerRef); clearTimer(endWatchdogRef); }, []);
41
124
  useEffect(() => {
42
125
  if (voiceAgentId)
43
126
  return;
@@ -75,6 +158,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
75
158
  return client.on("screen", () => pushContext());
76
159
  }, [client, ready, pushContext]);
77
160
  const beginCall = useCallback(async () => {
161
+ endedNotifiedRef.current = false;
78
162
  if (client.isConnected) {
79
163
  console.warn("[graine] The provider has its own connection open while GraineVoiceLauncher " +
80
164
  "is starting a call. Pass autoConnect={false} to <GraineProvider> — otherwise " +
@@ -132,6 +216,10 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
132
216
  }
133
217
  return;
134
218
  case "graine:ready":
219
+ clearTimer(readyTimerRef);
220
+ clearTimer(retryTimerRef);
221
+ attemptRef.current = 0;
222
+ setConnectivity({ state: "ready", attempt: 0 });
135
223
  setReady(true);
136
224
  pushContext();
137
225
  if ((autoStart || pendingStartRef.current) && !startedRef.current && activeRef.current) {
@@ -144,6 +232,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
144
232
  }
145
233
  return;
146
234
  case "graine:call":
235
+ if (msg.connected)
236
+ endedNotifiedRef.current = false;
147
237
  setConnected(!!msg.connected);
148
238
  setConnecting(!!msg.connecting);
149
239
  if (!msg.connected && !msg.connecting)
@@ -159,14 +249,30 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
159
249
  text: String(msg.text ?? ""),
160
250
  live: !!msg.live,
161
251
  };
252
+ if (line.role === "customer" && !line.live)
253
+ setAwaitingAgent(true);
254
+ if (line.role === "agent")
255
+ setAwaitingAgent(false);
162
256
  foldCaption(line);
163
257
  onCaption?.(line);
258
+ client.emit?.("caption", line);
259
+ if (line.role === "agent")
260
+ client.emit?.("agent_speaking", true);
261
+ return;
262
+ }
263
+ case "graine:widget-pending": {
264
+ client.emit?.("widget_pending", msg.widget || {});
164
265
  return;
165
266
  }
166
267
  case "graine:ended":
268
+ clearTimer(endWatchdogRef);
167
269
  setConnected(false);
168
270
  setConnecting(false);
169
- onEnded?.();
271
+ connectedRef.current = false;
272
+ if (!endedNotifiedRef.current) {
273
+ endedNotifiedRef.current = true;
274
+ onEnded?.();
275
+ }
170
276
  return;
171
277
  case "graine:mic-denied":
172
278
  client.emit("mic_denied", { reason: String(msg.reason || "NotAllowedError") });
@@ -191,7 +297,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
191
297
  }
192
298
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
193
299
  const pendingStartRef = useRef(false);
300
+ const lastCaption = captions.length ? captions[captions.length - 1] : null;
301
+ const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
302
+ : connecting || (!ready && !!voiceAgentId) ? "connecting"
303
+ : !connected ? "idle"
304
+ : lastCaption?.role === "agent" && lastCaption.live ? "speaking"
305
+ : awaitingAgent ? "thinking"
306
+ : "listening";
194
307
  const api = {
308
+ phase,
195
309
  connected,
196
310
  connecting,
197
311
  muted,
@@ -204,6 +318,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
204
318
  start: useCallback(() => {
205
319
  setConnecting(true);
206
320
  setCaptions([]);
321
+ setAwaitingAgent(false);
207
322
  client.clearPendingWidget();
208
323
  if (!ready) {
209
324
  pendingStartRef.current = true;
@@ -211,7 +326,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
211
326
  }
212
327
  void beginCall();
213
328
  }, [beginCall, ready]),
214
- end: useCallback(() => post({ type: "graine:end-call" }), [post]),
329
+ end: useCallback(() => { post({ type: "graine:end-call" }); armEndWatchdog(); }, [post, armEndWatchdog]),
330
+ connectivity,
331
+ retry: restartLoad,
215
332
  setMuted: useCallback((m) => {
216
333
  setMutedState(m);
217
334
  post({ type: "graine:set-muted", muted: m });
@@ -223,6 +340,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
223
340
  const first = activeSeenRef.current === null;
224
341
  activeSeenRef.current = active;
225
342
  if (active) {
343
+ if (attemptRef.current >= RETRY_DELAYS_MS.length) {
344
+ restartLoad();
345
+ }
226
346
  if (autoStart) {
227
347
  if (startedRef.current)
228
348
  return;
@@ -243,6 +363,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
243
363
  if (first)
244
364
  return;
245
365
  post({ type: "graine:end-call" });
366
+ armEndWatchdog();
246
367
  startedRef.current = false;
247
368
  pendingStartRef.current = false;
248
369
  setConnected(false);
@@ -286,5 +407,5 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
286
407
  (mountedIdleRef.current ? "&idle=1" : "") +
287
408
  `&pk=${encodeURIComponent(client.opts.publishableKey)}` +
288
409
  (appearance?.accent ? `&accent=${encodeURIComponent(String(appearance.accent).replace("#", ""))}` : "");
289
- return (_jsxs(_Fragment, { children: [_jsx(View, { style: { position: "absolute", width: 1, height: 1, opacity: 0, bottom: 0, left: 0 }, pointerEvents: "none", children: _jsx(WebView, { ref: ref, source: { uri: src }, originWhitelist: ["*"], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: false, mediaCapturePermissionGrantType: "grant", onMessage: onMessage, style: { width: 1, height: 1, backgroundColor: "transparent" } }) }), children?.(api)] }));
410
+ 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)] }));
290
411
  }
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { useContext } from "react";
3
- import { Image, Modal, Pressable, ScrollView, Text, View, useColorScheme } from "react-native";
2
+ import { useContext, useState } from "react";
3
+ import { Image, Modal, Platform, Pressable, ScrollView, Text, View, useColorScheme } from "react-native";
4
4
  import { Ctx, useGraineWidget } from "./index.js";
5
+ import { GraineLoader } from "./loader.js";
5
6
  import { resolveNativeAppearance, resolveWidgetPalette } from "./appearance.js";
6
- import { widgetSurface } from "../protocol.js";
7
+ import { PLATFORM_GRAMMAR, manySummary, resolveLoader, resolvePlatform, resolveWidgetLook, widgetSurface } from "../protocol.js";
7
8
  function tint(hex, alpha) {
8
9
  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
9
10
  if (!m)
@@ -11,38 +12,85 @@ function tint(hex, alpha) {
11
12
  const n = parseInt(m[1], 16);
12
13
  return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
13
14
  }
14
- function WidgetRow({ row, hero, theme, accent, radius, onPick, }) {
15
+ const HERO_HEIGHT = { small: 100, medium: 140, large: 180 };
16
+ const THUMB = { small: 56, medium: 72, large: 96 };
17
+ const LIST_THUMB = { small: 36, medium: 44, large: 56 };
18
+ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, picked, onPick, }) {
19
+ const many = picked !== null;
15
20
  const r = Math.max(10, radius - 6);
16
- return (_jsxs(View, { style: {
17
- borderRadius: r, borderWidth: 1, borderColor: theme.border,
18
- padding: 10, opacity: row.unavailable ? 0.55 : 1,
19
- flexDirection: hero ? "column" : "row", gap: 10, alignItems: hero ? "stretch" : "center",
20
- }, children: [row.image ? (_jsx(Image, { source: { uri: row.image }, accessibilityIgnoresInvertColors: true, style: hero
21
- ? { width: "100%", height: 140, borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border }
22
- : { width: 72, height: 72, borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border }, resizeMode: "cover" })) : null, _jsxs(View, { style: { flex: hero ? undefined : 1, minWidth: 0, gap: 3 }, children: [row.subtitle ? (_jsx(View, { style: { flexDirection: "row" }, children: _jsx(Text, { style: {
23
- color: accent, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4,
24
- textTransform: "uppercase", backgroundColor: tint(accent, 0.15),
25
- borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, overflow: "hidden",
26
- }, children: row.subtitle }) })) : null, _jsxs(View, { style: { flexDirection: "row", alignItems: "baseline", gap: 8 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: theme.text, fontSize: hero ? 16.5 : 14.5, fontWeight: "700", flex: 1 }, children: row.title }), row.price ? (_jsx(Text, { style: {
27
- color: theme.text, fontSize: hero ? 17 : 14.5, fontWeight: "800",
28
- fontVariant: ["tabular-nums"],
29
- }, children: row.price })) : null] }), row.facts.length ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 }, children: row.facts.map((f, i) => (_jsx(Text, { style: {
30
- color: theme.muted, fontSize: 11.5, fontWeight: "600",
31
- backgroundColor: tint(theme.border, 0.35),
32
- borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, overflow: "hidden",
33
- }, children: f }, `${f}-${i}`))) })) : null, hero ? (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: `${row.selectLabel}: ${row.title}`, style: {
34
- marginTop: 8, height: 44, borderRadius: 11, alignItems: "center", justifyContent: "center",
35
- backgroundColor: row.unavailable ? "transparent" : accent,
36
- borderWidth: row.unavailable ? 1 : 0, borderColor: theme.border,
37
- }, children: _jsx(Text, { style: { color: row.unavailable ? theme.muted : theme.onAccent, fontSize: 14, fontWeight: "700" }, children: row.unavailable ? "Unavailable" : row.selectLabel }) })) : null] }), hero ? null : (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: `${row.selectLabel}: ${row.title}`, hitSlop: 8, style: {
38
- minHeight: 44, justifyContent: "center",
39
- backgroundColor: row.unavailable ? "transparent" : accent,
40
- borderWidth: row.unavailable ? 1 : 0, borderColor: theme.border,
41
- borderRadius: 999, paddingHorizontal: 14,
42
- }, children: _jsx(Text, { style: { color: row.unavailable ? theme.muted : theme.onAccent, fontSize: 12.5, fontWeight: "700" }, children: row.unavailable ? "Unavailable" : row.selectLabel }) }))] }));
21
+ const tight = look.density === "compact";
22
+ const pad = (variant === "list" ? (tight ? 6 : 9) : (tight ? 7 : 10)) + grammar.padBoost;
23
+ const gap = tight ? 6 : 10;
24
+ const rowIsButton = many || look.button === "row";
25
+ const outline = look.button === "outline";
26
+ const label = row.unavailable ? "Unavailable" : row.selectLabel;
27
+ const a11y = `${row.selectLabel}: ${row.title}`;
28
+ const badge = row.subtitle ? (_jsx(View, { style: { flexDirection: "row" }, children: _jsx(Text, { style: {
29
+ color: accent, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4,
30
+ textTransform: "uppercase", backgroundColor: tint(accent, 0.15),
31
+ borderRadius: 999, paddingHorizontal: 8, paddingVertical: 3, overflow: "hidden",
32
+ }, children: row.subtitle }) })) : null;
33
+ const titleLine = (big) => (_jsxs(View, { style: { flexDirection: "row", alignItems: "baseline", gap: 8 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: theme.text, fontSize: big ? 16.5 : (variant === "list" ? 13.5 : 14.5), fontWeight: grammar.titleWeight, flex: 1 }, children: row.title }), row.price ? (_jsx(Text, { style: {
34
+ color: theme.text, fontSize: big ? 17 : (variant === "list" ? 13.5 : 14.5), fontWeight: "800",
35
+ fontVariant: ["tabular-nums"],
36
+ }, children: row.price })) : null] }));
37
+ const facts = look.facts && row.facts.length ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 2 }, children: (variant === "list" ? row.facts.slice(0, 2) : row.facts).map((f, i) => (_jsx(Text, { style: {
38
+ color: theme.muted, fontSize: variant === "list" ? 10.5 : 11.5, fontWeight: "600",
39
+ backgroundColor: grammar.chipOutlined ? "transparent" : tint(theme.border, 0.35),
40
+ borderWidth: grammar.chipOutlined ? 1 : 0, borderColor: theme.border,
41
+ borderRadius: grammar.chipRadius, paddingHorizontal: variant === "list" ? 6 : 8, paddingVertical: variant === "list" ? 2 : 4, overflow: "hidden",
42
+ }, children: f }, `${f}-${i}`))) })) : null;
43
+ const tick = (_jsx(View, { style: {
44
+ width: 22, height: 22, borderRadius: 999, alignItems: "center", justifyContent: "center",
45
+ backgroundColor: picked ? accent : "transparent",
46
+ borderWidth: picked ? 0 : 1.5, borderColor: theme.border,
47
+ }, children: picked ? _jsx(Text, { style: { color: theme.onAccent, fontSize: 12, fontWeight: "800" }, children: "\u2713" }) : null }));
48
+ const button = (wide) => (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, hitSlop: wide ? undefined : 8, style: {
49
+ minHeight: 44, justifyContent: "center", alignItems: "center",
50
+ borderRadius: wide ? grammar.buttonRadius : (grammar.pillButtons ? 999 : grammar.buttonRadius), paddingHorizontal: wide ? 12 : 14,
51
+ marginTop: wide ? 8 : 0,
52
+ backgroundColor: row.unavailable || outline ? "transparent" : accent,
53
+ borderWidth: row.unavailable || outline ? 1.5 : 0,
54
+ borderColor: row.unavailable ? theme.border : accent,
55
+ }, children: _jsx(Text, { style: {
56
+ color: row.unavailable ? theme.muted : (outline ? accent : theme.onAccent),
57
+ fontSize: wide ? 14 : 12.5, fontWeight: "700",
58
+ }, children: label }) }));
59
+ const image = (style) => row.image ? (_jsx(Image, { source: { uri: row.image }, accessibilityIgnoresInvertColors: true, resizeMode: "cover", style: { borderRadius: Math.max(8, radius - 8), backgroundColor: theme.border, ...style } })) : null;
60
+ const wrap = (children, style) => rowIsButton ? (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, style: style, children: children })) : (_jsx(View, { style: style, children: children }));
61
+ if (variant === "hero") {
62
+ return wrap(_jsxs(_Fragment, { children: [image({ width: "100%", height: HERO_HEIGHT[look.imageSize] }), _jsxs(View, { style: { gap: 3 }, children: [badge, titleLine(true), facts, many ? null : rowIsButton ? null : button(true)] }), many ? _jsx(View, { style: { position: "absolute", top: pad + 6, right: pad + 6 }, children: tick }) : null] }), {
63
+ borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border, padding: pad,
64
+ opacity: row.unavailable ? 0.55 : 1, gap, flexDirection: "column",
65
+ });
66
+ }
67
+ if (variant === "list") {
68
+ const t = LIST_THUMB[look.imageSize];
69
+ return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [titleLine(false), facts] }), many ? tick : rowIsButton ? (_jsx(Text, { style: { color: theme.muted, fontSize: 22, fontWeight: "600", paddingLeft: 4 }, children: "›" })) : button(false)] }), {
70
+ flexDirection: "row", alignItems: "center", gap,
71
+ paddingVertical: pad, paddingHorizontal: 2, minHeight: 44,
72
+ borderBottomWidth: 1, borderBottomColor: theme.border, marginLeft: grammar.separatorInset,
73
+ opacity: row.unavailable ? 0.55 : 1,
74
+ });
75
+ }
76
+ if (variant === "compact") {
77
+ return wrap(_jsxs(_Fragment, { children: [_jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [badge, titleLine(false), facts] }), many ? tick : rowIsButton ? null : button(false)] }), {
78
+ borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border,
79
+ paddingVertical: pad, paddingHorizontal: pad + 2,
80
+ flexDirection: "row", alignItems: "center", gap,
81
+ opacity: row.unavailable ? 0.55 : 1, minHeight: 44,
82
+ });
83
+ }
84
+ const t = THUMB[look.imageSize];
85
+ return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 3 }, children: [badge, titleLine(false), facts] }), many ? tick : rowIsButton ? null : button(false)] }), {
86
+ borderRadius: r, borderWidth: picked ? 2 : 1, borderColor: picked ? accent : theme.border, padding: pad,
87
+ opacity: row.unavailable ? 0.55 : 1, flexDirection: "row", gap, alignItems: "center",
88
+ });
43
89
  }
44
- function AnsweredLine({ answered, theme, accent, radius, onClear }) {
90
+ function AnsweredLine({ answered, theme, accent, radius, awaiting, onClear }) {
45
91
  const d = (answered.data || {});
92
+ const appearance = useContext(Ctx)?.appearance ?? null;
93
+ const loaderText = resolveLoader(appearance?.loader).text.thinking;
46
94
  const title = String(d.name || d.title || d.label || d.slot || "").trim();
47
95
  const detail = [String(d.price || ""), String(d.day || "")].filter(Boolean).join(" · ");
48
96
  if (!title && !detail)
@@ -54,20 +102,38 @@ function AnsweredLine({ answered, theme, accent, radius, onClear }) {
54
102
  }, children: [_jsx(View, { style: {
55
103
  width: 26, height: 26, borderRadius: 999, backgroundColor: tint(accent, 0.18),
56
104
  alignItems: "center", justifyContent: "center",
57
- }, children: _jsx(Text, { style: { color: accent, fontSize: 14, fontWeight: "800" }, children: "✓" }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: title || detail }), title && detail ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: detail })) : null] }), _jsx(Pressable, { onPress: onClear, accessibilityRole: "button", accessibilityLabel: "Clear what I chose", hitSlop: 10, style: { minHeight: 44, justifyContent: "center", paddingHorizontal: 4 }, children: _jsx(Text, { style: { color: theme.muted, fontSize: 12, fontWeight: "700" }, children: "Clear" }) })] }));
105
+ }, children: awaiting ? _jsx(GraineLoader, { size: 14 }) : (_jsx(Text, { style: { color: accent, fontSize: 14, fontWeight: "800" }, children: "✓" })) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: title || detail }), awaiting ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: loaderText })) : title && detail ? (_jsx(Text, { numberOfLines: 1, style: { color: theme.muted, fontSize: 11.5, fontWeight: "600" }, children: detail })) : null] }), awaiting ? null : (_jsx(Pressable, { onPress: onClear, accessibilityRole: "button", accessibilityLabel: "Clear what I chose", hitSlop: 10, style: { minHeight: 44, justifyContent: "center", paddingHorizontal: 4 }, children: _jsx(Text, { style: { color: theme.muted, fontSize: 12, fontWeight: "700" }, children: "Clear" }) }))] }));
58
106
  }
59
107
  export function GraineWidget(props) {
60
- const { widget, answered, submit, dismiss } = useGraineWidget();
108
+ const { widget, answered, loading, awaitingReply, submit, dismiss } = useGraineWidget();
61
109
  const appearance = useContext(Ctx)?.appearance ?? null;
62
110
  const scheme = useColorScheme();
111
+ const [pagerWidth, setPagerWidth] = useState(0);
112
+ const [page, setPage] = useState(0);
113
+ const [ticked, setTicked] = useState([]);
114
+ const look = resolveWidgetLook(widget?.style, widget?.kind);
115
+ const grammar = PLATFORM_GRAMMAR[resolvePlatform(look.platform, Platform.OS, Platform.constants?.Manufacturer ?? Platform.constants?.Brand)];
63
116
  const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
64
- const accent = props.accent ?? dash.accent;
65
- const radius = props.radius ?? dash.radius;
117
+ const accent = props.accent ?? look.accent ?? dash.accent;
118
+ const radius = props.radius ?? look.radius ?? (look.platform !== "auto" ? grammar.radius : dash.radius);
66
119
  const theme = {
67
120
  ...resolveWidgetPalette(appearance, { scheme, accent }),
68
121
  ...(props.theme ?? {}),
69
122
  };
70
- const chosen = answered ? (_jsx(AnsweredLine, { answered: answered, theme: theme, accent: accent, radius: radius, onClear: dismiss })) : null;
123
+ const chosen = answered ? (_jsx(AnsweredLine, { answered: answered, theme: theme, accent: accent, radius: radius, awaiting: awaitingReply, onClear: dismiss })) : null;
124
+ if (!widget && loading) {
125
+ const loader = resolveLoader(appearance?.loader);
126
+ const pendingSurface = loader.skeleton ? widgetSurface(loading.kind) : "card";
127
+ const bone = (w, h, extra = {}) => (_jsx(View, { style: { width: w, height: h, borderRadius: 8, backgroundColor: tint(theme.border, 0.6), ...extra } }));
128
+ const bones = (_jsxs(View, { style: { gap: 10 }, accessibilityLabel: loader.text.component, children: [[0, 1, 2].map((i) => (_jsxs(View, { style: { borderRadius: Math.max(10, radius - 6), borderWidth: 1, borderColor: theme.border, padding: 10, flexDirection: "row", gap: 10, alignItems: "center" }, children: [bone(72, 72), _jsxs(View, { style: { flex: 1, gap: 8 }, children: [bone("70%", 12), bone("40%", 12), bone("55%", 10)] })] }, i))), _jsx(View, { style: { alignItems: "center", paddingTop: 6 }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
129
+ if (pendingSurface === "screen") {
130
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsx(View, { style: { flex: 1, backgroundColor: theme.surface, paddingTop: 64, paddingHorizontal: 18 }, children: bones }) })] }));
131
+ }
132
+ if (pendingSurface === "sheet") {
133
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, onRequestClose: dismiss, children: _jsx(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: _jsx(View, { style: { backgroundColor: theme.surface, borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius), padding: 18, paddingBottom: 28 }, children: bones }) }) })] }));
134
+ }
135
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(View, { style: { backgroundColor: theme.surface, borderRadius: radius, borderWidth: 1, borderColor: theme.border, paddingHorizontal: 14, paddingVertical: 12, marginBottom: 8 }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
136
+ }
71
137
  if (!widget)
72
138
  return chosen;
73
139
  const data = (widget.data ?? {});
@@ -132,8 +198,19 @@ export function GraineWidget(props) {
132
198
  const isChips = (widget.kind === "choices" || isQuickActions) && options.length > 0;
133
199
  const isCalendar = widget.kind === "calendar_slots" && Array.isArray(data?.days) && data.days.length > 0;
134
200
  const drawable = isProducts || isChips || isCalendar;
135
- const surface = drawable ? widgetSurface(widget.kind) : "card";
201
+ const surface = drawable ? look.surface : "card";
136
202
  const pick = (it) => submit(normalised.toSubmit(it), normalised.toSay(it));
203
+ const many = look.select === "many" && drawable;
204
+ const submitMany = (items) => {
205
+ const titles = items.map((it) => String(it.title ?? it.name ?? "")).filter(Boolean);
206
+ submit({
207
+ items: items.map((it) => normalised.toSubmit(it)),
208
+ ids: items.map((it) => String(it.id ?? "")).join(","),
209
+ titles: titles.join(", "),
210
+ count: items.length,
211
+ }, manySummary(titles, normalised.selectLabel.toLowerCase()));
212
+ setTicked([]);
213
+ };
137
214
  const body = (_jsx(_Fragment, { children: isCalendar ? (_jsxs(View, { style: { gap: 12 }, children: [data.note ? (_jsx(Text, { style: { color: theme.muted, fontSize: 12.5, lineHeight: 18 }, children: String(data.note) })) : null, data.days.slice(0, 4).map((d, di) => {
138
215
  const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
139
216
  const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
@@ -144,7 +221,52 @@ export function GraineWidget(props) {
144
221
  borderWidth: 1, borderColor: theme.border, borderRadius: 11,
145
222
  paddingHorizontal: 14,
146
223
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
147
- })] })) : isProducts ? (_jsx(View, { style: { gap: 10 }, children: rows.map((row, i) => (_jsx(WidgetRow, { row: row, hero: surface === "screen" && i === 0 && !!row.image, theme: theme, accent: accent, radius: radius, onPick: () => pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`))) })) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
224
+ })] })) : isProducts ? ((() => {
225
+ const toggle = (id) => setTicked((cur) => cur.includes(id) ? cur.filter((x) => x !== id)
226
+ : cur.length >= look.maxSelect ? [...cur.slice(1), id]
227
+ : [...cur, id]);
228
+ const draw = (row, i, variant) => (_jsx(WidgetRow, { row: row, variant: variant, look: look, grammar: grammar, theme: theme, accent: accent, radius: radius, picked: many ? ticked.includes(row.id) : null, onPick: () => many
229
+ ? toggle(row.id)
230
+ : pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
231
+ const gap = look.density === "compact" ? 6 : 10;
232
+ if (look.preset === "spotlight") {
233
+ const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
234
+ const step = cardW + gap;
235
+ return (_jsxs(View, { onLayout: (e) => setPagerWidth(e?.nativeEvent?.layout?.width ?? 0), children: [_jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, snapToInterval: step, decelerationRate: "fast", onMomentumScrollEnd: (e) => setPage(Math.round((e?.nativeEvent?.contentOffset?.x ?? 0) / step)), contentContainerStyle: { gap, paddingRight: 24 }, children: rows.map((row, i) => (_jsx(View, { style: { width: cardW }, children: draw(row, i, "hero") }, `${row.id}-${i}`))) }), rows.length > 1 ? (_jsx(View, { style: { flexDirection: "row", justifyContent: "center", gap: 5, paddingTop: 10 }, accessibilityLabel: `${Math.min(page + 1, rows.length)} of ${rows.length}`, children: rows.map((row, i) => (_jsx(View, { style: {
236
+ height: 5, borderRadius: 999, width: i === page ? 14 : 5,
237
+ backgroundColor: i === page ? accent : theme.border,
238
+ } }, `dot-${row.id}-${i}`))) })) : null] }));
239
+ }
240
+ if (look.columns === 2) {
241
+ return (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap, justifyContent: "space-between" }, children: rows.map((row, i) => (_jsx(View, { style: { width: "48%" }, children: draw(row, i, "hero") }, `${row.id}-${i}`))) }));
242
+ }
243
+ const body = look.preset === "gallery" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) })
244
+ : look.preset === "list" ? _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) })
245
+ : look.preset === "compact" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) })
246
+ : _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
247
+ if (!many)
248
+ return body;
249
+ const chosenRows = ticked
250
+ .map((id) => ({ row: rows.find((r) => r.id === id), i: rows.findIndex((r) => r.id === id) }))
251
+ .filter((x) => !!x.row);
252
+ return (_jsxs(_Fragment, { children: [body, _jsx(Pressable, { disabled: chosenRows.length < 2, onPress: () => {
253
+ const items = chosenRows.map(({ row, i }) => ({
254
+ ...normalised.items[i], id: row.id, title: row.title, price: row.price,
255
+ }));
256
+ submitMany(items);
257
+ }, accessibilityRole: "button", accessibilityLabel: chosenRows.length < 2
258
+ ? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
259
+ : `${normalised.selectLabel} ${chosenRows.length}`, style: {
260
+ marginTop: 12, minHeight: 48, borderRadius: grammar.pillButtons ? 999 : grammar.buttonRadius,
261
+ alignItems: "center", justifyContent: "center",
262
+ backgroundColor: chosenRows.length < 2 ? tint(theme.border, 0.5) : accent,
263
+ }, children: _jsx(Text, { style: {
264
+ color: chosenRows.length < 2 ? theme.muted : theme.onAccent,
265
+ fontSize: 14.5, fontWeight: "700",
266
+ }, children: chosenRows.length < 2
267
+ ? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
268
+ : `${normalised.selectLabel} ${chosenRows.length}` }) })] }));
269
+ })()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
148
270
  const label = String(o?.label ?? o?.title ?? o ?? "");
149
271
  const id = String(o?.id ?? label);
150
272
  return (_jsx(Pressable, { onPress: () => submit(isQuickActions ? { id, label } : { chosen: id, label }, `I'll go with ${label}.`), accessibilityRole: "button", accessibilityLabel: label, style: {
@@ -155,7 +277,7 @@ export function GraineWidget(props) {
155
277
  const title = data?.title ? String(data.title) : "";
156
278
  const prompt = data?.prompt ? String(data.prompt) : "";
157
279
  if (surface === "screen") {
158
- return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: dismiss, transparent: false, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface }, children: [_jsxs(View, { style: {
280
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface }, children: [_jsxs(View, { style: {
159
281
  paddingTop: 52, paddingHorizontal: 18, paddingBottom: 12,
160
282
  flexDirection: "row", alignItems: "center", gap: 12,
161
283
  }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close and keep talking", hitSlop: 10, style: {
@@ -167,11 +289,11 @@ export function GraineWidget(props) {
167
289
  }, children: [_jsx(Text, { style: { color: theme.muted, fontSize: 10.5, fontWeight: "800", letterSpacing: 0.4, textTransform: "uppercase" }, children: "Still listening" }), _jsx(Text, { numberOfLines: 1, style: { color: theme.text, fontSize: 13.5, marginTop: 2 }, children: prompt || "Tap one, or just say it." })] })] }) })] }));
168
290
  }
169
291
  if (surface === "sheet") {
170
- return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", transparent: true, onRequestClose: dismiss, children: _jsxs(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close", style: { flex: 1 } }), _jsxs(View, { style: {
292
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "slide", transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, onRequestClose: dismiss, children: _jsxs(View, { style: { flex: 1, backgroundColor: "rgba(0,0,0,0.45)", justifyContent: "flex-end" }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close", style: { flex: 1 } }), _jsxs(View, { style: {
171
293
  backgroundColor: theme.surface,
172
294
  borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius),
173
295
  paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28, gap: 14,
174
- }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: 38, height: 4, borderRadius: 999, backgroundColor: theme.border } }) }), title ? (_jsx(Text, { style: { color: theme.text, fontSize: 18, fontWeight: "800" }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: 13, lineHeight: 18, marginTop: -8 }, children: prompt })) : null, _jsx(ScrollView, { style: { maxHeight: 420 }, contentContainerStyle: { paddingBottom: 4 }, keyboardShouldPersistTaps: "handled", children: body }), _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "None of these", style: {
296
+ }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: grammar.grabber.width, height: grammar.grabber.height, borderRadius: 999, backgroundColor: theme.border } }) }), title ? (_jsx(Text, { style: { color: theme.text, fontSize: 18, fontWeight: "800" }, children: title })) : null, prompt ? (_jsx(Text, { style: { color: theme.muted, fontSize: 13, lineHeight: 18, marginTop: -8 }, children: prompt })) : null, _jsx(ScrollView, { style: { maxHeight: 420 }, contentContainerStyle: { paddingBottom: 4 }, keyboardShouldPersistTaps: "handled", children: body }), _jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "None of these", style: {
175
297
  alignSelf: "flex-start", minHeight: 44, justifyContent: "center",
176
298
  paddingHorizontal: 16, borderRadius: 999, backgroundColor: tint(theme.border, 0.4),
177
299
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "700" }, children: "None of these" }) })] })] }) })] }));
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.33.3",
3
+ "version": "0.33.5",
4
4
  "type": "module",
5
- "description": "Graine in-app agent for React Native — an agent that sees the screen your customer is on and can act on it.",
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.",
6
6
  "license": "MIT",
7
7
  "private": false,
8
8
  "main": "dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "typecheck": "tsc -p tsconfig.build.json --noEmit",
35
35
  "test": "npm run build && node --test test/*.test.mjs && npm run test:render",
36
36
  "prepublishOnly": "npm test && node scripts/verify-package-clean.mjs",
37
- "test:render": "node --import ./test/harness/register.mjs --test test/render/render.test.mjs"
37
+ "test:render": "node --import ./test/harness/register.mjs --test test/render/*.test.mjs"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@jambonz/client-sdk-react-native": "^0.1.4",