@graineai/inapp-react-native 0.39.0 → 0.40.1

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.
@@ -1,3 +1,4 @@
1
+ import { normaliseLogoPlate } from "../theme.js";
1
2
  const HEX = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
2
3
  const RADIUS_CEILING = 48;
3
4
  const DEFAULT_ORIGIN = "https://www.graine.ai";
@@ -25,7 +26,7 @@ export function resolveNativeAppearance(appearance, fallback) {
25
26
  const origin = String(fallback.origin || DEFAULT_ORIGIN).replace(/\/+$/, "");
26
27
  const logoUrl = logoKey ? `${origin}/api/embed/logo?k=${encodeURIComponent(logoKey)}` : null;
27
28
  const radius = clamp(numeric(a.radius) ?? numeric(fallback.radius) ?? 18, max);
28
- return { accent, heading, logoUrl, radius };
29
+ return { accent, heading, logoUrl, logoPlate: normaliseLogoPlate(a.logoPlate), radius };
29
30
  }
30
31
  const LIGHT = { surface: "#FFFFFF", border: "#E5E4E0", text: "#101318", muted: "#5F6672" };
31
32
  const DARK = { surface: "#17181B", border: "#2C2E33", text: "#F5F5F4", muted: "#A0A5AE" };
@@ -7,7 +7,7 @@ import { GraineAgentBar } from "./ui.js";
7
7
  import { resolveNativeAppearance } from "./appearance.js";
8
8
  import { useDesign } from "./use-design.js";
9
9
  import { glyphPixels, launcherRadius, onAccentFor, shadowStyle } from "./design-math.js";
10
- import { glyphUrl } from "../theme.js";
10
+ import { glyphUrl, LOGO_PLATE_WHITE, logoBox, logoPlateLook, readableOn } from "../theme.js";
11
11
  import { UNNAMED_TAP } from "../protocol.js";
12
12
  function AboveNativeScreens({ overlay: Overlay, children, }) {
13
13
  if (!Overlay)
@@ -69,15 +69,18 @@ export function GraineFab({ webView, position = "bottom-right", size = 56, accen
69
69
  return null;
70
70
  const side = position === "bottom-left" ? { left: launcherInset.right } : { right: launcherInset.right };
71
71
  if (!look.classic) {
72
- return (_jsx(DesignedLauncher, { look: look, size: size, accent: accentProp ?? look.tokens.colors.accent, name: name, label: label ?? `Talk to ${name}`, logoUrl: dashboard.logoUrl, origin: client.opts.baseUrl, place: { position: "absolute", bottom: launcherInset.bottom, ...side }, onPress: () => { setOpen(true); api.start(); onOpen?.(); } }));
72
+ return (_jsx(DesignedLauncher, { look: look, size: size, accent: accentProp ?? look.tokens.colors.accent, name: name, label: label ?? `Talk to ${name}`, logoUrl: dashboard.logoUrl, logoPlate: dashboard.logoPlate, origin: client.opts.baseUrl, place: { position: "absolute", bottom: launcherInset.bottom, ...side }, onPress: () => { setOpen(true); api.start(); onOpen?.(); } }));
73
73
  }
74
74
  return (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label ?? `Talk to ${name}`, onPress: () => { setOpen(true); api.start(); onOpen?.(); }, style: ({ pressed }) => ({
75
75
  position: "absolute", bottom: launcherInset.bottom, ...side,
76
- width: size, height: size, borderRadius: size / 2, backgroundColor: dashboardAccent,
76
+ width: size, height: size, borderRadius: size / 2,
77
+ ...(dashboard.logoUrl && dashboard.logoPlate === "white"
78
+ ? { backgroundColor: LOGO_PLATE_WHITE, borderWidth: 1, borderColor: "#E5E7EB" }
79
+ : { backgroundColor: dashboardAccent }),
77
80
  alignItems: "center", justifyContent: "center",
78
81
  opacity: pressed ? 0.85 : 1,
79
82
  elevation: 6, shadowColor: "#000", shadowOpacity: 0.25, shadowRadius: 8, shadowOffset: { width: 0, height: 4 },
80
- }), children: dashboard.logoUrl ? (_jsx(Image, { source: { uri: dashboard.logoUrl }, style: { width: size * 0.6, height: size * 0.6, borderRadius: size * 0.3 } })) : (_jsx(Text, { style: { color: "#fff", fontSize: size * 0.4, fontWeight: "700" }, children: name.trim().charAt(0).toUpperCase() || "•" })) }));
83
+ }), children: dashboard.logoUrl ? (_jsx(Image, { source: { uri: dashboard.logoUrl }, resizeMode: "contain", style: { width: logoBox("circle", size), height: logoBox("circle", size) }, accessibilityIgnoresInvertColors: true })) : (_jsx(Text, { style: { color: "#fff", fontSize: size * 0.4, fontWeight: "700" }, children: name.trim().charAt(0).toUpperCase() || "•" })) }));
81
84
  };
