@wireai/activation 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +73 -0
  2. package/CHANGELOG.md +572 -0
  3. package/INTEGRATION_PROMPT.md +55 -0
  4. package/LICENSE +21 -0
  5. package/README.md +923 -0
  6. package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
  7. package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
  8. package/dist/coachmarks/index.d.mts +352 -0
  9. package/dist/coachmarks/index.d.ts +352 -0
  10. package/dist/coachmarks/index.js +944 -0
  11. package/dist/coachmarks/index.js.map +1 -0
  12. package/dist/coachmarks/index.mjs +918 -0
  13. package/dist/coachmarks/index.mjs.map +1 -0
  14. package/dist/decision-CFvGY6nP.d.ts +228 -0
  15. package/dist/decision-Cl8OFYzu.d.mts +228 -0
  16. package/dist/index.d.mts +1390 -0
  17. package/dist/index.d.ts +1390 -0
  18. package/dist/index.js +3260 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/index.mjs +3186 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/dist/questionnaire/index.d.mts +249 -0
  23. package/dist/questionnaire/index.d.ts +249 -0
  24. package/dist/questionnaire/index.js +922 -0
  25. package/dist/questionnaire/index.js.map +1 -0
  26. package/dist/questionnaire/index.mjs +906 -0
  27. package/dist/questionnaire/index.mjs.map +1 -0
  28. package/dist/reviews/index.d.mts +213 -0
  29. package/dist/reviews/index.d.ts +213 -0
  30. package/dist/reviews/index.js +1001 -0
  31. package/dist/reviews/index.js.map +1 -0
  32. package/dist/reviews/index.mjs +971 -0
  33. package/dist/reviews/index.mjs.map +1 -0
  34. package/dist/showcase/index.d.mts +119 -0
  35. package/dist/showcase/index.d.ts +119 -0
  36. package/dist/showcase/index.js +660 -0
  37. package/dist/showcase/index.js.map +1 -0
  38. package/dist/showcase/index.mjs +651 -0
  39. package/dist/showcase/index.mjs.map +1 -0
  40. package/dist/types-BKfpdZzX.d.mts +84 -0
  41. package/dist/types-BKfpdZzX.d.ts +84 -0
  42. package/dist/types-CMuOexw0.d.mts +116 -0
  43. package/dist/types-CMuOexw0.d.ts +116 -0
  44. package/dist/types-GL_hQ0TN.d.mts +46 -0
  45. package/dist/types-GL_hQ0TN.d.ts +46 -0
  46. package/llms.txt +36 -0
  47. package/metro/index.d.ts +32 -0
  48. package/metro/index.js +83 -0
  49. package/package.json +171 -0
  50. package/src/DemoOnboarding.tsx +133 -0
  51. package/src/OnboardingFlow.tsx +504 -0
  52. package/src/WireOnboarding.tsx +248 -0
  53. package/src/analytics/analyticsEvent.ts +69 -0
  54. package/src/analytics/reportClientEvent.ts +119 -0
  55. package/src/analytics/sendPreview.ts +73 -0
  56. package/src/attribution/attribution.ts +46 -0
  57. package/src/cards/ChipSelectCard.tsx +265 -0
  58. package/src/cards/InterstitialCard.tsx +299 -0
  59. package/src/cards/NumberStepperCard.tsx +121 -0
  60. package/src/cards/SelectionCard.tsx +270 -0
  61. package/src/cards/StatusCard.tsx +170 -0
  62. package/src/cards/TextInputCard.tsx +147 -0
  63. package/src/cards/index.ts +23 -0
  64. package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
  65. package/src/coachmarks/CoachmarkProvider.tsx +108 -0
  66. package/src/coachmarks/GestureHint.tsx +331 -0
  67. package/src/coachmarks/SpotlightOverlay.tsx +254 -0
  68. package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
  69. package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
  70. package/src/coachmarks/index.ts +62 -0
  71. package/src/coachmarks/runtime.ts +103 -0
  72. package/src/coachmarks/selectTourSteps.ts +45 -0
  73. package/src/coachmarks/types.ts +58 -0
  74. package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
  75. package/src/coachmarks/useCoachmarkTour.ts +200 -0
  76. package/src/components/AnimatedSparkle.tsx +101 -0
  77. package/src/components/Button.tsx +129 -0
  78. package/src/components/CardHandoff.tsx +212 -0
  79. package/src/components/CardLayout.tsx +103 -0
  80. package/src/components/CenteredModal.tsx +184 -0
  81. package/src/components/CompletionView.tsx +186 -0
  82. package/src/components/DoneBlock.tsx +37 -0
  83. package/src/components/ErrorBlock.tsx +42 -0
  84. package/src/components/Illustration.tsx +36 -0
  85. package/src/components/LoadingBlock.tsx +182 -0
  86. package/src/components/LoadingScreen.tsx +35 -0
  87. package/src/components/OnboardingScaffold.tsx +105 -0
  88. package/src/components/StepProgress.tsx +85 -0
  89. package/src/components/loaderChrome.ts +28 -0
  90. package/src/config/onboardingFlag.ts +39 -0
  91. package/src/config/wireConfigFromEnv.ts +52 -0
  92. package/src/device/deviceContext.ts +158 -0
  93. package/src/features/WireFeaturesProvider.tsx +81 -0
  94. package/src/features/cache.ts +92 -0
  95. package/src/features/defaults.ts +67 -0
  96. package/src/features/fetchWireFeatures.ts +86 -0
  97. package/src/features/index.ts +39 -0
  98. package/src/features/types.ts +61 -0
  99. package/src/features/useWireFeatures.ts +57 -0
  100. package/src/identity/userIdentity.ts +100 -0
  101. package/src/illustrations/defaultIllustrations.tsx +99 -0
  102. package/src/index.ts +159 -0
  103. package/src/motion/centeredModalMotion.ts +45 -0
  104. package/src/motion/motionSpec.ts +133 -0
  105. package/src/motion/useReducedMotion.ts +43 -0
  106. package/src/questionnaire/QuestionnaireGate.tsx +235 -0
  107. package/src/questionnaire/decision.ts +63 -0
  108. package/src/questionnaire/index.ts +54 -0
  109. package/src/questionnaire/runtime.ts +20 -0
  110. package/src/questionnaire/transport.ts +83 -0
  111. package/src/questionnaire/types.ts +182 -0
  112. package/src/questionnaire/useQuestionnaireGate.ts +159 -0
  113. package/src/reviews/ReviewGate.tsx +316 -0
  114. package/src/reviews/ReviewModal.tsx +13 -0
  115. package/src/reviews/decision.ts +120 -0
  116. package/src/reviews/equality.ts +54 -0
  117. package/src/reviews/expo-store-review.d.ts +16 -0
  118. package/src/reviews/feedbackForm.ts +41 -0
  119. package/src/reviews/index.ts +78 -0
  120. package/src/reviews/presentation.ts +29 -0
  121. package/src/reviews/runtime.ts +45 -0
  122. package/src/reviews/storeReview.ts +62 -0
  123. package/src/reviews/transport.ts +89 -0
  124. package/src/reviews/types.ts +172 -0
  125. package/src/reviews/useReviewGate.ts +137 -0
  126. package/src/session/persistedSession.ts +143 -0
  127. package/src/session-analytics/index.ts +18 -0
  128. package/src/session-analytics/reportSessionStart.ts +144 -0
  129. package/src/session-analytics/useSessionStart.ts +114 -0
  130. package/src/showcase/FeatureShowcase.tsx +233 -0
  131. package/src/showcase/index.ts +19 -0
  132. package/src/showcase/selectShowcaseSlides.ts +45 -0
  133. package/src/showcase/showcaseColors.ts +91 -0
  134. package/src/showcase/types.ts +74 -0
  135. package/src/theme/ThemeContext.tsx +38 -0
  136. package/src/theme/defaultTheme.ts +70 -0
  137. package/src/theme/themeFromBrand.ts +93 -0
  138. package/src/theme/types.ts +88 -0
  139. package/src/theme/typography.ts +46 -0
  140. package/src/types.ts +215 -0
  141. package/src/utils/deriveAnswers.ts +43 -0
  142. package/src/utils/readProgress.ts +33 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * OnboardingFlow — the inner engine. Mirrors the proven production-app usage of the SDK
