@graineai/inapp-react-native 0.33.4 → 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
@@ -2,7 +2,16 @@ export type { ActionHandler, AppActionRequest, AppActionResult, AppEvent, Screen
2
2
  export { widgetSurface } from "./protocol.js";
3
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
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";
5
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";
6
15
  export type { GraineWidgetProps, GraineWidgetTheme } from "./react-native/widget.js";
7
16
  export { GraineInAppClient, type GraineInAppOptions, type SessionConfig, type ActionDiagnosis, } from "./client.js";
8
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,6 +1,11 @@
1
1
  export { widgetSurface } from "./protocol.js";
2
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";
3
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";
4
9
  export { GraineInAppClient, } from "./client.js";
5
10
  export { FrictionDetector, resolveFriction, rulesFor, DEFAULT_FRICTION, } from "./friction.js";
6
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";
@@ -68,6 +70,7 @@ export type WidgetPreset = "gallery" | "cards" | "list" | "spotlight" | "compact
68
70
  export type WidgetImageSize = "small" | "medium" | "large";
69
71
  export type WidgetDensity = "comfortable" | "compact";
70
72
  export type WidgetButton = "filled" | "outline" | "row";
73
+ export type WidgetSelect = "one" | "many";
71
74
  export type WidgetPlatform = "auto" | "ios" | "material" | "oneui";
72
75
  export interface WidgetStyle {
73
76
  preset?: WidgetPreset;
@@ -80,6 +83,8 @@ export interface WidgetStyle {
80
83
  radius?: number;
81
84
  accent?: string;
82
85
  platform?: WidgetPlatform;
86
+ select?: WidgetSelect;
87
+ maxSelect?: number;
83
88
  }
84
89
  export declare const WIDGET_PRESETS: ReadonlyArray<WidgetPreset>;
85
90
  export declare const WIDGET_IMAGE_SIZES: ReadonlyArray<WidgetImageSize>;
@@ -87,6 +92,8 @@ export declare const WIDGET_DENSITIES: ReadonlyArray<WidgetDensity>;
87
92
  export declare const WIDGET_BUTTONS: ReadonlyArray<WidgetButton>;
88
93
  export declare const WIDGET_SURFACE_CHOICES: ReadonlyArray<"auto" | WidgetSurface>;
89
94
  export declare const WIDGET_PLATFORMS: ReadonlyArray<WidgetPlatform>;
95
+ export declare const WIDGET_SELECTS: ReadonlyArray<WidgetSelect>;
96
+ export declare const MAX_SELECT_DEFAULT = 3;
90
97
  export declare const DEFAULT_WIDGET_PRESET: WidgetPreset;
91
98
  export declare function normaliseWidgetStyle(raw: unknown): WidgetStyle;
92
99
  export interface WidgetLook {
@@ -100,8 +107,11 @@ export interface WidgetLook {
100
107
  radius?: number;
101
108
  accent?: string;
102
109
  platform: WidgetPlatform;
110
+ select: WidgetSelect;
111
+ maxSelect: number;
103
112
  }
104
113
  export declare function resolveWidgetLook(style: WidgetStyle | null | undefined, kind: string | null | undefined): WidgetLook;
114
+ export declare function manySummary(titles: string[], verb?: string): string;
105
115
  export interface PlatformGrammar {
106
116
  radius: number;
107
117
  chipRadius: number;
@@ -118,3 +128,54 @@ export interface PlatformGrammar {
118
128
  }
119
129
  export declare const PLATFORM_GRAMMAR: Record<Exclude<WidgetPlatform, "auto">, PlatformGrammar>;
120
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
@@ -47,6 +47,8 @@ export const WIDGET_DENSITIES = ["comfortable", "compact"];
47
47
  export const WIDGET_BUTTONS = ["filled", "outline", "row"];
48
48
  export const WIDGET_SURFACE_CHOICES = ["auto", "screen", "sheet", "card"];
49
49
  export const WIDGET_PLATFORMS = ["auto", "ios", "material", "oneui"];
50
+ export const WIDGET_SELECTS = ["one", "many"];
51
+ export const MAX_SELECT_DEFAULT = 3;
50
52
  export const DEFAULT_WIDGET_PRESET = "cards";
51
53
  export function normaliseWidgetStyle(raw) {
52
54
  if (!raw || typeof raw !== "object" || Array.isArray(raw))
@@ -85,6 +87,12 @@ export function normaliseWidgetStyle(raw) {
85
87
  const platform = pick(r.platform, WIDGET_PLATFORMS);
86
88
  if (platform)
87
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
+ }
88
96
  return out;
89
97
  }
90
98
  const PRESET_DEFAULTS = {
@@ -110,8 +118,18 @@ export function resolveWidgetLook(style, kind) {
110
118
  ...(s.radius !== undefined ? { radius: s.radius } : {}),
111
119
  ...(s.accent ? { accent: s.accent } : {}),
112
120
  platform: s.platform ?? "auto",
121
+ select: s.select ?? "one",
122
+ maxSelect: s.maxSelect ?? MAX_SELECT_DEFAULT,
113
123
  };
114
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
+ }
115
133
  export const PLATFORM_GRAMMAR = {
116
134
  ios: { radius: 12, chipRadius: 8, buttonRadius: 12, pillButtons: false, separatorInset: 16, grabber: { width: 36, height: 5 }, titleWeight: "600", padBoost: 0, chipOutlined: false },
117
135
  material: { radius: 16, chipRadius: 8, buttonRadius: 20, pillButtons: true, separatorInset: 0, grabber: { width: 32, height: 4 }, titleWeight: "500", padBoost: 0, chipOutlined: true },
@@ -124,3 +142,122 @@ export function resolvePlatform(platform, os, manufacturer) {
124
142
  return /samsung/i.test(String(manufacturer ?? "")) ? "oneui" : "material";
125
143
  return "ios";
126
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,7 +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 } from "../protocol.js";
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
8
  export { widgetSurface } from "../protocol.js";
@@ -69,12 +69,15 @@ export type GraineEvent = {
69
69
  screen: string;
70
70
  detail: string;
71
71
  };
72
+ export declare const PENDING_WIDGET_MS = 12000;
73
+ export declare const AWAITING_REPLY_MS = 12000;
72
74
  export interface GraineWidget {
73
75
  id: string;
74
76
  kind: string;
75
77
  data: Record<string, unknown>;
76
78
  fallbackText: string;
77
79
  style?: WidgetStyle;
80
+ onSelect?: WidgetSelectAction;
78
81
  }
79
82
  export interface Caption {
80
83
  role: "agent" | "customer";
@@ -132,6 +135,13 @@ export declare function useGraineWidget(): {
132
135
  data: Record<string, unknown>;
133
136
  summary: string;
134
137
  } | null;
138
+ loading: {
139
+ id: string;
140
+ kind: string;
141
+ } | null;
142
+ awaitingReply: boolean;
143
+ closed: GraineWidget | null;
144
+ reopen: () => void;
135
145
  submit: (data: Record<string, unknown>, summary: string) => void;
136
146
  dismiss: () => void;
137
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, normaliseWidgetStyle } 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) {
@@ -397,36 +399,125 @@ export function useGraineWidget() {
397
399
  data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
398
400
  fallbackText: String(w.fallback_text || ""),
399
401
  style: normaliseWidgetStyle(w.style),
402
+ onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
400
403
  }
401
404
  : null, [client]);
402
405
  const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
403
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]);
404
437
  useEffect(() => {
405
438
  const pending = adopt(client.pendingWidget);
406
439
  if (pending)
407
440
  setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
408
- 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) => {
409
448
  const next = adopt(w);
410
449
  if (!next)
411
450
  return;
451
+ if (loadingTimer.current) {
452
+ clearTimeout(loadingTimer.current);
453
+ loadingTimer.current = null;
454
+ }
455
+ setLoading(null);
456
+ setClosed(null);
412
457
  setWidget(next);
413
458
  setAnswered(null);
414
459
  });
460
+ return () => { offPending(); offWidget(); };
415
461
  }, [client, adopt]);