82
85
  if (!prewarm && !open)
83
86
  return _jsx(AboveNativeScreens, { overlay: overlay, children: button({ start: () => { } }) });
@@ -87,9 +90,9 @@ export function GraineLauncherButton({ onPress, style, label, size = 56 }) {
87
90
  const { appearance, client } = useGraineAgent();
88
91
  const look = useDesign();
89
92
  const dashboard = resolveNativeAppearance(appearance, { accent: look.tokens.colors.accent, heading: "Assistant" });
90
- return (_jsx(DesignedLauncher, { look: look, size: size, accent: look.tokens.colors.accent, name: dashboard.heading, label: label ?? `Talk to ${dashboard.heading}`, logoUrl: dashboard.logoUrl, origin: client.opts.baseUrl, place: style ?? {}, onPress: onPress }));
93
+ return (_jsx(DesignedLauncher, { look: look, size: size, accent: look.tokens.colors.accent, name: dashboard.heading, label: label ?? `Talk to ${dashboard.heading}`, logoUrl: dashboard.logoUrl, logoPlate: dashboard.logoPlate, origin: client.opts.baseUrl, place: style ?? {}, onPress: onPress }));
91
94
  }
92
- function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, place, onPress, }) {
95
+ function DesignedLauncher({ look, size, accent, name, label, logoUrl, logoPlate = "accent", origin, place, onPress, }) {
93
96
  const t = look.tokens;
94
97
  const [failed, setFailed] = useState([]);
95
98
  const fail = (uri) => setFailed((cur) => (cur.includes(uri) ? cur : [...cur, uri]));
@@ -113,11 +116,12 @@ function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, pl
113
116
  const glyph = glyphUri && !failed.includes(glyphUri) ? glyphUri : null;
114
117
  const logo = logoUrl && !failed.includes(logoUrl) ? logoUrl : null;
115
118
  const hard = t.elevation.hardOffset;
119
+ const plate = logoPlateLook(!glyph && logo ? logoPlate : "accent", { ...t.colors, accent }, t.shape.borderWidth);
116
120
  const corners = {
117
121
  borderRadius: radius,
118
122
  ...(t.shape.launcher === "squircle" && Platform.OS === "ios" ? { borderCurve: "continuous" } : null),
119
123
  };
120
- const mark = glyph ? (_jsx(Image, { source: { uri: glyph }, style: { width: g, height: g }, onError: () => fail(glyph), accessibilityIgnoresInvertColors: true }, glyph)) : logo ? (_jsx(Image, { source: { uri: logo }, resizeMode: "contain", style: { width: g, height: g }, onError: () => fail(logo), accessibilityIgnoresInvertColors: true }, logo)) : (_jsx(Text, { style: {
124
+ const mark = glyph ? (_jsx(Image, { source: { uri: glyph }, style: { width: g, height: g }, onError: () => fail(glyph), accessibilityIgnoresInvertColors: true }, glyph)) : logo ? (_jsx(Image, { source: { uri: logo }, resizeMode: "contain", style: { width: logoBox(pill ? "circle" : t.shape.launcher, size), height: logoBox(pill ? "circle" : t.shape.launcher, size) }, onError: () => fail(logo), accessibilityIgnoresInvertColors: true }, logo)) : (_jsx(Text, { style: {
121
125
  color: onAccent, fontSize: Math.round(size * 0.46), fontWeight: String(t.font.weights.title),
122
126
  fontFamily: look.font(t.font.heading),
123
127
  }, children: name.trim().charAt(0).toUpperCase() || "\u2022" }));
@@ -126,11 +130,12 @@ function DesignedLauncher({ look, size, accent, name, label, logoUrl, origin, pl
126
130
  height: size, minWidth: size, width: pill ? undefined : size,
127
131
  flexDirection: "row", alignItems: "center", justifyContent: "center",
128
132
  gap: pill ? 8 : 0, paddingHorizontal: pill ? Math.round(size * 0.32) : 0,
129
- backgroundColor: accent,
133
+ backgroundColor: plate.fill,
134
+ ...(plate.outline && !hard ? { borderWidth: plate.outline.width, borderColor: plate.outline.color } : null),
130
135
  opacity: pressed ? 0.85 : 1,
131
136
  ...(hard ? { borderWidth: t.shape.borderWidth, borderColor: t.colors.text } : shadowStyle(t.elevation.floating)),
132
137
  }), children: [mark, pill ? (_jsx(Text, { numberOfLines: 1, style: {
133
- color: onAccent, fontSize: t.font.scale.body, fontWeight: String(t.font.weights.strong),
138
+ color: plate.outline ? readableOn(LOGO_PLATE_WHITE) : onAccent, fontSize: t.font.scale.body, fontWeight: String(t.font.weights.strong),
134
139
  fontFamily: look.font(t.font.body),
135
140
  }, children: name })) : null] })] }));
