@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.
- package/AGENTS.md +73 -0
- package/CHANGELOG.md +572 -0
- package/INTEGRATION_PROMPT.md +55 -0
- package/LICENSE +21 -0
- package/README.md +923 -0
- package/dist/CenteredModal-C3qQBHsA.d.mts +38 -0
- package/dist/CenteredModal-Cdgns6--.d.ts +38 -0
- package/dist/coachmarks/index.d.mts +352 -0
- package/dist/coachmarks/index.d.ts +352 -0
- package/dist/coachmarks/index.js +944 -0
- package/dist/coachmarks/index.js.map +1 -0
- package/dist/coachmarks/index.mjs +918 -0
- package/dist/coachmarks/index.mjs.map +1 -0
- package/dist/decision-CFvGY6nP.d.ts +228 -0
- package/dist/decision-Cl8OFYzu.d.mts +228 -0
- package/dist/index.d.mts +1390 -0
- package/dist/index.d.ts +1390 -0
- package/dist/index.js +3260 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3186 -0
- package/dist/index.mjs.map +1 -0
- package/dist/questionnaire/index.d.mts +249 -0
- package/dist/questionnaire/index.d.ts +249 -0
- package/dist/questionnaire/index.js +922 -0
- package/dist/questionnaire/index.js.map +1 -0
- package/dist/questionnaire/index.mjs +906 -0
- package/dist/questionnaire/index.mjs.map +1 -0
- package/dist/reviews/index.d.mts +213 -0
- package/dist/reviews/index.d.ts +213 -0
- package/dist/reviews/index.js +1001 -0
- package/dist/reviews/index.js.map +1 -0
- package/dist/reviews/index.mjs +971 -0
- package/dist/reviews/index.mjs.map +1 -0
- package/dist/showcase/index.d.mts +119 -0
- package/dist/showcase/index.d.ts +119 -0
- package/dist/showcase/index.js +660 -0
- package/dist/showcase/index.js.map +1 -0
- package/dist/showcase/index.mjs +651 -0
- package/dist/showcase/index.mjs.map +1 -0
- package/dist/types-BKfpdZzX.d.mts +84 -0
- package/dist/types-BKfpdZzX.d.ts +84 -0
- package/dist/types-CMuOexw0.d.mts +116 -0
- package/dist/types-CMuOexw0.d.ts +116 -0
- package/dist/types-GL_hQ0TN.d.mts +46 -0
- package/dist/types-GL_hQ0TN.d.ts +46 -0
- package/llms.txt +36 -0
- package/metro/index.d.ts +32 -0
- package/metro/index.js +83 -0
- package/package.json +171 -0
- package/src/DemoOnboarding.tsx +133 -0
- package/src/OnboardingFlow.tsx +504 -0
- package/src/WireOnboarding.tsx +248 -0
- package/src/analytics/analyticsEvent.ts +69 -0
- package/src/analytics/reportClientEvent.ts +119 -0
- package/src/analytics/sendPreview.ts +73 -0
- package/src/attribution/attribution.ts +46 -0
- package/src/cards/ChipSelectCard.tsx +265 -0
- package/src/cards/InterstitialCard.tsx +299 -0
- package/src/cards/NumberStepperCard.tsx +121 -0
- package/src/cards/SelectionCard.tsx +270 -0
- package/src/cards/StatusCard.tsx +170 -0
- package/src/cards/TextInputCard.tsx +147 -0
- package/src/cards/index.ts +23 -0
- package/src/coachmarks/CoachmarkOverlayHost.tsx +44 -0
- package/src/coachmarks/CoachmarkProvider.tsx +108 -0
- package/src/coachmarks/GestureHint.tsx +331 -0
- package/src/coachmarks/SpotlightOverlay.tsx +254 -0
- package/src/coachmarks/coachmarkAnchorRegistry.ts +70 -0
- package/src/coachmarks/coachmarkOverlayStore.ts +63 -0
- package/src/coachmarks/index.ts +62 -0
- package/src/coachmarks/runtime.ts +103 -0
- package/src/coachmarks/selectTourSteps.ts +45 -0
- package/src/coachmarks/types.ts +58 -0
- package/src/coachmarks/useCoachmarkAnchor.ts +49 -0
- package/src/coachmarks/useCoachmarkTour.ts +200 -0
- package/src/components/AnimatedSparkle.tsx +101 -0
- package/src/components/Button.tsx +129 -0
- package/src/components/CardHandoff.tsx +212 -0
- package/src/components/CardLayout.tsx +103 -0
- package/src/components/CenteredModal.tsx +184 -0
- package/src/components/CompletionView.tsx +186 -0
- package/src/components/DoneBlock.tsx +37 -0
- package/src/components/ErrorBlock.tsx +42 -0
- package/src/components/Illustration.tsx +36 -0
- package/src/components/LoadingBlock.tsx +182 -0
- package/src/components/LoadingScreen.tsx +35 -0
- package/src/components/OnboardingScaffold.tsx +105 -0
- package/src/components/StepProgress.tsx +85 -0
- package/src/components/loaderChrome.ts +28 -0
- package/src/config/onboardingFlag.ts +39 -0
- package/src/config/wireConfigFromEnv.ts +52 -0
- package/src/device/deviceContext.ts +158 -0
- package/src/features/WireFeaturesProvider.tsx +81 -0
- package/src/features/cache.ts +92 -0
- package/src/features/defaults.ts +67 -0
- package/src/features/fetchWireFeatures.ts +86 -0
- package/src/features/index.ts +39 -0
- package/src/features/types.ts +61 -0
- package/src/features/useWireFeatures.ts +57 -0
- package/src/identity/userIdentity.ts +100 -0
- package/src/illustrations/defaultIllustrations.tsx +99 -0
- package/src/index.ts +159 -0
- package/src/motion/centeredModalMotion.ts +45 -0
- package/src/motion/motionSpec.ts +133 -0
- package/src/motion/useReducedMotion.ts +43 -0
- package/src/questionnaire/QuestionnaireGate.tsx +235 -0
- package/src/questionnaire/decision.ts +63 -0
- package/src/questionnaire/index.ts +54 -0
- package/src/questionnaire/runtime.ts +20 -0
- package/src/questionnaire/transport.ts +83 -0
- package/src/questionnaire/types.ts +182 -0
- package/src/questionnaire/useQuestionnaireGate.ts +159 -0
- package/src/reviews/ReviewGate.tsx +316 -0
- package/src/reviews/ReviewModal.tsx +13 -0
- package/src/reviews/decision.ts +120 -0
- package/src/reviews/equality.ts +54 -0
- package/src/reviews/expo-store-review.d.ts +16 -0
- package/src/reviews/feedbackForm.ts +41 -0
- package/src/reviews/index.ts +78 -0
- package/src/reviews/presentation.ts +29 -0
- package/src/reviews/runtime.ts +45 -0
- package/src/reviews/storeReview.ts +62 -0
- package/src/reviews/transport.ts +89 -0
- package/src/reviews/types.ts +172 -0
- package/src/reviews/useReviewGate.ts +137 -0
- package/src/session/persistedSession.ts +143 -0
- package/src/session-analytics/index.ts +18 -0
- package/src/session-analytics/reportSessionStart.ts +144 -0
- package/src/session-analytics/useSessionStart.ts +114 -0
- package/src/showcase/FeatureShowcase.tsx +233 -0
- package/src/showcase/index.ts +19 -0
- package/src/showcase/selectShowcaseSlides.ts +45 -0
- package/src/showcase/showcaseColors.ts +91 -0
- package/src/showcase/types.ts +74 -0
- package/src/theme/ThemeContext.tsx +38 -0
- package/src/theme/defaultTheme.ts +70 -0
- package/src/theme/themeFromBrand.ts +93 -0
- package/src/theme/types.ts +88 -0
- package/src/theme/typography.ts +46 -0
- package/src/types.ts +215 -0
- package/src/utils/deriveAnswers.ts +43 -0
- 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
|
+
};
|