@graineai/inapp-react-native 0.33.4 → 0.33.6

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, AWAITING_REPLY_MS, MIN_LOADER_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, AWAITING_REPLY_MS, MIN_LOADER_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 ?? false,
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,16 @@ 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;
74
+ export declare const MIN_LOADER_MS = 450;
72
75
  export interface GraineWidget {
73
76
  id: string;
74
77
  kind: string;
75
78
  data: Record<string, unknown>;
76
79
  fallbackText: string;
77
80
  style?: WidgetStyle;
81
+ onSelect?: WidgetSelectAction;
78
82
  }
79
83
  export interface Caption {
80
84
  role: "agent" | "customer";
@@ -132,6 +136,13 @@ export declare function useGraineWidget(): {
132
136
  data: Record<string, unknown>;
133
137
  summary: string;
134
138
  } | null;
139
+ loading: {
140
+ id: string;
141
+ kind: string;
142
+ } | null;
143
+ awaitingReply: boolean;
144
+ closed: GraineWidget | null;
145
+ reopen: () => void;
135
146
  submit: (data: Record<string, unknown>, summary: string) => void;
136
147
  dismiss: () => void;
137
148
  };
@@ -3,11 +3,14 @@ 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;
13
+ export const MIN_LOADER_MS = 450;
11
14
  export const Ctx = createContext(null);
12
15
  let defaultClient = null;