3
+ * (useWireAIThread + useWireAIAction + ComponentRenderer) and layers on the kit's
4
+ * shell, progress, and per-step validators.
5
+ *
6
+ * Lifecycle per turn:
7
+ * 1. Auto-send `startMessage` once → backend returns the first card.
8
+ * 2. Show LoadingBlock until the first card arrives AND between every turn
9
+ * (while `isLoading`); on error/timeout, auto-retry up to `maxRetries`, then
10
+ * degrade to the host's static `fallbackFlow` (preferred), else `onError`, else
11
+ * an inline ErrorBlock — so a generation failure never breaks onboarding.
12
+ * 3. Render the latest card inside OnboardingScaffold. The progress bar follows
13
+ * an asymptotic curve of the step index — it never reveals a total.
14
+ * 4. Before any answer is sent, if the current screen's question key has a
15
+ * validator, run it; on failure show the error inline and DON'T advance.
16
+ * 5. On the terminal StatusCard, show CompletionView (the recap of what the app
17
+ * will do for the user) and fire onComplete only when they tap its CTA.
18
+ *
19
+ * Lifecycle events (started / per-turn / error) are surfaced via `onEvent` so a
20
+ * host can keep its analytics even though the kit owns the thread loop.
21
+ */
22
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import {
24
+ ComponentRenderer,
25
+ useWireAIAction,
26
+ useWireAIThread,
27
+ type Message,
28
+ } from "wireai-rn";
29
+ import { OnboardingScaffold } from "./components/OnboardingScaffold";
30
+ import { LoadingBlock } from "./components/LoadingBlock";
31
+ import { LoadingScreen } from "./components/LoadingScreen";
32
+ import { ErrorBlock } from "./components/ErrorBlock";
33
+ import { CompletionView } from "./components/CompletionView";
34
+ import { CardHandoff } from "./components/CardHandoff";
35
+ import { deriveAnswers } from "./utils/deriveAnswers";
36
+ import { readProgress } from "./utils/readProgress";
37
+ import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
38
+ import { sendPreview } from "./analytics/sendPreview";
39
+ import type { DeviceContext } from "./device/deviceContext";
40
+ import type { OnboardingCopy, OnboardingEvent, OnboardingResult, StepValidator } from "./types";
41
+
42
+ type OnboardingFlowProps = {
43
+ validators?: Record<string, StepValidator>;
44
+ onComplete: (result: OnboardingResult) => void;
45
+ onSkip?: () => void;
46
+ onError?: (err: unknown) => void;
47
+ onEvent?: (event: OnboardingEvent) => void;
48
+ copy?: Partial<OnboardingCopy>;
49
+ approxScreens?: number;
50
+ startMessage: string;
51
+ startTimeoutMs: number;
52
+ /** Static onboarding rendered when the AI flow fails and retries are exhausted. */
53
+ fallbackFlow?: React.ReactNode;
54
+ /** Consecutive failures to auto-retry before degrading. */
55
+ maxRetries: number;
56
+ /** Where to POST device-only client events (`dropped`). Omit to disable reporting. */
57
+ reportTarget?: ClientEventTarget;
58
+ /** Session id shared with the backend (A2A contextId), so `dropped` correlates. */
59
+ sessionId: string;
60
+ /**
61
+ * Device snapshot + host-injected user context, attached to every client event so the
62
+ * backend can segment the funnel by device. Old servers ignore the extra fields.
63
+ */
64
+ clientContext?: {
65
+ device?: DeviceContext;
66
+ userContext?: Record<string, string | number | boolean>;
67
+ /** The host's opaque user id (see WireOnboarding `userId`), rides `dropped`/`client_fallback`. */
68
+ userId?: string;
69
+ };
70
+ /**
71
+ * True when `sessionId` was restored from persisted storage (app killed mid-flow).
72
+ * The kickoff then sends the skip sentinel instead of `startMessage` — the server
73
+ * would record the literal start text as the ANSWER to the pending question, while
74
+ * the sentinel advances one card recording nothing — and fires `resumed` instead of
75
+ * `started` so host funnels don't double-count the session.
76
+ */
77
+ resumed?: boolean;
78
+ };
79
+
80
+ // The kit's per-question Skip sends this sentinel; the backend skips ONE question
81
+ // (advances one card, no answer recorded). Must match the backend's SKIP_ONE_SENTINEL.
82
+ const SKIP_ONE_SENTINEL = "__wireai_skip__";
83
+
84
+ // Callbacks that carry the user's ANSWER (and so must pass validation first).
85
+ const ANSWER_CALLBACKS = ["onSubmit", "onSelect", "onConfirm", "onPress"] as const;
86
+
87
+ export const DEFAULT_COPY: OnboardingCopy = {
88
+ startingTitle: "Getting started…",
89
+ startingHint: "Setting up your onboarding",
90
+ restoringTitle: "Picking up where you left off…",
91
+ restoringHint: "Restoring your session",
92
+ thinkingTitle: "Personalizing…",
93
+ thinkingHint: "Tailoring your next question",
94
+ checkingTitle: "Checking…",
95
+ checkingHint: "One moment",
96
+ completeTitle: "You're all set",
97
+ completeCta: "Let's go",
98
+ };
99
+
100
+ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
101
+ validators,
102
+ onComplete,
103
+ onError,
104
+ onEvent,
105
+ copy,
106
+ approxScreens,
107
+ startMessage,
108
+ startTimeoutMs,
109
+ fallbackFlow,
110
+ maxRetries,
111
+ reportTarget,
112
+ sessionId,
113
+ resumed = false,
114
+ clientContext,
115
+ }) => {
116
+ const { messages, error, isLoading, sendMessage, reset } = useWireAIThread();
117
+ const makeActions = useWireAIAction(sendMessage);
118
+
119
+ const c = useMemo<OnboardingCopy>(() => ({ ...DEFAULT_COPY, ...copy }), [copy]);
120
+
121
+ // Hold the latest event/error callbacks in refs so the lifecycle effects below
122
+ // stay stable (and fire exactly once) regardless of the host re-passing closures.
123
+ const onEventRef = useRef(onEvent);
124
+ onEventRef.current = onEvent;
125
+ const onErrorRef = useRef(onError);
126
+ onErrorRef.current = onError;
127
+
128
+ // Client-event reporting refs, captured so the unmount cleanup (which runs once, with a
129
+ // stale closure) reads current values.
130
+ const reportTargetRef = useRef(reportTarget);
131
+ reportTargetRef.current = reportTarget;
132
+ const sessionIdRef = useRef(sessionId);
133
+ sessionIdRef.current = sessionId;
134
+ // Device + user context, held in a ref so the mount-once unmount cleanup (the `dropped`
135
+ // reporter, which runs with a stale closure) reads the current snapshot.
136
+ const clientContextRef = useRef(clientContext);
137
+ clientContextRef.current = clientContext;
138
+ // Last 0-based screen index we showed the user (matches the server's `screen_shown`
139
+ // index). Used as the `dropped` event's screen_index. -1 = nothing shown yet.
140
+ const lastScreenIndexRef = useRef(-1);
141
+ // Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
142
+ const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
143
+
144
+ const started = useRef(false);
145
+ const finished = useRef(false);
146
+ const errored = useRef(false);
147
+ const attempts = useRef(0);
148
+ // Mirror of the `degraded` state for the unmount cleanup: a degrade to the static
149
+ // fallback is NOT a drop, so `dropped` must not fire after it.
150
+ const degradedRef = useRef(false);
151
+ const [degraded, setDegraded] = useState(false);
152
+ const [timedOut, setTimedOut] = useState(false);
153
+ const [validationError, setValidationError] = useState<string | undefined>();
154
+ const [validating, setValidating] = useState(false);
155
+
156
+ const lastCard = useMemo<Message | undefined>(
157
+ () => [...messages].reverse().find((m) => m.role === "assistant" && m.response?.action === "render"),
158
+ [messages],
159
+ );
160
+
161
+ // Local fallback counter — how many distinct rendered cards we've seen.
162
+ const renderedCount = useMemo(
163
+ () => messages.filter((m) => m.role === "assistant" && m.response?.action === "render").length,
164
+ [messages],
165
+ );
166
+
167
+ const progress = useMemo(() => readProgress(lastCard?.response), [lastCard]);
168
+
169
+ // 1. Auto-start: the backend treats the first inbound message as "begin".
170
+ // On a RESUMED session (persisted id restored after an app kill) the kickoff is
171
+ // the skip sentinel instead: the server has this session mid-flow with a pending
172
+ // question, and would record `startMessage`'s literal text as its ANSWER — the
173
+ // sentinel advances one card recording nothing (a `screen_skipped`). On a session
174
+ // the server no longer knows (restart/expiry), the sentinel degrades cleanly to a
175
+ // normal kickoff under the same id. Retries re-send the same kickoff.
176
+ useEffect(() => {
177
+ if (started.current) return;
178
+ started.current = true;
179
+ sendMessage(resumed ? SKIP_ONE_SENTINEL : startMessage);
180
+ // Surface the session id (the A2A contextId) on the start event so a host can capture it
181
+ // and bind a user AFTER the flow via `identifyOnboarding({ contextId })` — completion clears
182
+ // the persisted session, so the in-flight event is the reliable post-flow handle. sessionId
183
+ // is stable for the whole session (minted before this subtree mounts), so this stays mount-once.
184
+ onEventRef.current?.(
185
+ resumed
186
+ ? { type: "resumed", contextId: sessionId }
187
+ : { type: "started", contextId: sessionId },
188
+ );
189
+ }, [sendMessage, startMessage, resumed, sessionId]);
190
+
191
+ // 2. Startup watchdog.
192
+ useEffect(() => {
193
+ if (lastCard) return;
194
+ const timer = setTimeout(() => setTimedOut(true), startTimeoutMs);
195
+ return () => clearTimeout(timer);
196
+ }, [lastCard, startTimeoutMs]);
197
+
198
+ // A new card arrived → clear any prior inline validation error + emit a turn event.
199
+ useEffect(() => {
200
+ setValidationError(undefined);
201
+ if (!lastCard?.id) return;
202
+ const step = progress.step ?? renderedCount;
203
+ // Remember the latest screen we showed (0-based) for a possible `dropped` event.
204
+ lastScreenIndexRef.current = Math.max(0, step - 1);
205
+ onEventRef.current?.({
206
+ type: "turn",
207
+ step,
208
+ component: lastCard.response?.component,
209
+ });
210
+ // Prefetch: an InterstitialCard's answer is always "continue", so we can warm the next
211
+ // turn's cache during the momentum window — by the time the user taps Continue the following
212
+ // card is already generated. The preview MUST use the exact text the real Continue tap sends,
213
+ // or the backend's message-keyed preview cache misses. The card's onSubmit("continue") is
214
+ // wrapped by the SDK as `My answer is: ${JSON.stringify(value)}`, so mirror that here.
215
+ // Fire-and-forget; a failed/missed preview just means the real turn generates normally.
216
+ if (lastCard.response?.component === "InterstitialCard") {
217
+ sendPreview(reportTargetRef.current, {
218
+ sessionId: sessionIdRef.current,
219
+ userMessage: `My answer is: ${JSON.stringify("continue")}`,
220
+ });
221
+ }
222
+ // eslint-disable-next-line react-hooks/exhaustive-deps
223
+ }, [lastCard?.id]);
224
+
225
+ // `dropped`: the user abandoned onboarding. No backend request fires on unmount, so the
226
+ // server can't see this — the client must report it. Fire ONLY on a true unmount that
227
+ // did NOT complete (`onComplete` never ran) and did NOT degrade to the static fallback
228
+ // (a degrade is a reliability fallback, not a user drop). Refs keep this effect mount-once.
229
+ useEffect(() => {
230
+ return () => {
231
+ if (finished.current || degradedRef.current) return;
232
+ reportClientEvent(reportTargetRef.current, {
233
+ event_type: "dropped",
234
+ session_id: sessionIdRef.current,
235
+ screen_index: lastScreenIndexRef.current >= 0 ? lastScreenIndexRef.current : undefined,
236
+ device: clientContextRef.current?.device,
237
+ user_context: clientContextRef.current?.userContext,
238
+ user_id: clientContextRef.current?.userId,
239
+ });
240
+ };
241
+ }, []);
242
+
243
+ // Silently restart the thread from turn one (shared by auto-retry + manual retry).
244
+ // Does NOT clear the attempt counter or the degraded flag — the caller decides that.
245
+ const restartThread = useCallback(() => {
246
+ setTimedOut(false);
247
+ started.current = false;
248
+ errored.current = false;
249
+ reset();
250
+ }, [reset]);
251
+
252
+ // 3. Error/timeout → auto-retry a transient blip, then degrade to the static flow.
253
+ // Each failure: log `error`; if we have retries left, restart the thread silently
254
+ // (`retry`); once exhausted, mark `degraded` so the render falls through to the
255
+ // host's `fallbackFlow`/`onError` (`fallback`). Counter is a ref → survives the
256
+ // reset()-driven re-renders. This is the "AI fails → static onboarding, never a
257
+ // broken screen" guarantee.
258
+ useEffect(() => {
259
+ if (errored.current) return;
260
+ if (!error && !timedOut) return;
261
+ errored.current = true;
262
+ const reason: "backend" | "timeout" = error ? "backend" : "timeout";
263
+ onEventRef.current?.({ type: "error", reason });
264
+ attempts.current += 1;
265
+ if (attempts.current <= maxRetries) {
266
+ onEventRef.current?.({ type: "retry", reason, attempt: attempts.current });
267
+ restartThread();
268
+ } else {
269
+ onEventRef.current?.({ type: "fallback", reason });
270
+ // Report the degrade to the backend so the dashboard's fallback-rate reflects it.
271
+ // Server-side `llm_fallback` fires per single degraded turn; this `client_fallback`
272
+ // captures the whole-flow case (AI gave up, the user got static onboarding). The server
273
+ // ingests it idempotently and back-fills a `session_started` if it never saw the session,
274
+ // so even a device that dropped straight to its static manifest shows as a counted
275
+ // fallback session. This is the ONE fallback signal — hosts must NOT also report it.
276
+ reportClientEvent(reportTargetRef.current, {
277
+ event_type: "client_fallback",
278
+ session_id: sessionIdRef.current,
279
+ screen_index: lastScreenIndexRef.current >= 0 ? lastScreenIndexRef.current : undefined,
280
+ meta: JSON.stringify({ reason }),
281
+ device: clientContextRef.current?.device,
282
+ user_context: clientContextRef.current?.userContext,
283
+ user_id: clientContextRef.current?.userId,
284
+ });
285
+ degradedRef.current = true;
286
+ setDegraded(true);
287
+ onErrorRef.current?.(error ?? new Error("Onboarding timed out"));
288
+ }
289
+ // eslint-disable-next-line react-hooks/exhaustive-deps
290
+ }, [error, timedOut, maxRetries]);
291
+
292
+ // 5. Completion: the terminal StatusCard is rendered as CompletionView; we fire
293
+ // onComplete only when the user taps its CTA (guarded so it fires once).
294
+ const handleFinish = useCallback(() => {
295
+ if (finished.current) return;
296
+ finished.current = true;
297
+ onComplete({ answers: deriveAnswers(messages), raw: messages });
298
+ }, [messages, onComplete]);
299
+
300
+ // Manual retry (the no-fallback ErrorBlock button): clear the degrade + counter so
301
+ // the user gets a fresh set of attempts.
302
+ const handleRetry = useCallback(() => {
303
+ attempts.current = 0;
304
+ degradedRef.current = false;
305
+ setDegraded(false);
306
+ restartThread();
307
+ }, [restartThread]);
308
+
309
+ // Per-question Skip: tell the backend to skip THIS question only and advance to the
310
+ // next card (no answer recorded) — distinct from finishing the flow. Rendered only
311
+ // when the current card is `skippable` (see the scaffold below).
312
+ //
313
+ // NOTE: we deliberately do NOT report a client `screen_skipped` here. The backend already
314
+ // emits `screen_skipped` (correctly correlated by its own contextId, with screen_index +
315
+ // question_key) when it processes this SKIP_ONE_SENTINEL — see the server engine. A client
316
+ // emission would double-count. The reporter supports `screen_skipped` for clients whose skip
317
+ // never reaches the server, which is not the case for this kit.
318
+ const skipQuestion = useCallback(() => {
319
+ if (isLoading) return;
320
+ sendMessage(SKIP_ONE_SENTINEL);
321
+ }, [isLoading, sendMessage]);
322
+
323
+ // Prefetch on SELECT — the fix for "the first AI question is always the slowest". Q1 is a
324
+ // deterministic base card (instant); Q2 is the first LLM turn, and neither cache protects it
325
+ // (flow-cache misses a novel answer combo, the provider prompt-cache is cold on the first
326
+ // call). Choice cards hold their selection LOCALLY and only commit on Continue, so there's a
327
+ // real gap we can use: as the user picks, fire a debounced `message/preview` keyed on the
328
+ // EXACT text the Continue tap will send (`I selected: ${JSON.stringify(value)}`), so the
329
+ // backend generates Q2 during the pick→Continue window. By the time they tap Continue it's
330
+ // already cached → the slow question returns instantly. Fire-and-forget: if they change the
331
+ // selection after the last preview, the real turn just generates normally (never worse than
332
+ // today). Trailing-edge debounced so rapid multi-select toggles warm at most once.
333
+ const previewOnSelect = useCallback((value: string | string[]) => {
334
+ const target = reportTargetRef.current;
335
+ if (!target?.serverUrl) return;
336
+ if (Array.isArray(value) ? value.length === 0 : !value) return;
337
+ if (previewTimer.current) clearTimeout(previewTimer.current);
338
+ previewTimer.current = setTimeout(() => {
339
+ sendPreview(target, {
340
+ sessionId: sessionIdRef.current,
341
+ userMessage: `I selected: ${JSON.stringify(value)}`,
342
+ });
343
+ }, 300);
344
+ }, []);
345
+
346
+ // Cancel any pending preview on unmount so a fired-but-unsent warm can't outlive the flow.
347
+ useEffect(() => () => {
348
+ if (previewTimer.current) clearTimeout(previewTimer.current);
349
+ }, []);
350
+
351
+ // 4. Wrap the SDK's action callbacks so an answer is validated before it's sent.
352
+ const wrappedActions = useCallback(
353
+ (messageId: string) => {
354
+ const base = makeActions(messageId);
355
+ const key = progress.key;
356
+ const validator = key ? validators?.[key] : undefined;
357
+ if (!validator) return base;
358
+
359
+ const out: Record<string, (...args: unknown[]) => void> = { ...base };
360
+ for (const name of ANSWER_CALLBACKS) {
361
+ const original = base[name];
362
+ if (!original) continue;
363
+ out[name] = (...args: unknown[]) => {
364
+ const value = args[0];
365
+ // Only string answers are validatable (e.g. username). Pass others through.
366
+ if (typeof value !== "string") return original(...args);
367
+ setValidating(true);
368
+ validator(value)
369
+ .then((res) => {
370
+ if (res.ok) {
371
+ setValidationError(undefined);
372
+ original(...args);
373
+ } else {
374
+ setValidationError(res.error ?? "That value isn't available.");
375
+ }
376
+ })
377
+ .catch(() => {
378
+ // A validator failure must never block the user — let them proceed.
379
+ original(...args);
380
+ })
381
+ .finally(() => setValidating(false));
382
+ };
383
+ }
384
+ return out;
385
+ },
386
+ [makeActions, progress.key, validators],
387
+ );
388
+
389
+ // ── Render ────────────────────────────────────────────────────────────────
390
+ // Degraded (retries exhausted): render the host's static onboarding in place —
391
+ // the "AI failed → your static flow, never a broken screen" guarantee. If no
392
+ // static flow was supplied, hand off to onError (host navigates away), else the
393
+ // inline retry screen as a last resort.
394
+ if (degraded) {
395
+ if (fallbackFlow !== undefined) return <>{fallbackFlow}</>;
396
+ if (onError) {
397
+ return <LoadingScreen title={c.startingTitle} hint={c.startingHint} />;
398
+ }
399
+ return (
400
+ <ErrorBlock
401
+ onRetry={handleRetry}
402
+ message={
403
+ timedOut
404
+ ? "This is taking longer than expected. Try again?"
405
+ : "We couldn't reach the onboarding service. Try again?"
406
+ }
407
+ />
408
+ );
409
+ }
410
+
411
+ // Errored but not yet degraded → an auto-retry is in flight. Show a neutral loader
412
+ // so a transient blip never flashes an error screen.
413
+ if (error || timedOut) {
414
+ return <LoadingScreen title={c.startingTitle} hint={c.startingHint} />;
415
+ }
416
+
417
+ const response = lastCard?.response;
418
+
419
+ // No card yet → the first "thinking" screen.
420
+ if (!lastCard || !response) {
421
+ return <LoadingScreen title={c.startingTitle} hint={c.startingHint} />;
422
+ }
423
+
424
+ // Step index drives the progress bar. A total (never shown) only paces the fill:
425
+ // prefer a backend-supplied total, else the host's approximate prop, else asymptotic.
426
+ const step = progress.step ?? renderedCount;
427
+ const total = progress.total ?? approxScreens;
428
+ const isTerminal = response.action === "render" && response.component === "StatusCard";
429
+
430
+ // Terminal recap: show what the app will now do for the user (backend-authored),
431
+ // and finish only when they tap the CTA.
432
+ if (isTerminal) {
433
+ const props = (response.props ?? {}) as {
434
+ title?: string;
435
+ message?: string;
436
+ ctaLabel?: string;
437
+ };
438
+ return (
439
+ <OnboardingScaffold step={step} complete approxScreens={total}>
440
+ <CompletionView
441
+ title={props.title ?? c.completeTitle}
442
+ message={props.message}
443
+ ctaLabel={props.ctaLabel ?? c.completeCta}
444
+ onContinue={handleFinish}
445
+ />
446
+ </OnboardingScaffold>
447
+ );
448
+ }
449
+
450
+ const overrides: Record<string, unknown> = {
451
+ ...wrappedActions(lastCard.id),
452
+ // Inject the inline error onto whichever card supports it (TextInputCard).
453
+ validationError,
454
+ // Choice cards (Selection/ChipSelect) call this as the live selection changes, so the flow
455
+ // can prefetch the next turn before Continue. Non-committing; ignored by cards that don't use it.
456
+ onSelectionChange: previewOnSelect,
457
+ };
458
+
459
+ return (
460
+ <OnboardingScaffold
461
+ step={step}
462
+ onSkip={progress.skippable ? skipQuestion : undefined}
463
+ approxScreens={total}
464
+ >
465
+ {/* CardHandoff pairs the exit with the enter (the design's "cards hand off,
466
+ they don't cut"): the answered card leaves left while the next arrives
467
+ from the right; loader states enter/exit as a short fade. transitionKey
468
+ identifies WHAT is on screen — loader states get a stable key per state,
469
+ cards key on the message id. */}
470
+ <CardHandoff
471
+ transitionKey={validating ? "loader:checking" : isLoading ? "loader:thinking" : lastCard.id}
472
+ variant={
473
+ validating || isLoading
474
+ ? "fade"
475
+ : lastCard.response?.component === "InterstitialCard"
476
+ ? "spring"
477
+ : "slide"
478
+ }
479
+ >
480
+ {validating ? (
481
+ <LoadingBlock title={c.checkingTitle} hint={c.checkingHint} />
482
+ ) : isLoading ? (
483
+ // Between turns: the answer was sent and we're waiting on the next card.
484
+ // Show the "thinking" loader instead of leaving the old card on screen.
485
+ <LoadingBlock title={c.thinkingTitle} hint={c.thinkingHint} />
486
+ ) : (
487
+ // key={lastCard.id} is load-bearing: cards (SelectionCard/ChipSelectCard)
488
+ // hold internal `selected`/`submitted` state, and React reuses a component
489
+ // instance across turns when the rendered type is identical (e.g. two
490
+ // SelectionCards in a row). Without a per-turn key the second card inherits
491
+ // the first's `submitted=true` and silently rejects all taps. Keying on the
492
+ // message id forces a fresh mount every turn. (CardHandoff's transitionKey
493
+ // shares the id, but this renderer key stays the state-isolation guarantee.)
494
+ <ComponentRenderer
495
+ key={lastCard.id}
496
+ messageId={lastCard.id}
497
+ response={response}
498
+ callbackOverrides={overrides as never}
499
+ />
500
+ )}
501
+ </CardHandoff>
502
+ </OnboardingScaffold>
503
+ );
504
+ };