416
462
  const submit = useCallback((data, summary) => {
417
463
  if (!widget)
418
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
+ }
419
483
  client.submitWidget(widget.id, data, summary);
420
484
  client.clearPendingWidget();
421
485
  setWidget(null);
422
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);
423
491
  }, [client, widget]);
424
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);
425
507
  client.clearPendingWidget();
426
508
  setWidget(null);
427
509
  setAnswered(null);
428
- }, [client]);
429
- 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 };
430
521
  }
431
522
  export function useGraineAction(name, handler) {
432
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
+ }
@@ -35,7 +35,9 @@ export interface GraineConnectivity {
35
35
  attempt: number;
36
36
  reason?: string;
37
37
  }
38
+ export type GrainePhase = "idle" | "connecting" | "reconnecting" | "thinking" | "speaking" | "listening";
38
39
  export interface GraineVoiceApi {
40
+ phase: GrainePhase;
39
41
  connectivity: GraineConnectivity;
40
42
  retry: () => void;
41
43
  connected: boolean;
@@ -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) => {
@@ -248,8 +249,19 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
248
249
  text: String(msg.text ?? ""),
249
250
  live: !!msg.live,
250
251
  };
252
+ if (line.role === "customer" && !line.live)
253
+ setAwaitingAgent(true);
254
+ if (line.role === "agent")
255
+ setAwaitingAgent(false);
251
256
  foldCaption(line);