13
16
  export function setDefaultClient(client) {
@@ -126,6 +129,12 @@ export function GraineProvider({ children, autoConnect = true, onProactive, endO
126
129
  openRef.current = open;
127
130
  useEffect(() => {
128
131
  const offs = [
132
+ client.on("call_state", (s) => {
133
+ if (client.isConnected)
134
+ return;
135
+ setConnected(!!s?.connected);
136
+ setConnecting(!!s?.connecting);
137
+ }),
129
138
  client.on("open", () => {
130
139
  setConnected(true);
131
140
  setConnecting(false);
@@ -397,36 +406,146 @@ export function useGraineWidget() {
397
406
  data: resolveWidgetImages((w.data || {}), client.opts.baseUrl),
398
407
  fallbackText: String(w.fallback_text || ""),
399
408
  style: normaliseWidgetStyle(w.style),
409
+ onSelect: normaliseSelectAction(w.on_select ?? w.onSelect),
400
410
  }
401
411
  : null, [client]);
402
412
  const [widget, setWidget] = useState(() => adopt(client.pendingWidget));
403
413
  const [answered, setAnswered] = useState(null);
414
+ const [loading, setLoading] = useState(null);
415
+ const loadingTimer = useRef(null);
416
+ const shownAt = useRef(null);
417
+ const minTimer = useRef(null);
418
+ const [awaitingReply, setAwaitingReply] = useState(false);
419
+ const [closed, setClosed] = useState(null);
420
+ const awaitingTimer = useRef(null);
421
+ useEffect(() => () => {
422
+ if (loadingTimer.current)
423
+ clearTimeout(loadingTimer.current);
424
+ if (awaitingTimer.current)
425
+ clearTimeout(awaitingTimer.current);
426
+ if (minTimer.current)
427
+ clearTimeout(minTimer.current);
428
+ }, []);
429
+ useEffect(() => {
430
+ const done = () => {
431
+ if (awaitingTimer.current) {
432
+ clearTimeout(awaitingTimer.current);
433
+ awaitingTimer.current = null;
434
+ }
435
+ setAwaitingReply(false);
436
+ };
437
+ const offs = [
438
+ client.on("agent_speaking", (speaking) => { if (speaking !== false)
439
+ done(); }),
440
+ client.on("chunk", done),
441
+ client.on("caption", (c) => { if (c?.role === "agent")
442
+ done(); }),
443
+ client.on("widget", done),
444
+ ];
445
+ return () => { for (const off of offs)
446
+ off(); };
447
+ }, [client]);
404
448
  useEffect(() => {
405
449
  const pending = adopt(client.pendingWidget);
406
450
  if (pending)
407
451
  setWidget((cur) => (cur && cur.id === pending.id ? cur : pending));
408
- return client.on("widget", (w) => {
452
+ const offPending = client.on("widget_pending", (hint) => {
453
+ shownAt.current = Date.now();
454
+ setLoading({ id: String(hint?.widget_id ?? ""), kind: String(hint?.kind ?? "") });
455
+ if (loadingTimer.current)
456
+ clearTimeout(loadingTimer.current);
457
+ loadingTimer.current = setTimeout(() => setLoading(null), PENDING_WIDGET_MS);
458
+ });
459
+ const offWidget = client.on("widget", (w) => {
409
460
  const next = adopt(w);
410
461
  if (!next)
411
462
  return;
412
- setWidget(next);
413
- setAnswered(null);
463
+ if (loadingTimer.current) {
464
+ clearTimeout(loadingTimer.current);
465
+ loadingTimer.current = null;
466
+ }
467
+ const show = () => {
468
+ shownAt.current = null;
469
+ setLoading(null);
470
+ setClosed(null);
471
+ setWidget(next);
472
+ setAnswered(null);
473
+ };
474
+ const seen = shownAt.current === null ? MIN_LOADER_MS : Date.now() - shownAt.current;
475
+ if (seen >= MIN_LOADER_MS)
476
+ show();
477
+ else {
478
+ if (minTimer.current)
479
+ clearTimeout(minTimer.current);
480
+ minTimer.current = setTimeout(show, MIN_LOADER_MS - seen);
481
+ }
414
482
  });
483
+ return () => { offPending(); offWidget(); };
415
484
  }, [client, adopt]);
416
485
  const submit = useCallback((data, summary) => {
417
486
  if (!widget)
418
487
  return;
488
+ const onSelect = widget.onSelect;
489
+ const item = { ...data, summary };
490
+ const call = selectCall(onSelect, item);
491
+ if (call) {
492
+ void client
493
+ .executeAction(call.name, call.args)
494
+ .then((res) => {
495
+ if (res?.status && res.status !== "ok") {
496
+ client.reportEvent({
497
+ name: "in_app_action_failed",
498
+ detail: `"${call.name}" did not run: ${res?.message || res.status}. Do not describe a screen the customer cannot see.`,
499
+ });
500
+ }
501
+ })
502
+ .catch(() => { });
503
+ if (onSelect?.say)
504
+ summary = fillSelectTemplate(onSelect.say, item);
505
+ }
419
506
  client.submitWidget(widget.id, data, summary);
420
507
  client.clearPendingWidget();
421
508
  setWidget(null);
422
509
  setAnswered({ widget, data, summary });
510
+ setAwaitingReply(true);
511
+ if (awaitingTimer.current)
512
+ clearTimeout(awaitingTimer.current);
513
+ awaitingTimer.current = setTimeout(() => setAwaitingReply(false), AWAITING_REPLY_MS);
423
514
  }, [client, widget]);
424
515
  const dismiss = useCallback(() => {
516
+ if (minTimer.current) {
517
+ clearTimeout(minTimer.current);
518
+ minTimer.current = null;
519
+ }
520
+ shownAt.current = null;
521
+ if (widget) {
522
+ client.reportEvent({
523
+ name: "component_dismissed",
524
+ 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.",
525
+ quiet: true,
526
+ data: {
527
+ tool_name: widget.id.split(":")[0] || "",
528
+ kind: widget.kind,
529
+ title: String(widget.data?.title ?? ""),
530
+ answered: false,
531
+ },
532
+ });
533
+ }
534
+ setClosed(widget);
425
535
  client.clearPendingWidget();
426
536
  setWidget(null);
427
537
  setAnswered(null);
428
- }, [client]);
429
- return { widget, answered, submit, dismiss };
538
+ setLoading(null);
539
+ setAwaitingReply(false);
540
+ }, [client, widget]);
541
+ const reopen = useCallback(() => {
542
+ if (!closed)
543
+ return;
544
+ setWidget(closed);
545
+ setClosed(null);
546
+ setAnswered(null);
547
+ }, [closed]);
548
+ return { widget, answered, loading, awaitingReply, closed, reopen, submit, dismiss };
430
549
  }
431
550
  export function useGraineAction(name, handler) {
432
551
  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
+ }
@@ -1,7 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { ActivityIndicator, Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
3
+ import { Image, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, useColorScheme, View, } from "react-native";
4
4
  import { useGraineAgent } from "./index.js";
5
+ import { GraineLoader } from "./loader.js";
5
6
  import { resolveNativeAppearance } from "./appearance.js";
6
7
  const DARK = {
7
8
  surface: "#17161A",
@@ -40,6 +41,8 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
40
41
  const [draft, setDraft] = useState("");
41
42
  const scrollRef = useRef(null);
42
43
  const last = messages[messages.length - 1];
44
+ const waiting = connecting;
45
+ const waitingMoment = "connecting";
43
46
  const status = error
44
47
  ? "unavailable"
45
48
  : connecting ? "connecting…"
@@ -74,7 +77,7 @@ export function GraineAgentBar({ accent: accentProp, name: nameProp, avatarUrl:
74
77
  ], children: [expanded && (_jsxs(View, { style: styles.panel, children: [_jsxs(ScrollView, { ref: scrollRef, style: styles.transcript, contentContainerStyle: styles.transcriptInner, showsVerticalScrollIndicator: false, children: [messages.length === 0 && (_jsx(Text, { style: styles.empty, children: "I can see what you're working on \u2014 ask me anything, or I'll step in if you get stuck." })), messages.map((m, i) => (_jsx(View, { style: [
75
78
  styles.bubble,
76
79
  m.role === "agent" ? styles.agentBubble : [styles.customerBubble, { backgroundColor: accent }],
77
- ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), _jsx(Text, { style: styles.status, children: status })] }), connecting ? _jsx(ActivityIndicator, { size: "small", color: accent }) : null, _jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] })] }));
80
+ ], children: _jsx(Text, { style: m.role === "agent" ? styles.agentText : styles.customerText, children: m.text }) }, i)))] }), _jsx(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, children: _jsxs(View, { style: styles.composer, children: [_jsx(TextInput, { style: styles.input, value: draft, onChangeText: setDraft, placeholder: "Type a message", placeholderTextColor: styles.placeholder.color, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false }), _jsx(Pressable, { onPress: submit, disabled: !draft.trim(), accessibilityLabel: "Send", style: [styles.send, { backgroundColor: accent, opacity: draft.trim() ? 1 : 0.4 }], children: _jsx(Text, { style: styles.sendLabel, children: "\u2191" }) })] }) })] })), captions && caption?.text ? (_jsx(View, { style: styles.captionWrap, children: _jsx(Text, { numberOfLines: 3, style: [styles.caption, caption.live && styles.captionLive], children: caption.text }) })) : null, _jsxs(View, { style: styles.bar, children: [avatarUrl ? (_jsx(Image, { source: { uri: avatarUrl }, style: styles.avatarImage, accessibilityIgnoresInvertColors: true })) : (_jsx(View, { style: [styles.avatar, { backgroundColor: accent }], children: _jsx(Text, { style: styles.avatarGlyph, children: "\u2726" }) })), _jsxs(Pressable, { onPress: () => setExpanded((v) => !v), style: { flex: 1 }, accessibilityLabel: expanded ? "Collapse" : "Expand", children: [_jsx(Text, { style: styles.name, children: name }), waiting ? (_jsx(GraineLoader, { moment: waitingMoment, size: 15 })) : (_jsx(Text, { style: styles.status, children: status }))] }), waiting ? null : (_jsxs(_Fragment, { children: [_jsx(Pressable, { onPress: () => setCaptions((v) => !v), hitSlop: 8, accessibilityLabel: captions ? "Hide captions" : "Show captions", style: [styles.round, captions && { backgroundColor: accent }], children: _jsx(Text, { style: [styles.glyph, captions && styles.glyphOnAccent], children: "CC" }) }), _jsx(Pressable, { onPress: () => setMuted(!muted), disabled: !connected, hitSlop: 8, accessibilityLabel: muted ? "Unmute" : "Mute", style: [styles.round, muted && styles.muted, !connected && { opacity: 0.4 }], children: _jsx(Text, { style: [styles.glyph, muted && styles.glyphOnAccent], children: muted ? "🔇" : "🎙" }) }), _jsx(Pressable, { onPress: () => setExpanded((v) => !v), hitSlop: 8, accessibilityLabel: expanded ? "Collapse" : "Expand", style: styles.round, children: _jsx(Text, { style: styles.chevron, children: expanded ? "⌄" : "⌃" }) })] }))] })] }));
78
81
  }