136
141
  }
@@ -43,6 +43,7 @@ interface GraineContextValue {
43
43
  fonts: Record<string, string> | null;
44
44
  faces: LoadedFaces;
45
45
  svg: SvgKit | null;
46
+ endOnBackground: boolean;
46
47
  sessionReady: boolean;
47
48
  onEvent: (fn: (e: GraineEvent) => void) => () => void;
48
49
  track: (name: string, data?: Record<string, unknown>) => void;
@@ -390,9 +390,10 @@ export function GraineProvider({ children, autoConnect = true, surface: surfaceP
390
390
  connectionQuality,
391
391
  network,
392
392
  setVariables: (vars) => client.setVariables(vars),
393
+ endOnBackground,
393
394
  }), [client, connected, connecting, error, open, messages, widgets, muted, agentSpeaking, caption,
394
395
  launcherVisible, launcherInset, currentScreen, onEvent, appearance, design, fonts, faces, svgKit, sessionReady,
395
- surface, surfacePolicy, setSurface, connectionQuality, network]);
396
+ surface, surfacePolicy, setSurface, connectionQuality, network, endOnBackground]);
396
397
  const wantsCapture = captureTaps ?? !!fab;
397
398
  const body = fab || wantsCapture ? (_jsxs(GraineTouchCapture, { enabled: wantsCapture, children: [children, fab ? _jsx(GraineFab, { ...fab }) : null] })) : children;
398
399
  return _jsx(Ctx.Provider, { value: value, children: body });
@@ -2,19 +2,29 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import React, { useContext, useEffect, useRef, useState } from "react";
3
3
  import { Animated, Easing, Image, PixelRatio, Platform, Pressable, ScrollView, Text, TextInput, View } from "react-native";
4
4
  import { Ctx, useGraineAgent } from "./index.js";
5
- import { GraineLoader } from "./loader.js";
5
+ import { GraineLoader, useGraineLoader } from "./loader.js";
6
6
  import { GraineWidget } from "./widget.js";
7
7
  import { resolveNativeAppearance } from "./appearance.js";
8
8
  import { useDesign } from "./use-design.js";
9
9
  import { glyphPixels, launcherRadius, letterSpacing, lineHeight, shadowStyle, surfaceFill } from "./design-math.js";
10
10
  import { useKeyboardLift } from "./keyboard.js";
11
11
  import { useDraggablePlacement } from "./placement.js";
12
- import { glyphUrl, SDK_ICONS } from "../theme.js";
12
+ import { glyphUrl, logoBox, logoPlateLook, SDK_ICONS } from "../theme.js";
13
+ import { DEFAULT_LOADER_TEXT } from "../protocol.js";
13
14
  import { TitleBar, windowed } from "./window-chrome.js";
14
15
  const BAR_HEIGHT = 60;
15
16
  const BUTTON = 38;
16
17
  const METER = [0.55, 1, 0.7, 0.4];