252
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 || {});
253
265
  return;
254
266
  }
255
267
  case "graine:ended":
@@ -285,7 +297,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
285
297
  }
286
298
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
287
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";
288
307
  const api = {
308
+ phase,
289
309
  connected,
290
310
  connecting,
291
311
  muted,
@@ -298,6 +318,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
298
318
  start: useCallback(() => {
299
319
  setConnecting(true);
300
320
  setCaptions([]);
321
+ setAwaitingAgent(false);
301
322
  client.clearPendingWidget();
302
323
  if (!ready) {
303
324
  pendingStartRef.current = true;
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { useContext, useState } from "react";
3
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 { PLATFORM_GRAMMAR, resolvePlatform, resolveWidgetLook } 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)
@@ -14,12 +15,13 @@ function tint(hex, alpha) {
14
15
  const HERO_HEIGHT = { small: 100, medium: 140, large: 180 };
15
16
  const THUMB = { small: 56, medium: 72, large: 96 };
16
17
  const LIST_THUMB = { small: 36, medium: 44, large: 56 };
17
- function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick, }) {
18
+ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, picked, onPick, }) {
19
+ const many = picked !== null;
18
20
  const r = Math.max(10, radius - 6);
19
21
  const tight = look.density === "compact";
20
22
  const pad = (variant === "list" ? (tight ? 6 : 9) : (tight ? 7 : 10)) + grammar.padBoost;
21
23
  const gap = tight ? 6 : 10;
22
- const rowIsButton = look.button === "row";
24
+ const rowIsButton = many || look.button === "row";
23
25
  const outline = look.button === "outline";
24
26
  const label = row.unavailable ? "Unavailable" : row.selectLabel;
25
27
  const a11y = `${row.selectLabel}: ${row.title}`;
@@ -38,6 +40,11 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
38
40
  borderWidth: grammar.chipOutlined ? 1 : 0, borderColor: theme.border,
39
41
  borderRadius: grammar.chipRadius, paddingHorizontal: variant === "list" ? 6 : 8, paddingVertical: variant === "list" ? 2 : 4, overflow: "hidden",
40
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 }));
41
48
  const button = (wide) => (_jsx(Pressable, { disabled: row.unavailable, onPress: onPick, accessibilityRole: "button", accessibilityLabel: a11y, hitSlop: wide ? undefined : 8, style: {
42
49
  minHeight: 44, justifyContent: "center", alignItems: "center",
43
50
  borderRadius: wide ? grammar.buttonRadius : (grammar.pillButtons ? 999 : grammar.buttonRadius), paddingHorizontal: wide ? 12 : 14,
@@ -52,14 +59,14 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
52
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;
53
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 }));
54
61
  if (variant === "hero") {
55
- return wrap(_jsxs(_Fragment, { children: [image({ width: "100%", height: HERO_HEIGHT[look.imageSize] }), _jsxs(View, { style: { gap: 3 }, children: [badge, titleLine(true), facts, rowIsButton ? null : button(true)] })] }), {
56
- borderRadius: r, borderWidth: 1, borderColor: theme.border, padding: pad,
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,
57
64
  opacity: row.unavailable ? 0.55 : 1, gap, flexDirection: "column",
58
65
  });
59
66
  }
60
67
  if (variant === "list") {
61
68
  const t = LIST_THUMB[look.imageSize];
62
- return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [titleLine(false), facts] }), rowIsButton ? (_jsx(Text, { style: { color: theme.muted, fontSize: 22, fontWeight: "600", paddingLeft: 4 }, children: "›" })) : button(false)] }), {
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)] }), {
63
70
  flexDirection: "row", alignItems: "center", gap,
64
71
  paddingVertical: pad, paddingHorizontal: 2, minHeight: 44,
65
72
  borderBottomWidth: 1, borderBottomColor: theme.border, marginLeft: grammar.separatorInset,
@@ -67,21 +74,23 @@ function WidgetRow({ row, variant, look, grammar, theme, accent, radius, onPick,
67
74
  });
68
75
  }