79
82
  export const GraineLauncher = GraineAgentBar;
80
83
  const makeStyles = (t) => StyleSheet.create({
@@ -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) => {
@@ -235,6 +236,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
235
236
  endedNotifiedRef.current = false;
236
237
  setConnected(!!msg.connected);
237
238
  setConnecting(!!msg.connecting);
239
+ client.emit?.("call_state", { connected: !!msg.connected, connecting: !!msg.connecting });
238
240
  if (!msg.connected && !msg.connecting)
239
241
  startedRef.current = false;
240
242
  onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
@@ -248,8 +250,19 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
248
250
  text: String(msg.text ?? ""),
249
251
  live: !!msg.live,
250
252
  };
253
+ if (line.role === "customer" && !line.live)
254
+ setAwaitingAgent(true);
255
+ if (line.role === "agent")
256
+ setAwaitingAgent(false);
251
257
  foldCaption(line);
252
258
  onCaption?.(line);
259
+ client.emit?.("caption", line);
260
+ if (line.role === "agent")
261
+ client.emit?.("agent_speaking", true);
262
+ return;
263
+ }
264
+ case "graine:widget-pending": {
265
+ client.emit?.("widget_pending", msg.widget || {});
253
266
  return;
254
267
  }
255
268
  case "graine:ended":