17
- function statusOf(api, isChat, chatConnected) {
18
+ function connectingLook(api, connectingText) {
19
+ if (api.connectStage === "retrying" || api.connectStage === "switching") {
20
+ return { text: "Reconnecting…", waiting: "reconnecting" };
21
+ }
22
+ if (api.slowConnect && connectingText === DEFAULT_LOADER_TEXT.connecting) {
23
+ return { text: "Still connecting…", waiting: "connecting", label: "Still connecting…" };
24
+ }
25
+ return { text: connectingText, waiting: "connecting" };
26
+ }
27
+ function statusOf(api, isChat, chatConnected, connectingText = DEFAULT_LOADER_TEXT.connecting) {
18
28
  const conn = api.connectivity?.state;
19
29
  const say = (text) => ({ text, waiting: null, meter: false });
20
30
  if (api.error)
@@ -29,8 +39,10 @@ function statusOf(api, isChat, chatConnected) {
29
39
  return say("Needs microphone");
30
40
  if (conn === "failed")
31
41
  return say("Couldn’t load · tap to retry");
42
+ if (api.callFailed)
43
+ return say("Couldn’t connect · tap to retry");
32
44
  if (!api.ready || api.connecting)
33
- return { text: "Connecting…", waiting: "connecting", meter: false };
45
+ return { ...connectingLook(api, connectingText), meter: false };
34
46
  if (api.muted)
35
47
  return say("Muted");
36
48
  if (api.connected && api.phase === "thinking")
@@ -63,7 +75,8 @@ export function GraineVoiceBar({ api, onClose, bottomInset, draggable = true, pl
63
75
  initialOffset: placement?.bottomOffset,
64
76
  onChange: (bottomOffset) => onPlacementChange?.({ bottomOffset }),
65
77
  });
66
- const status = statusOf(api, isChat, chatConnected);
78
+ const loader = useGraineLoader();
79
+ const status = statusOf(api, isChat, chatConnected, loader.text.connecting);
67
80
  const conn = api.connectivity?.state;
68
81
  const micBlocked = !!api.error || api.micDenied || api.connecting || (!api.ready && conn !== "failed");
69
82
  const caption = !isChat && api.captionsOn && api.caption?.text ? api.caption : null;
@@ -79,11 +92,11 @@ export function GraineVoiceBar({ api, onClose, bottomInset, draggable = true, pl
79
92
  return (_jsxs(View, { pointerEvents: "box-none", ...drag.panHandlers, onLayout: drag.onLayout, style: {
80
93
  position: "absolute", zIndex: 900, gap: 8, left: 12, right: 12,
81
94
  bottom: baseBottom + drag.offset + keyboardLift,
82
- }, children: [_jsx(WidgetGuard, { children: _jsx(GraineWidget, {}) }), isChat ? (_jsx(ChatWindow, { look: look, kit: kit, origin: origin, heading: heading, logoUrl: dash.logoUrl ?? undefined, messages: messages, send: send, online: chatConnected, lifted: keyboardLift > 0, onCollapse: () => (canSwitch ? setSurface("voice") : endCall()) })) : caption ? (_jsx(CaptionCard, { look: look, role: caption.role, text: caption.text, live: !!caption.live })) : null, _jsx(Raised, { t: t, level: "floating", radius: t.shape.radius.xl, children: _jsxs(View, { style: {
95
+ }, children: [_jsx(WidgetGuard, { children: _jsx(GraineWidget, {}) }), isChat ? (_jsx(ChatWindow, { look: look, kit: kit, origin: origin, heading: heading, logoUrl: dash.logoUrl ?? undefined, logoPlate: dash.logoPlate, messages: messages, send: send, online: chatConnected, lifted: keyboardLift > 0, onCollapse: () => (canSwitch ? setSurface("voice") : endCall()) })) : caption ? (_jsx(CaptionCard, { look: look, role: caption.role, text: caption.text, live: !!caption.live })) : null, _jsx(Raised, { t: t, level: "floating", radius: t.shape.radius.xl, children: _jsxs(View, { style: {
83
96
  height: BAR_HEIGHT, paddingHorizontal: 9, flexDirection: "row", alignItems: "center", gap: 10,
84
97
  backgroundColor: surfaceFill(c.surface, t), borderWidth: t.shape.borderWidth, borderColor: c.border,
85
98
  borderRadius: t.shape.radius.xl, ...softShadow(t, "floating"),
86
- }, children: [_jsx(View, { style: { width: BUTTON, height: BUTTON, borderRadius: BUTTON / 2, backgroundColor: c.accent, alignItems: "center", justifyContent: "center", overflow: "hidden" }, children: _jsx(Mark, { t: t, size: BUTTON, glyph: 18, kit: kit, origin: origin, logoUrl: dash.logoUrl ?? undefined, initial: heading.charAt(0).toUpperCase() || "A", cover: true }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...body(14, t.font.weights.strong), color: c.text, lineHeight: 17.5 }, children: heading }), _jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 1 }, children: status.waiting ? (_jsx(GraineLoader, { moment: status.waiting, size: 14 })) : (_jsxs(_Fragment, { children: [status.meter ? _jsx(Meter, { look: look, color: c.accent }) : null, _jsx(Text, { numberOfLines: 1, style: { ...body(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.3) }, children: status.text })] })) })] }), canSwitch ? (_jsx(RoundButton, { label: isChat ? "Switch to a call" : "Switch to chat", fill: c.accentSoft, onPress: () => setSurface(isChat ? "voice" : "chat"), children: _jsx(Icon, { name: isChat ? "call" : "chat", t: t, color: c.text, kit: kit }) })) : null, isChat ? null : (_jsx(RoundButton, { label: !api.connected ? `Start talking to ${spoken}` : api.muted ? "Unmute" : "Mute", fill: api.muted ? c.danger : c.accent, disabled: micBlocked, onPress: () => {
99
+ }, children: [_jsx(View, { style: { width: BUTTON, height: BUTTON, borderRadius: BUTTON / 2, ...markFill(t, dash.logoPlate, !t.icons.launcher && !!dash.logoUrl), alignItems: "center", justifyContent: "center", overflow: "hidden" }, children: _jsx(Mark, { t: t, size: BUTTON, glyph: 18, kit: kit, origin: origin, logoUrl: dash.logoUrl ?? undefined, initial: heading.charAt(0).toUpperCase() || "A" }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...body(14, t.font.weights.strong), color: c.text, lineHeight: 17.5 }, children: heading }), _jsx(View, { style: { flexDirection: "row", alignItems: "center", gap: 6, marginTop: 1 }, children: status.waiting ? (_jsx(GraineLoader, { moment: status.waiting, label: status.label, size: 14 })) : (_jsxs(_Fragment, { children: [status.meter ? _jsx(Meter, { look: look, color: c.accent }) : null, _jsx(Text, { numberOfLines: 1, style: { ...body(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.3) }, children: status.text })] })) })] }), canSwitch ? (_jsx(RoundButton, { label: isChat ? "Switch to a call" : "Switch to chat", fill: c.accentSoft, onPress: () => setSurface(isChat ? "voice" : "chat"), children: _jsx(Icon, { name: isChat ? "call" : "chat", t: t, color: c.text, kit: kit }) })) : null, isChat ? null : (_jsx(RoundButton, { label: !api.connected ? `Start talking to ${spoken}` : api.muted ? "Unmute" : "Mute", fill: api.muted ? c.danger : c.accent, disabled: micBlocked, onPress: () => {
87
100
  if (conn === "failed") {
88
101
  api.retry();
89
102
  return;
@@ -125,7 +138,13 @@ function Icon({ name, t, color, size = 18, kit, rotate }) {
125
138
  const common = { stroke: color, strokeLinecap: sharp ? "square" : "round", strokeLinejoin: sharp ? "miter" : "round" };
126
139
  return (_jsx(View, { style: rotate ? { transform: [{ rotate: "180deg" }] } : undefined, children: _jsxs(Svg, { width: size, height: size, viewBox: "0 0 24 24", children: [icon.body ? (_jsx(Path, { d: icon.body, ...common, fill: filled || style === "duotone" ? color : "none", fillOpacity: style === "duotone" ? 0.28 : 1, strokeWidth: filled ? Math.max(1, stroke * 0.75) : stroke })) : null, icon.lines ? _jsx(Path, { d: icon.lines, ...common, fill: "none", strokeWidth: stroke }) : null] }) }));
127
140
  }
128
- function Mark({ t, size, glyph, kit, origin, logoUrl, initial, cover = false, }) {
141
+ function markFill(t, plate, showsLogo) {
142
+ const look = logoPlateLook(showsLogo ? plate : "accent", t.colors, t.shape.borderWidth);
143
+ return look.outline
144
+ ? { backgroundColor: look.fill, borderWidth: look.outline.width, borderColor: look.outline.color }
145
+ : { backgroundColor: look.fill };
146
+ }
147
+ function Mark({ t, size, glyph, kit, origin, logoUrl, initial, shape = "circle", }) {
129
148
  const on = t.colors.onAccent;
130
149
  const g = t.icons.launcher;
131
150
  if (g) {
@@ -136,7 +155,8 @@ function Mark({ t, size, glyph, kit, origin, logoUrl, initial, cover = false, })
136
155
  return (_jsx(Image, { source: { uri: glyphUrl(origin, g.id, on, glyphPixels(size, PixelRatio.get()), t.icons.stroke) }, style: { width: glyph, height: glyph }, accessibilityIgnoresInvertColors: true }));
137
156
  }
138
157
  if (logoUrl) {
139
- return _jsx(Image, { source: { uri: logoUrl }, style: cover ? { width: size, height: size } : { width: glyph, height: glyph }, resizeMode: cover ? "cover" : "contain", accessibilityIgnoresInvertColors: true });
158
+ const box = logoBox(shape, size);
159
+ return _jsx(Image, { source: { uri: logoUrl }, style: { width: box, height: box }, resizeMode: "contain", accessibilityIgnoresInvertColors: true });
140
160
  }
141
161
  return _jsx(Text, { style: { color: on, fontSize: Math.round(size * 0.4), fontWeight: "700" }, children: initial });
142
162
  }
@@ -191,7 +211,7 @@ function CaptionCard({ look, role, text, live }) {
191
211
  borderRadius: t.shape.radius.lg, paddingHorizontal: 12, paddingVertical: 8, ...softShadow(t, "card"),
192
212
  }, children: [win ? (_jsx(View, { style: { marginBottom: 6 }, children: _jsx(TitleBar, { t: t, title: "captions", size: "caption", radius: t.shape.radius.lg, bleed: { horizontal: 12, top: 8 } }) })) : null, role === "customer" ? (_jsx(Text, { style: { ...look.face(t.font.body, t.font.weights.strong), color: c.accent, fontSize: 10.5, letterSpacing: 0.6, textTransform: "uppercase", marginBottom: 2 }, children: "You" })) : null, _jsx(Text, { numberOfLines: 2, style: { ...look.face(t.font.body, t.font.weights.strong), color: c.text, fontSize: 13, lineHeight: 17.5, opacity: live ? 0.75 : 1 }, children: text })] }) }) }));
193
213
  }
194
- function ChatWindow({ look, kit, origin, heading, logoUrl, messages, send, online, lifted, onCollapse, }) {
214
+ function ChatWindow({ look, kit, origin, heading, logoUrl, logoPlate = "accent", messages, send, online, lifted, onCollapse, }) {
195
215
  const t = look.tokens;
196
216
  const c = t.colors;
197
217
  const r = t.shape.radius;
@@ -217,7 +237,7 @@ function ChatWindow({ look, kit, origin, heading, logoUrl, messages, send, onlin
217
237
  letterSpacing: letterSpacing(size, t.font.letterSpacing.body),
218
238
  });
219
239
  const titleSize = Math.min(t.font.scale.title, 16);
220
- return (_jsx(Animated.View, { style: rise, children: _jsx(Raised, { t: t, level: "floating", radius: r.xl, children: _jsx(View, { style: { backgroundColor: c.background, borderWidth: bw, borderColor: c.border, borderRadius: r.xl, ...softShadow(t, "floating") }, children: _jsxs(View, { style: { borderRadius: Math.max(0, r.xl - bw), overflow: "hidden" }, children: [windowed(t) ? (_jsx(TitleBar, { t: t, title: heading, online: online, radius: r.xl, right: _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, size: 16, rotate: true }) }) })) : (_jsxs(View, { style: { height: 50, flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 12, backgroundColor: surfaceFill(c.surface, t), borderBottomWidth: bw, borderBottomColor: c.border }, children: [_jsx(View, { style: { width: 30, height: 30, borderRadius: launcherRadius(markShape, 30, t), backgroundColor: c.accent, alignItems: "center", justifyContent: "center", overflow: "hidden", ...(Platform.OS === "ios" && markShape === "squircle" ? { borderCurve: "continuous" } : null) }, children: _jsx(Mark, { t: t, size: 30, glyph: 14, kit: kit, origin: origin, logoUrl: logoUrl, initial: heading.charAt(0).toUpperCase() || "A" }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...look.face(t.font.heading, t.font.weights.title), color: c.text, fontSize: titleSize, lineHeight: lineHeight(titleSize, t.font.lineHeight.title), letterSpacing: letterSpacing(titleSize, t.font.letterSpacing.title) }, children: heading }), _jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: 4 }, children: [online ? _jsx(View, { style: { width: 6, height: 6, borderRadius: 3, backgroundColor: c.success } }) : null, _jsx(Text, { style: { ...text(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.2) }, children: online ? "Online" : "Connecting…" })] })] }), _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, rotate: true }) })] })), _jsxs(ScrollView, { ref: scroll, style: { maxHeight: lifted ? 150 : 260 }, contentContainerStyle: { padding: 12, gap: t.spacing.gap }, keyboardShouldPersistTaps: "handled", children: [messages.length === 0 ? (_jsx(Text, { style: { ...text(t.font.scale.body, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.body, t.font.lineHeight.body) }, children: online ? `${heading} can see what you’re looking at — ask it anything.` : "Connecting…" })) : null, messages.map((m, i) => {
240
+ return (_jsx(Animated.View, { style: rise, children: _jsx(Raised, { t: t, level: "floating", radius: r.xl, children: _jsx(View, { style: { backgroundColor: c.background, borderWidth: bw, borderColor: c.border, borderRadius: r.xl, ...softShadow(t, "floating") }, children: _jsxs(View, { style: { borderRadius: Math.max(0, r.xl - bw), overflow: "hidden" }, children: [windowed(t) ? (_jsx(TitleBar, { t: t, title: heading, online: online, radius: r.xl, right: _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, size: 16, rotate: true }) }) })) : (_jsxs(View, { style: { height: 50, flexDirection: "row", alignItems: "center", gap: 10, paddingHorizontal: 12, backgroundColor: surfaceFill(c.surface, t), borderBottomWidth: bw, borderBottomColor: c.border }, children: [_jsx(View, { style: { width: 30, height: 30, borderRadius: launcherRadius(markShape, 30, t), ...markFill(t, logoPlate, !t.icons.launcher && !!logoUrl), alignItems: "center", justifyContent: "center", overflow: "hidden", ...(Platform.OS === "ios" && markShape === "squircle" ? { borderCurve: "continuous" } : null) }, children: _jsx(Mark, { t: t, size: 30, glyph: 14, kit: kit, origin: origin, logoUrl: logoUrl, initial: heading.charAt(0).toUpperCase() || "A", shape: markShape }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { ...look.face(t.font.heading, t.font.weights.title), color: c.text, fontSize: titleSize, lineHeight: lineHeight(titleSize, t.font.lineHeight.title), letterSpacing: letterSpacing(titleSize, t.font.letterSpacing.title) }, children: heading }), _jsxs(View, { style: { flexDirection: "row", alignItems: "center", gap: 4 }, children: [online ? _jsx(View, { style: { width: 6, height: 6, borderRadius: 3, backgroundColor: c.success } }) : null, _jsx(Text, { style: { ...text(t.font.scale.caption, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.caption, 1.2) }, children: online ? "Online" : "Connecting…" })] })] }), _jsx(Pressable, { onPress: onCollapse, hitSlop: 10, accessibilityRole: "button", accessibilityLabel: "Collapse", children: _jsx(Icon, { name: "chevron", t: t, color: c.textMuted, kit: kit, rotate: true }) })] })), _jsxs(ScrollView, { ref: scroll, style: { maxHeight: lifted ? 150 : 260 }, contentContainerStyle: { padding: 12, gap: t.spacing.gap }, keyboardShouldPersistTaps: "handled", children: [messages.length === 0 ? (_jsx(Text, { style: { ...text(t.font.scale.body, t.font.weights.body), color: c.textMuted, lineHeight: lineHeight(t.font.scale.body, t.font.lineHeight.body) }, children: online ? `${heading} can see what you’re looking at — ask it anything.` : "Connecting…" })) : null, messages.map((m, i) => {
221
241
  const b = bubble(t, m.role !== "agent");
222
242
  return (_jsx(View, { style: [{ maxWidth: "84%" }, b.box], children: _jsx(Text, { style: { ...text(t.font.scale.body, t.font.weights.body), color: b.color, lineHeight: lineHeight(t.font.scale.body, t.font.lineHeight.body), opacity: m.live ? 0.75 : 1 }, children: m.text }) }, i));
223
243
  })] }), _jsxs(View, { style: { height: 58, flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 10, backgroundColor: c.background }, children: [_jsx(TextInput, { value: draft, onChangeText: setDraft, onSubmitEditing: submit, returnKeyType: "send", blurOnSubmit: false, placeholder: "Message\u2026", placeholderTextColor: c.textMuted, style: {
@@ -31,6 +31,7 @@ export interface GraineVoiceLauncherProps {
31
31
  bootMs?: number;
32
32
  hardCapMs?: number;
33
33
  endWatchdogMs?: number;
34
+ slowConnectMs?: number;
34
35
  };
35
36
  children?: (api: GraineVoiceApi) => React.ReactNode;
36
37
  }
@@ -61,6 +62,10 @@ export interface GraineVoiceApi {
61
62
  reconnecting: boolean;
62
63
  error: string | null;
63
64
  micDenied: boolean;
65
+ connectStage: "registering" | "dialling" | "retrying" | "switching" | null;
66
+ slowConnect: boolean;
67
+ callFailed: boolean;
64
68
  }
65
69
  export declare const CAPTION_HISTORY = 12;
70
+ export declare const SLOW_CONNECT_MS = 4000;
66
71
  export declare function GraineVoiceLauncher({ webView: WebView, autoStart, active: activeProp, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }: GraineVoiceLauncherProps): React.JSX.Element;
@@ -1,11 +1,15 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useCallback, useContext, useEffect, useRef, useState } from "react";
3
3
  import { AppState, View } from "react-native";
4
- import { useGraineAgent } from "./index.js";
4
+ import { Ctx, useGraineAgent } from "./index.js";
5
5
  import { requestMicrophonePermission } from "./permissions.js";
6
6
  export const CAPTION_HISTORY = 12;
7
+ export const SLOW_CONNECT_MS = 4000;
7
8
  export function GraineVoiceLauncher({ webView: WebView, autoStart = false, active: activeProp = true, transport, onTransport, onCaption, onCallState, onMicDenied, onError, timing, onEnded, children, }) {
8
9
  const { client, appearance, surface, surfaces } = useGraineAgent();
10
+ const endOnBackground = useContext(Ctx)?.endOnBackground ?? true;
11
+ const endOnBackgroundRef = useRef(endOnBackground);
12
+ endOnBackgroundRef.current = endOnBackground;
9
13
  const active = activeProp && surface !== "chat";
10
14
  const ref = useRef(null);
11
15
  useEffect(() => {
@@ -22,6 +26,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
22
26
  const [muted, setMutedState] = useState(false);
23
27
  const [fault, setFault] = useState(null);
24
28
  const [micDenied, setMicDenied] = useState(false);
29
+ const [connectStage, setConnectStage] = useState(null);
30
+ const [slowConnect, setSlowConnect] = useState(false);
31
+ const [callFailed, setCallFailed] = useState(false);
25
32
  const [ready, setReady] = useState(false);
26
33
  const readyRef = useRef(false);
27
34
  readyRef.current = ready;
@@ -291,6 +298,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
291
298
  if (msg.connected) {
292
299
  endedNotifiedRef.current = false;
293
300
  setReconnecting(false);
301
+ setConnectStage(null);
302
+ setCallFailed(false);
294
303
  }
295
304
  setConnected(!!msg.connected);
296
305
  setConnecting(!!msg.connecting);
@@ -299,6 +308,22 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
299
308
  startedRef.current = false;
300
309
  onCallState?.({ connected: !!msg.connected, connecting: !!msg.connecting });
301
310
  return;
311
+ case "graine:progress": {
312
+ const stage = String(msg.stage || "");
313
+ if (stage === "failed") {
314
+ setConnectStage(null);
315
+ setConnecting(false);
316
+ setCallFailed(true);
317
+ startedRef.current = false;
318
+ pendingStartRef.current = false;
319
+ onCallState?.({ connected: false, connecting: false });
320
+ return;
321
+ }
322
+ if (stage === "registering" || stage === "dialling" || stage === "retrying" || stage === "switching") {
323
+ setConnectStage(stage);
324
+ }
325
+ return;
326
+ }
302
327
  case "graine:muted":
303
328
  setMutedState(!!msg.muted);
304
329
  return;
@@ -372,6 +397,15 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
372
397
  }
373
398
  }, [autoStart, beginCall, client, onCaption, onCallState, onMicDenied, onTransport, onError, onEnded, post, pushContext, foldCaption]);
374
399
  const pendingStartRef = useRef(false);
400
+ const waitingOnConnect = active && !connected && !callFailed && (connecting || !ready);
401
+ useEffect(() => {
402
+ if (!waitingOnConnect) {
403
+ setSlowConnect(false);
404
+ return;
405
+ }
406
+ const t = setTimeout(() => setSlowConnect(true), timing?.slowConnectMs ?? SLOW_CONNECT_MS);
407
+ return () => clearTimeout(t);
408
+ }, [waitingOnConnect]);
375
409
  const lastCaption = captions.length ? captions[captions.length - 1] : null;
376
410
  const phase = reconnecting || connectivity.state === "failed" || connectivity.state === "retrying" ? "reconnecting"
377
411
  : connecting || (!ready && !!voiceAgentId) ? "connecting"
@@ -394,6 +428,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
394
428
  setConnecting(true);
395
429
  setMicDenied(false);
396
430
  setFault(null);
431
+ setCallFailed(false);
432
+ setConnectStage(null);
397
433
  setCaptions([]);
398
434
  setAwaitingAgent(false);
399
435
  client.clearPendingWidget();
@@ -411,6 +447,9 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
411
447
  reconnecting,
412
448
  error: fault,
413
449
  micDenied,
450
+ connectStage,
451
+ slowConnect,
452
+ callFailed,
414
453
  setMuted: useCallback((m) => {
415
454
  setMutedState(m);
416
455
  post({ type: "graine:set-muted", muted: m });
@@ -460,6 +499,8 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
460
499
  setCaptions([]);
461
500
  setMicDenied(false);
462
501
  setFault(null);
502
+ setCallFailed(false);
503
+ setConnectStage(null);
463
504
  client.clearPendingWidget();
464
505
  onCallState?.({ connected: false, connecting: false });
465
506
  }, [active]);
@@ -480,7 +521,7 @@ export function GraineVoiceLauncher({ webView: WebView, autoStart = false, activ
480
521
  post({ type: "graine:warm" });
481
522
  return;
482
523
  }
483
- if (next === "background" && wasActive && !pending) {
524
+ if (next === "background" && wasActive && !pending && endOnBackgroundRef.current) {
484
525
  pending = setTimeout(() => {
485
526
  pending = null;
486
527
  if (AppState.currentState !== "active")
package/dist/theme.d.ts CHANGED
@@ -163,6 +163,18 @@ export declare const DESIGN_BUBBLES: ReadonlyArray<DesignBubbles>;
163
163
  export declare const LAUNCHER_SHAPES: ReadonlyArray<LauncherShape>;
164
164
  export declare const ICON_STYLES: ReadonlyArray<IconStyle>;
165
165
  export declare const DESIGN_CHROMES: ReadonlyArray<DesignChrome>;
166
+ export type LogoPlate = "accent" | "white";
167
+ export declare const LOGO_PLATES: ReadonlyArray<LogoPlate>;
168
+ export declare const LOGO_PLATE_WHITE = "#FFFFFF";
169
+ export declare function normaliseLogoPlate(value: unknown): LogoPlate;
170
+ export declare function logoBox(shape: string, size: number): number;
171
+ export declare function logoPlateLook(plate: LogoPlate, colors: Pick<ThemeColors, "accent" | "border">, borderWidth?: number): {
172
+ fill: string;
173
+ outline: {
174
+ width: number;
175
+ color: string;
176
+ } | null;
177
+ };
166
178
  export declare function normaliseHex(value: unknown): string | null;
167
179
  export declare function mixHex(a: string, b: string, t: number): string;
168
180
  export declare function luminanceOf(hex: string): number;