69
76
  if (variant === "compact") {
70
- return wrap(_jsxs(_Fragment, { children: [_jsxs(View, { style: { flex: 1, minWidth: 0, gap: 2 }, children: [badge, titleLine(false), facts] }), rowIsButton ? null : button(false)] }), {
71
- borderRadius: r, borderWidth: 1, borderColor: theme.border,
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,
72
79
  paddingVertical: pad, paddingHorizontal: pad + 2,
73
80
  flexDirection: "row", alignItems: "center", gap,
74
81
  opacity: row.unavailable ? 0.55 : 1, minHeight: 44,
75
82
  });
76
83
  }
77
84
  const t = THUMB[look.imageSize];
78
- return wrap(_jsxs(_Fragment, { children: [image({ width: t, height: t }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 3 }, children: [badge, titleLine(false), facts] }), rowIsButton ? null : button(false)] }), {
79
- borderRadius: r, borderWidth: 1, borderColor: theme.border, padding: pad,
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,
80
87
  opacity: row.unavailable ? 0.55 : 1, flexDirection: "row", gap, alignItems: "center",
81
88
  });
82
89
  }
83
- function AnsweredLine({ answered, theme, accent, radius, onClear }) {
90
+ function AnsweredLine({ answered, theme, accent, radius, awaiting, onClear }) {
84
91
  const d = (answered.data || {});
92
+ const appearance = useContext(Ctx)?.appearance ?? null;
93
+ const loaderText = resolveLoader(appearance?.loader).text.thinking;
85
94
  const title = String(d.name || d.title || d.label || d.slot || "").trim();
86
95
  const detail = [String(d.price || ""), String(d.day || "")].filter(Boolean).join(" · ");
87
96
  if (!title && !detail)
@@ -93,14 +102,15 @@ function AnsweredLine({ answered, theme, accent, radius, onClear }) {
93
102
  }, children: [_jsx(View, { style: {
94
103
  width: 26, height: 26, borderRadius: 999, backgroundColor: tint(accent, 0.18),
95
104
  alignItems: "center", justifyContent: "center",
96
- }, 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" }) }))] }));
97
106
  }
98
107
  export function GraineWidget(props) {
99
- const { widget, answered, submit, dismiss } = useGraineWidget();
108
+ const { widget, answered, loading, awaitingReply, submit, dismiss } = useGraineWidget();
100
109
  const appearance = useContext(Ctx)?.appearance ?? null;
101
110
  const scheme = useColorScheme();
102
111
  const [pagerWidth, setPagerWidth] = useState(0);
103
112
  const [page, setPage] = useState(0);
113
+ const [ticked, setTicked] = useState([]);
104
114
  const look = resolveWidgetLook(widget?.style, widget?.kind);
105
115
  const grammar = PLATFORM_GRAMMAR[resolvePlatform(look.platform, Platform.OS, Platform.constants?.Manufacturer ?? Platform.constants?.Brand)];
106
116
  const dash = resolveNativeAppearance(appearance, { accent: "#318CE7", heading: "" });
@@ -110,7 +120,20 @@ export function GraineWidget(props) {
110
120
  ...resolveWidgetPalette(appearance, { scheme, accent }),
111
121
  ...(props.theme ?? {}),
112
122
  };
113
- 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
+ }
114
137
  if (!widget)
115
138
  return chosen;
116
139
  const data = (widget.data ?? {});
@@ -177,6 +200,17 @@ export function GraineWidget(props) {
177
200
  const drawable = isProducts || isChips || isCalendar;
178
201
  const surface = drawable ? look.surface : "card";
179
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
+ };
180
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) => {
181
215
  const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
182
216
  const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
@@ -188,7 +222,12 @@ export function GraineWidget(props) {
188
222
  paddingHorizontal: 14,
189
223
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
190
224
  })] })) : isProducts ? ((() => {
191
- const draw = (row, i, variant) => (_jsx(WidgetRow, { row: row, variant: variant, look: look, grammar: grammar, theme: theme, accent: accent, radius: radius, onPick: () => pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
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}`));
192
231
  const gap = look.density === "compact" ? 6 : 10;
193
232
  if (look.preset === "spotlight") {
194
233
  const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
@@ -201,16 +240,32 @@ export function GraineWidget(props) {
201
240
  if (look.columns === 2) {
202
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}`))) }));
203
242
  }
204
- if (look.preset === "gallery") {
205
- return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) });
206
- }
207
- if (look.preset === "list") {
208
- return _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) });
209
- }
210
- if (look.preset === "compact") {
211
- return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) });
212
- }
213
- return _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
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}` }) })] }));
214
269
  })()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
215
270
  const label = String(o?.label ?? o?.title ?? o ?? "");
216
271
  const id = String(o?.id ?? label);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graineai/inapp-react-native",
3
- "version": "0.33.4",
3
+ "version": "0.33.5",
4
4
  "type": "module",
5
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",