@@ -285,7 +298,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
285
298
  }
286
299
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
287
300
  const pendingStartRef = useRef(false);
301
+ const lastCaption = captions.length ? captions[captions.length - 1] : null;
302
+ const phase = connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
303
+ : connecting || (!ready && !!voiceAgentId) ? "connecting"
304
+ : !connected ? "idle"
305
+ : lastCaption?.role === "agent" && lastCaption.live ? "speaking"
306
+ : awaitingAgent ? "thinking"
307
+ : "listening";
288
308
  const api = {
309
+ phase,
289
310
  connected,
290
311
  connecting,
291
312
  muted,
@@ -298,6 +319,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
298
319
  start: useCallback(() => {
299
320
  setConnecting(true);
300
321
  setCaptions([]);
322
+ setAwaitingAgent(false);
301
323
  client.clearPendingWidget();
302
324
  if (!ready) {
303
325
  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,23 @@ 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 kindSurface = widgetSurface(loading.kind);
127
+ const pendingSurface = kindSurface;
128
+ const big = pendingSurface === "screen" ? 34 : pendingSurface === "sheet" ? 26 : undefined;
129
+ const mark = (_jsx(View, { style: { alignItems: "center", gap: 10 }, children: _jsx(GraineLoader, { moment: "component", size: big, stacked: true }) }));
130
+ const bone = (w, h) => (_jsx(View, { style: { width: w, height: h, borderRadius: 8, backgroundColor: tint(theme.border, 0.6) } }));
131
+ const bones = (rows) => (_jsx(View, { style: { gap: 10, opacity: 0.55 }, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", children: Array.from({ length: rows }, (_, 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))) }));
132
+ if (pendingSurface === "screen") {
133
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", onRequestClose: dismiss, transparent: true, presentationStyle: "overFullScreen", statusBarTranslucent: true, children: _jsxs(View, { style: { flex: 1, backgroundColor: theme.surface, paddingTop: 56, paddingHorizontal: 18 }, children: [_jsx(Pressable, { onPress: dismiss, accessibilityRole: "button", accessibilityLabel: "Close and keep talking", hitSlop: 10, style: { width: 44, height: 44, borderRadius: 999, alignItems: "center", justifyContent: "center", backgroundColor: tint(theme.border, 0.4) }, children: _jsx(Text, { style: { color: theme.text, fontSize: 20, fontWeight: "600" }, children: "\u00D7" }) }), _jsx(View, { style: { flex: 1, justifyContent: "center", paddingBottom: loader.skeleton ? 24 : 72 }, children: mark }), loader.skeleton ? bones(3) : null] }) })] }));
134
+ }
135
+ if (pendingSurface === "sheet") {
136
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(Modal, { visible: true, animationType: "fade", 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: { backgroundColor: theme.surface, borderTopLeftRadius: Math.max(20, radius), borderTopRightRadius: Math.max(20, radius), paddingHorizontal: 18, paddingTop: 10, paddingBottom: 28, gap: 18 }, children: [_jsx(View, { style: { alignItems: "center" }, children: _jsx(View, { style: { width: grammar.grabber.width, height: grammar.grabber.height, borderRadius: 999, backgroundColor: theme.border } }) }), _jsx(View, { style: { paddingVertical: loader.skeleton ? 10 : 34 }, children: mark }), loader.skeleton ? bones(2) : null] })] }) })] }));
137
+ }
138
+ return (_jsxs(_Fragment, { children: [chosen, _jsx(View, { style: { backgroundColor: theme.surface, borderRadius: radius, borderWidth: 1, borderColor: theme.border, paddingHorizontal: 14, paddingVertical: 14, marginBottom: 8, alignItems: "center" }, children: _jsx(GraineLoader, { moment: "component" }) })] }));
139
+ }
114
140
  if (!widget)
115
141
  return chosen;
116
142
  const data = (widget.data ?? {});
@@ -177,6 +203,17 @@ export function GraineWidget(props) {
177
203
  const drawable = isProducts || isChips || isCalendar;
178
204
  const surface = drawable ? look.surface : "card";
179
205
  const pick = (it) => submit(normalised.toSubmit(it), normalised.toSay(it));
206
+ const many = look.select === "many" && drawable;
207
+ const submitMany = (items) => {
208
+ const titles = items.map((it) => String(it.title ?? it.name ?? "")).filter(Boolean);
209
+ submit({
210
+ items: items.map((it) => normalised.toSubmit(it)),
211
+ ids: items.map((it) => String(it.id ?? "")).join(","),
212
+ titles: titles.join(", "),
213
+ count: items.length,
214
+ }, manySummary(titles, normalised.selectLabel.toLowerCase()));
215
+ setTicked([]);
216
+ };
180
217
  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
218
  const dayLabel = String(d?.label ?? d?.id ?? `Day ${di + 1}`);
182
219
  const slots = Array.isArray(d?.slots) ? d.slots.map(String) : [];
@@ -188,7 +225,12 @@ export function GraineWidget(props) {
188
225
  paddingHorizontal: 14,
189
226
  }, children: _jsx(Text, { style: { color: theme.text, fontSize: 13.5, fontWeight: "600" }, children: slot }) }, `${slot}-${si}`))) })] }, `${dayLabel}-${di}`));
190
227
  })] })) : 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}`));
228
+ const toggle = (id) => setTicked((cur) => cur.includes(id) ? cur.filter((x) => x !== id)
229
+ : cur.length >= look.maxSelect ? [...cur.slice(1), id]
230
+ : [...cur, id]);
231
+ 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
232
+ ? toggle(row.id)
233
+ : pick({ ...normalised.items[i], id: row.id, title: row.title, price: row.price }) }, `${row.id}-${i}`));
192
234
  const gap = look.density === "compact" ? 6 : 10;
193
235
  if (look.preset === "spotlight") {
194
236
  const cardW = pagerWidth ? Math.round(pagerWidth * 0.86) : 300;
@@ -201,16 +243,32 @@ export function GraineWidget(props) {
201
243
  if (look.columns === 2) {
202
244
  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
245
  }
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")) });
246
+ const body = look.preset === "gallery" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "hero")) })
247
+ : look.preset === "list" ? _jsx(View, { children: rows.map((row, i) => draw(row, i, "list")) })
248
+ : look.preset === "compact" ? _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "compact")) })
249
+ : _jsx(View, { style: { gap }, children: rows.map((row, i) => draw(row, i, "row")) });
250
+ if (!many)
251
+ return body;
252
+ const chosenRows = ticked
253
+ .map((id) => ({ row: rows.find((r) => r.id === id), i: rows.findIndex((r) => r.id === id) }))
254
+ .filter((x) => !!x.row);
255
+ return (_jsxs(_Fragment, { children: [body, _jsx(Pressable, { disabled: chosenRows.length < 2, onPress: () => {
256
+ const items = chosenRows.map(({ row, i }) => ({
257
+ ...normalised.items[i], id: row.id, title: row.title, price: row.price,
258
+ }));
259
+ submitMany(items);
260
+ }, accessibilityRole: "button", accessibilityLabel: chosenRows.length < 2
261
+ ? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
262
+ : `${normalised.selectLabel} ${chosenRows.length}`, style: {
263
+ marginTop: 12, minHeight: 48, borderRadius: grammar.pillButtons ? 999 : grammar.buttonRadius,
264
+ alignItems: "center", justifyContent: "center",
265
+ backgroundColor: chosenRows.length < 2 ? tint(theme.border, 0.5) : accent,
266
+ }, children: _jsx(Text, { style: {
267
+ color: chosenRows.length < 2 ? theme.muted : theme.onAccent,
268
+ fontSize: 14.5, fontWeight: "700",
269
+ }, children: chosenRows.length < 2
270
+ ? `Pick ${2 - chosenRows.length} more to ${normalised.selectLabel.toLowerCase()}`
271
+ : `${normalised.selectLabel} ${chosenRows.length}` }) })] }));
214
272
  })()) : isChips ? (_jsx(View, { style: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, children: options.map((o, i) => {
215
273
  const label = String(o?.label ?? o?.title ?? o ?? "");
216
274
  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.6",
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",