@wireai/activation 0.13.0 → 0.13.3
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 +7 -4
- package/CHANGELOG.md +341 -4
- package/README.md +98 -2
- package/dist/analytics/index.d.mts +4 -4
- package/dist/analytics/index.d.ts +4 -4
- package/dist/analytics/index.js +164 -471
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +162 -469
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +14 -1
- package/dist/coachmarks/index.d.ts +14 -1
- package/dist/coachmarks/index.js +58 -14
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +58 -14
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
- package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
- package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
- package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
- package/dist/index.d.mts +218 -4
- package/dist/index.d.ts +218 -4
- package/dist/index.js +1104 -888
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +887 -692
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +42 -10
- package/dist/questionnaire/index.d.ts +42 -10
- package/dist/questionnaire/index.js +45 -13
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +45 -13
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +4 -4
- package/dist/reviews/index.d.ts +4 -4
- package/dist/reviews/index.js +39 -8
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +39 -8
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +7 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +7 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
- package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
- package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
- package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
- package/package.json +4 -1
- package/src/OnboardingFlow.tsx +175 -15
- package/src/WireOnboarding.tsx +70 -8
- package/src/activation/wireActivation.ts +3 -3
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +2 -2
- package/src/analytics/eventQueue.ts +85 -20
- package/src/analytics/reportClientEvent.ts +68 -12
- package/src/cards/CardGridSelectCard.tsx +1 -1
- package/src/cards/ChipSelectCard.tsx +2 -2
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/SelectionCard.tsx +1 -1
- package/src/cards/index.ts +7 -0
- package/src/coachmarks/runtime.ts +62 -2
- package/src/coachmarks/useCoachmarkTour.ts +62 -14
- package/src/context/deviceId.ts +4 -4
- package/src/features/WireFeaturesProvider.tsx +4 -1
- package/src/identity/userIdentity.ts +1 -1
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +38 -0
- package/src/permissions/index.ts +64 -0
- package/src/permissions/permissionCopy.ts +87 -0
- package/src/permissions/permissionEvents.ts +76 -0
- package/src/permissions/permissionMemory.ts +88 -0
- package/src/permissions/placement.ts +88 -0
- package/src/permissions/types.ts +131 -0
- package/src/questionnaire/QuestionnaireGate.tsx +90 -10
- package/src/questionnaire/index.ts +9 -1
- package/src/questionnaire/transport.ts +46 -11
- package/src/reviews/ReviewGate.tsx +61 -6
- package/src/reviews/index.ts +10 -1
- package/src/reviews/transport.ts +58 -12
- package/src/session/persistedSession.ts +10 -3
- package/src/session-analytics/useLifecycleEvents.ts +1 -1
- package/src/theme/ThemeContext.tsx +42 -1
- package/src/types.ts +66 -4
- package/src/utils/submitResult.ts +39 -0
|
@@ -2,6 +2,46 @@ import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
|
|
|
2
2
|
import { C as CoachmarkStorage } from './types-BcmagF6K.mjs';
|
|
3
3
|
import { W as WireFeatures, a as WireFeaturesConfig } from './types-CKFhyrMu.mjs';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* submitResult — the ONE delivery verdict every user-payload POST in this kit reports back.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT IS SHARED, AND WHY IT IS NOT A BOOLEAN. Two surfaces post a payload the user typed —
|
|
9
|
+
* `submitReview` (`reviews/transport.ts`) and `submitQuestionnaireResponse`
|
|
10
|
+
* (`questionnaire/transport.ts`) — and NEITHER has a persisted queue behind it (unlike
|
|
11
|
+
* `analytics/eventQueue`, with its `maxSize`/`maxRetries`/backoff). Both sit behind a permanent
|
|
12
|
+
* once-gate: the host writes `wire_review_<id>_seen` / `wire_questionnaire_<id>_<ver>_seen` when the
|
|
13
|
+
* gate resolves, so a payload that dies in the socket is lost AND the user is never asked again.
|
|
14
|
+
* Reading the fate of the request is the only thing that lets a caller keep an undelivered payload
|
|
15
|
+
* recoverable, so the two surfaces must answer the same question in the same words.
|
|
16
|
+
*
|
|
17
|
+
* • `accepted` — the server answered 2xx. Stored.
|
|
18
|
+
* • `rejected` — the server ANSWERED, non-2xx. It was reached; it either stored the payload and
|
|
19
|
+
* lost the ack (a gateway 502 after the write commits) or deliberately refused it.
|
|
20
|
+
* Either way the server owns the outcome and a re-post risks a SECOND row.
|
|
21
|
+
* • `unsent` — nothing came back at all: no target, a missing/throwing `fetch`, a rejected
|
|
22
|
+
* request. **This is the ONLY outcome a caller may retry on.**
|
|
23
|
+
*
|
|
24
|
+
* ── WHY THREE VALUES AND NOT TWO (pinned here so the defect cannot move surface) ───────────────
|
|
25
|
+
*
|
|
26
|
+
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
|
+
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
+
* treats `false` as "still owed" and re-posts. But the server mints its own row id
|
|
29
|
+
* (`create_review` / `_new_id()`), and neither `CreateReviewRequest` nor the questionnaire response
|
|
30
|
+
* body carries a client id, so there is NO idempotency key on the wire: a 502 returned AFTER the
|
|
31
|
+
* insert commits means the re-post writes a SECOND row. A response of any status proves the server
|
|
32
|
+
* was reached, and that is a different question from whether it liked the payload.
|
|
33
|
+
*
|
|
34
|
+
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
|
+
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
+
* device. Retrying only on `unsent` is therefore SAFER, not SAFE. Closing that window needs a
|
|
39
|
+
* CLIENT-MINTED IDEMPOTENCY KEY the server upserts on, which is a server change and not something
|
|
40
|
+
* the kit can fake. Until it exists, prefer losing a row over inventing one: a lost detractor is a
|
|
41
|
+
* gap in the data, a duplicated one is a lie in the data.
|
|
42
|
+
*/
|
|
43
|
+
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
44
|
+
|
|
5
45
|
/**
|
|
6
46
|
* The firing verdict — the SAME contract on both sides of the seam. `decideReview`
|
|
7
47
|
* turns local rules into one of these; a server `{fire, reason}` (from
|
|
@@ -161,4 +201,4 @@ interface ReviewGateController {
|
|
|
161
201
|
markResolved: () => void;
|
|
162
202
|
}
|
|
163
203
|
|
|
164
|
-
export type { ReviewGateProps as R, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
|
|
204
|
+
export type { ReviewGateProps as R, SubmitResult as S, UseReviewGateOptions as U, ReviewPresentation as a, ReviewGateController as b, ReviewDecision as c, ReviewStoreConfig as d, RatingRoute as e, ReviewConfig as f, ReviewDecisionResponse as g, ReviewGateEvent as h, ReviewGateEventName as i, ReviewSubmission as j, ReviewTarget as k };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
|
@@ -137,8 +137,11 @@
|
|
|
137
137
|
"scripts": {
|
|
138
138
|
"build": "tsup",
|
|
139
139
|
"prepack": "npm run build",
|
|
140
|
+
"prepublishOnly": "npm run build && npm run test:canary",
|
|
140
141
|
"typecheck": "tsc --noEmit",
|
|
142
|
+
"pretest": "npm run build",
|
|
141
143
|
"test": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"src/**/*.test.ts\" \"test/canary/**/*.test.tsx\"",
|
|
144
|
+
"test:canary": "node --test --experimental-strip-types --import ./test/register-ts-resolver.mjs \"test/canary/**/*.test.tsx\"",
|
|
142
145
|
"pack:tarball": "npm pack"
|
|
143
146
|
},
|
|
144
147
|
"peerDependencies": {
|
package/src/OnboardingFlow.tsx
CHANGED
|
@@ -36,6 +36,17 @@ import { deriveAnswers } from "./utils/deriveAnswers";
|
|
|
36
36
|
import { readProgress } from "./utils/readProgress";
|
|
37
37
|
import { reportClientEvent, type ClientEventTarget } from "./analytics/reportClientEvent";
|
|
38
38
|
import { sendPreview } from "./analytics/sendPreview";
|
|
39
|
+
import { PermissionCardView, PERMISSION_CARD_NAME } from "./cards/PermissionCard";
|
|
40
|
+
import { permissionEventName, permissionEventProps } from "./permissions/permissionEvents";
|
|
41
|
+
import { selectDuePermissionScreen } from "./permissions/placement";
|
|
42
|
+
import { resolvePermissionCopy } from "./permissions/permissionCopy";
|
|
43
|
+
import type {
|
|
44
|
+
PermissionScreenConfig,
|
|
45
|
+
PermissionStage,
|
|
46
|
+
WirePermissionKind,
|
|
47
|
+
WirePermissionOutcome,
|
|
48
|
+
WirePermissionStatus,
|
|
49
|
+
} from "./permissions/types";
|
|
39
50
|
import type { DeviceContext } from "./device/deviceContext";
|
|
40
51
|
import type { OnboardingCopy, OnboardingEvent, OnboardingResult, StepValidator } from "./types";
|
|
41
52
|
|
|
@@ -75,6 +86,25 @@ type OnboardingFlowProps = {
|
|
|
75
86
|
* `started` so host funnels don't double-count the session.
|
|
76
87
|
*/
|
|
77
88
|
resumed?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Host-declared permission screens injected into this server-driven stream. See
|
|
91
|
+
* `permissions/types.ts`: the OS dialog is only ever reached from the primary tap.
|
|
92
|
+
*/
|
|
93
|
+
permissionScreens?: PermissionScreenConfig[];
|
|
94
|
+
/**
|
|
95
|
+
* Screen ids already settled in THIS session, restored from `storage` by WireOnboarding so an
|
|
96
|
+
* app kill mid-flow does not re-ask. Empty (the default) means "nothing settled yet".
|
|
97
|
+
*/
|
|
98
|
+
settledPermissions?: readonly string[];
|
|
99
|
+
/**
|
|
100
|
+
* True while that restore is still in flight. Permission screens are suppressed until it lands,
|
|
101
|
+
* because showing one before the record is read is exactly the re-ask the record prevents. The
|
|
102
|
+
* read is short (one storage get, timeout-capped) and cannot outlive the first card's round trip
|
|
103
|
+
* in practice, so this normally never shows up as a delay.
|
|
104
|
+
*/
|
|
105
|
+
permissionsPending?: boolean;
|
|
106
|
+
/** Persist one settled screen id (fire-and-forget; WireOnboarding owns the storage). */
|
|
107
|
+
onPermissionSettled?: (id: string, outcome: WirePermissionOutcome) => void;
|
|
78
108
|
};
|
|
79
109
|
|
|
80
110
|
// The kit's per-question Skip sends this sentinel; the backend skips ONE question
|
|
@@ -112,6 +142,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
112
142
|
sessionId,
|
|
113
143
|
resumed = false,
|
|
114
144
|
clientContext,
|
|
145
|
+
permissionScreens,
|
|
146
|
+
settledPermissions,
|
|
147
|
+
permissionsPending = false,
|
|
148
|
+
onPermissionSettled,
|
|
115
149
|
}) => {
|
|
116
150
|
const { messages, error, isLoading, sendMessage, reset } = useWireAIThread();
|
|
117
151
|
const makeActions = useWireAIAction(sendMessage);
|
|
@@ -145,7 +179,17 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
145
179
|
// Trailing-edge debounce timer for prefetch-on-select (see previewOnSelect below).
|
|
146
180
|
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
147
181
|
|
|
148
|
-
|
|
182
|
+
// The attempt generation. It lives in STATE, not in a ref, and both the auto-start effect and
|
|
183
|
+
// the startup watchdog list it in their deps — a retry has to genuinely RE-ARM both, and a ref
|
|
184
|
+
// cannot re-trigger an effect. Nothing else in those deps moves on a retry: `sendMessage` is
|
|
185
|
+
// memoized in wireai-rn on [registry, provider, model], and `lastCard` is still `undefined`
|
|
186
|
+
// because no card ever arrived. Without the generation the kickoff is never re-sent, `timedOut`
|
|
187
|
+
// can never become true a second time, and the retry budget can never be spent — which leaves a
|
|
188
|
+
// backend that never answers sitting on the loader forever.
|
|
189
|
+
const [runId, setRunId] = useState(0);
|
|
190
|
+
// The generation whose kickoff has already been sent. -1 = nothing sent yet (so the very first
|
|
191
|
+
// run can be told apart from a retry, which must NOT re-fire `started`/`resumed`).
|
|
192
|
+
const startedRunRef = useRef(-1);
|
|
149
193
|
const finished = useRef(false);
|
|
150
194
|
const errored = useRef(false);
|
|
151
195
|
const attempts = useRef(0);
|
|
@@ -156,6 +200,11 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
156
200
|
const [timedOut, setTimedOut] = useState(false);
|
|
157
201
|
const [validationError, setValidationError] = useState<string | undefined>();
|
|
158
202
|
const [validating, setValidating] = useState(false);
|
|
203
|
+
// Permission screens settled during THIS mount, on top of whatever was restored from storage.
|
|
204
|
+
// A settled screen never comes back, which is the "shown exactly once" half of the contract.
|
|
205
|
+
const [locallySettledPermissions, setLocallySettledPermissions] = useState<string[]>([]);
|
|
206
|
+
const onPermissionSettledRef = useRef(onPermissionSettled);
|
|
207
|
+
onPermissionSettledRef.current = onPermissionSettled;
|
|
159
208
|
|
|
160
209
|
const lastCard = useMemo<Message | undefined>(
|
|
161
210
|
() => [...messages].reverse().find((m) => m.role === "assistant" && m.response?.action === "render"),
|
|
@@ -171,33 +220,50 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
171
220
|
const progress = useMemo(() => readProgress(lastCard?.response), [lastCard]);
|
|
172
221
|
|
|
173
222
|
// 1. Auto-start: the backend treats the first inbound message as "begin".
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
223
|
+
// ONLY the very first attempt of a fresh session sends `startMessage`. Every other kickoff
|
|
224
|
+
// — a RESUMED session (persisted id restored after an app kill) and every RETRY — sends the
|
|
225
|
+
// skip sentinel, because both can reach a server that already has a question PENDING on this
|
|
226
|
+
// session, and a non-empty message arriving against a pending question is recorded as that
|
|
227
|
+
// question's ANSWER (server engine dispatch: `if user_message and pending and not
|
|
228
|
+
// _stop_requested(...)` appends `{key, question, answer}` to `session["answers"]`). For a
|
|
229
|
+
// retry that is not hypothetical: the watchdog fires at `startTimeoutMs` while a slow LLM is
|
|
230
|
+
// still generating, so the server can genuinely hold card 1 pending when the retry lands, and
|
|
231
|
+
// the user's first onboarding answer becomes the literal kickoff text — silently.
|
|
232
|
+
// THE COST, stated plainly: when the server DID have card 1 pending, the sentinel SKIPS that
|
|
233
|
+
// card — the user loses one question and the server logs a `screen_skipped`. Deliberate. A
|
|
234
|
+
// skipped question is visible in the funnel and recorded as nothing; a kickoff string stored
|
|
235
|
+
// as the user's first answer is invisible and poisons the data the product is built on.
|
|
236
|
+
// When the server has no pending (restart/expiry, or a retry whose predecessor never landed)
|
|
237
|
+
// the sentinel is zeroed and falls through — it degrades cleanly to a normal kickoff under
|
|
238
|
+
// the same id, which is what makes it safe to send on an attempt that may be either.
|
|
180
239
|
useEffect(() => {
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
240
|
+
if (startedRunRef.current === runId) return;
|
|
241
|
+
const isFirstRun = startedRunRef.current < 0;
|
|
242
|
+
startedRunRef.current = runId;
|
|
243
|
+
sendMessage(isFirstRun && !resumed ? startMessage : SKIP_ONE_SENTINEL);
|
|
244
|
+
// Only the FIRST run is a session start. A retry re-kicks the flow (with the sentinel) but
|
|
245
|
+
// must not re-fire `started`/`resumed`, or every host funnel double-counts the session; the
|
|
246
|
+
// retry itself is already surfaced as a `retry` event by the error effect below.
|
|
247
|
+
if (!isFirstRun) return;
|
|
184
248
|
// Surface the session id (the A2A contextId) on the start event so a host can capture it
|
|
185
249
|
// and bind a user AFTER the flow via `identifyOnboarding({ contextId })` — completion clears
|
|
186
250
|
// the persisted session, so the in-flight event is the reliable post-flow handle. sessionId
|
|
187
|
-
// is stable for the whole session (minted before this subtree mounts), so this stays
|
|
251
|
+
// is stable for the whole session (minted before this subtree mounts), so this stays once
|
|
252
|
+
// per session.
|
|
188
253
|
onEventRef.current?.(
|
|
189
254
|
resumed
|
|
190
255
|
? { type: "resumed", contextId: sessionId }
|
|
191
256
|
: { type: "started", contextId: sessionId },
|
|
192
257
|
);
|
|
193
|
-
}, [sendMessage, startMessage, resumed, sessionId]);
|
|
258
|
+
}, [sendMessage, startMessage, resumed, sessionId, runId]);
|
|
194
259
|
|
|
195
|
-
// 2. Startup watchdog.
|
|
260
|
+
// 2. Startup watchdog. `runId` is load-bearing in these deps: after a retry there is still no
|
|
261
|
+
// card, so `lastCard` does not change and nothing else would re-arm the timer.
|
|
196
262
|
useEffect(() => {
|
|
197
263
|
if (lastCard) return;
|
|
198
264
|
const timer = setTimeout(() => setTimedOut(true), startTimeoutMs);
|
|
199
265
|
return () => clearTimeout(timer);
|
|
200
|
-
}, [lastCard, startTimeoutMs]);
|
|
266
|
+
}, [lastCard, startTimeoutMs, runId]);
|
|
201
267
|
|
|
202
268
|
// A new card arrived → clear any prior inline validation error + emit a turn event.
|
|
203
269
|
useEffect(() => {
|
|
@@ -248,8 +314,10 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
248
314
|
// Does NOT clear the attempt counter or the degraded flag — the caller decides that.
|
|
249
315
|
const restartThread = useCallback(() => {
|
|
250
316
|
setTimedOut(false);
|
|
251
|
-
started.current = false;
|
|
252
317
|
errored.current = false;
|
|
318
|
+
// Bumping the generation IN STATE is what actually re-runs the kickoff effect and re-arms the
|
|
319
|
+
// watchdog. Flipping a ref here would re-trigger neither.
|
|
320
|
+
setRunId((n) => n + 1);
|
|
253
321
|
reset();
|
|
254
322
|
}, [reset]);
|
|
255
323
|
|
|
@@ -324,6 +392,51 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
324
392
|
sendMessage(SKIP_ONE_SENTINEL);
|
|
325
393
|
}, [isLoading, sendMessage]);
|
|
326
394
|
|
|
395
|
+
// ── Permission screens (host-injected, never server-driven in this release) ─────────────────
|
|
396
|
+
//
|
|
397
|
+
// Analytics for one moment of a permission screen. It rides the SAME plumbing as the rest of the
|
|
398
|
+
// funnel: an `app_event` on `/v1/events` whose `question_key` is the canonical `wire_permission_*`
|
|
399
|
+
// name (exactly how `WIRE_PURCHASE_EVENTS` reach the wire), carrying the same device snapshot and
|
|
400
|
+
// `user_context`, so the `device_key` join that makes every other number real covers these too.
|
|
401
|
+
// It is ALSO surfaced on `onEvent`, so a host keeps its own analytics without a second wiring.
|
|
402
|
+
//
|
|
403
|
+
// `screen_index` is the last card index shown, NOT a new one: a permission screen is not a
|
|
404
|
+
// question, so it must never introduce a screen index the server never emitted a `screen_shown`
|
|
405
|
+
// for. Nothing here can throw into the UI (reportClientEvent is fire-and-forget by contract).
|
|
406
|
+
const emitPermissionStage = useCallback(
|
|
407
|
+
(permission: WirePermissionKind, stage: PermissionStage, status?: WirePermissionStatus) => {
|
|
408
|
+
onEventRef.current?.({ type: "permission", permission, stage, status });
|
|
409
|
+
reportClientEvent(reportTargetRef.current, {
|
|
410
|
+
event_type: "app_event",
|
|
411
|
+
session_id: sessionIdRef.current,
|
|
412
|
+
question_key: permissionEventName(stage),
|
|
413
|
+
component: PERMISSION_CARD_NAME,
|
|
414
|
+
screen_index: lastScreenIndexRef.current >= 0 ? lastScreenIndexRef.current : undefined,
|
|
415
|
+
meta: JSON.stringify(permissionEventProps(permission, status)),
|
|
416
|
+
device: clientContextRef.current?.device,
|
|
417
|
+
user_context: clientContextRef.current?.userContext,
|
|
418
|
+
user_id: clientContextRef.current?.userId,
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
[],
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
// A screen is DONE: remember it (so it never returns), let the host persist that across a kill,
|
|
425
|
+
// and hand the outcome to `onResult`, the seam a host schedules its first local notification
|
|
426
|
+
// from. A throwing host callback is swallowed: the flow continues on every path, always.
|
|
427
|
+
const settlePermission = useCallback(
|
|
428
|
+
(id: string, screen: PermissionScreenConfig, outcome: WirePermissionOutcome) => {
|
|
429
|
+
setLocallySettledPermissions((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
|
430
|
+
onPermissionSettledRef.current?.(id, outcome);
|
|
431
|
+
try {
|
|
432
|
+
screen.onResult?.(screen.permission, outcome);
|
|
433
|
+
} catch {
|
|
434
|
+
// A host's post-grant work (scheduling a reminder) must never break onboarding.
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
[],
|
|
438
|
+
);
|
|
439
|
+
|
|
327
440
|
// Prefetch on SELECT — the fix for "the first AI question is always the slowest". Q1 is a
|
|
328
441
|
// deterministic base card (instant); Q2 is the first LLM turn, and neither cache protects it
|
|
329
442
|
// (flow-cache misses a novel answer combo, the provider prompt-cache is cold on the first
|
|
@@ -438,6 +551,53 @@ export const OnboardingFlow: React.FC<OnboardingFlowProps> = ({
|
|
|
438
551
|
const total = progress.total ?? approxScreens;
|
|
439
552
|
const isTerminal = response.action === "render" && response.component === "StatusCard";
|
|
440
553
|
|
|
554
|
+
// A permission screen sits IN FRONT of the card the flow was about to render (including the
|
|
555
|
+
// terminal recap, which is what `"beforeEnd"` means), and hands that card straight back once it
|
|
556
|
+
// settles. Nothing is sent to the backend, nothing is appended to the thread, and `step` is
|
|
557
|
+
// untouched, so the progress bar, the funnel, and `deriveAnswers` cannot tell it happened.
|
|
558
|
+
// Placement is resolved against the card ABOUT to render, because the stream length is
|
|
559
|
+
// server-driven; an `afterCard` the stream never reaches clamps to the terminal position.
|
|
560
|
+
const duePermission = permissionsPending
|
|
561
|
+
? undefined
|
|
562
|
+
: selectDuePermissionScreen(
|
|
563
|
+
permissionScreens,
|
|
564
|
+
settledPermissions
|
|
565
|
+
? [...settledPermissions, ...locallySettledPermissions]
|
|
566
|
+
: locallySettledPermissions,
|
|
567
|
+
{ cardIndex: renderedCount - 1, isTerminal },
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
if (duePermission) {
|
|
571
|
+
const { screen, id } = duePermission;
|
|
572
|
+
const copy = resolvePermissionCopy(screen.permission, screen.copy);
|
|
573
|
+
return (
|
|
574
|
+
<OnboardingScaffold step={step} approxScreens={total}>
|
|
575
|
+
{/* The same shell + handoff every card uses; "spring" is the value-beat register the
|
|
576
|
+
InterstitialCard already wears, which is what this screen is. */}
|
|
577
|
+
<CardHandoff transitionKey={`permission:${id}`} variant="spring">
|
|
578
|
+
<PermissionCardView
|
|
579
|
+
key={id}
|
|
580
|
+
permission={screen.permission}
|
|
581
|
+
title={copy.title}
|
|
582
|
+
message={copy.message}
|
|
583
|
+
primaryLabel={copy.primaryLabel}
|
|
584
|
+
secondaryLabel={copy.secondaryLabel}
|
|
585
|
+
blockedTitle={copy.blockedTitle}
|
|
586
|
+
blockedMessage={copy.blockedMessage}
|
|
587
|
+
settingsLabel={copy.settingsLabel}
|
|
588
|
+
continueLabel={copy.continueLabel}
|
|
589
|
+
illustration={screen.illustration}
|
|
590
|
+
request={screen.request}
|
|
591
|
+
getStatus={screen.getStatus}
|
|
592
|
+
openSettings={screen.openSettings}
|
|
593
|
+
onStage={(stage, status) => emitPermissionStage(screen.permission, stage, status)}
|
|
594
|
+
onSettled={(outcome) => settlePermission(id, screen, outcome)}
|
|
595
|
+
/>
|
|
596
|
+
</CardHandoff>
|
|
597
|
+
</OnboardingScaffold>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
441
601
|
// Terminal recap: show what the app will now do for the user (backend-authored),
|
|
442
602
|
// and finish only when they tap the CTA.
|
|
443
603
|
if (isTerminal) {
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -34,6 +34,12 @@ import {
|
|
|
34
34
|
DEFAULT_SESSION_TTL_MS,
|
|
35
35
|
type LoadedSession,
|
|
36
36
|
} from "./session/persistedSession";
|
|
37
|
+
import {
|
|
38
|
+
clearSettledPermissions,
|
|
39
|
+
loadSettledPermissions,
|
|
40
|
+
permissionStorageKey,
|
|
41
|
+
saveSettledPermissions,
|
|
42
|
+
} from "./permissions/permissionMemory";
|
|
37
43
|
import type { OnboardingResult, WireOnboardingProps } from "./types";
|
|
38
44
|
import { warnInDev } from "./utils/warnInDev";
|
|
39
45
|
|
|
@@ -70,6 +76,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
70
76
|
userContext,
|
|
71
77
|
userId,
|
|
72
78
|
autoJoinKey = true,
|
|
79
|
+
permissionScreens,
|
|
73
80
|
}) => {
|
|
74
81
|
// The host's own opaque user id (trimmed + capped, NO PII) so onboarding sessions can be
|
|
75
82
|
// reconciled to real users later. `sanitizeUserId` is a pure string transform → the memoized
|
|
@@ -107,7 +114,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
107
114
|
// `activated` funnel rather than an error. Two real consumers shipped without it.
|
|
108
115
|
//
|
|
109
116
|
// A host-supplied key is also RECORDED on the process-wide provenance registry, so a sibling
|
|
110
|
-
// surface can later ask "does this app own a device id?" — see the
|
|
117
|
+
// surface can later ask "does this app own a device id?" — see the third-id-space warning below.
|
|
118
|
+
// The registry
|
|
111
119
|
// write is idempotent (same key, same value), so running it from a memo is safe under StrictMode's
|
|
112
120
|
// double-invoke; it lives here rather than in an effect so the answer is already true for any
|
|
113
121
|
// surface constructed later in the same tick.
|
|
@@ -136,7 +144,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
136
144
|
// counter rather than leaving it empty, and it inflates distinct-device counts on top. Same
|
|
137
145
|
// reason `useLifecycleEvents` gates its own fallback on storage.
|
|
138
146
|
//
|
|
139
|
-
// ⚠️ 0.13.0
|
|
147
|
+
// ⚠️ 0.13.0: the third condition used to be `Boolean(storage)` — the PRESENCE of the prop,
|
|
140
148
|
// never the SUCCESS of the write. A REJECTING adapter (a locked / full / permission-denied
|
|
141
149
|
// AsyncStorage, the most common real breakage) therefore walked the per-launch key in through the
|
|
142
150
|
// front door: `hydrateAutoDeviceKey` resolves to the in-memory mint on every failure branch, and a
|
|
@@ -146,8 +154,8 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
146
154
|
//
|
|
147
155
|
// It resolves through `hydrateDeviceIdentity`, never the sync `resolveAutoDeviceKey`: the sync
|
|
148
156
|
// contract returns the freshly minted id and adopts the persisted one milliseconds later, which
|
|
149
|
-
// is exactly the 0.12.1
|
|
150
|
-
// stamps, not a fresh mint per launch.
|
|
157
|
+
// is exactly the split-identity defect 0.12.1 shipped — the key stamped here must be the one the
|
|
158
|
+
// analytics side stamps, not a fresh mint per launch. It is therefore awaited BEHIND THE LOADER GATE
|
|
151
159
|
// below (the same gate the persisted-session read already holds, and it is only ever open when
|
|
152
160
|
// `storage` is present anyway), because `userContext` feeds the `llm` memo: swapping it after the
|
|
153
161
|
// provider mounted would recreate the A2A adapter and drop the server-learned `contextId`.
|
|
@@ -194,7 +202,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
194
202
|
[userContextStable, injectedJoinKey],
|
|
195
203
|
);
|
|
196
204
|
|
|
197
|
-
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS
|
|
205
|
+
// THE JOIN DECISION IS FROZEN AT THE MOMENT THE GATE OPENS, exactly as `startupUserIdRef`
|
|
198
206
|
// below freezes the user id, and for the identical reason: `userContext` feeds the `llm` memo, and
|
|
199
207
|
// wireai-rn recreates its A2A adapter whenever the `llm` identity changes — the adapter ctor resets
|
|
200
208
|
// `contextId` (`this.contextId = void 0`), so the server-learned session id is dropped.
|
|
@@ -254,7 +262,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
254
262
|
);
|
|
255
263
|
}, [warnMissingJoinKey, autoJoinReason]);
|
|
256
264
|
|
|
257
|
-
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE
|
|
265
|
+
// THE SECOND CASE AUTO-JOIN WOULD OTHERWISE SILENCE. 0.12.2 suppressed the missing-join-key
|
|
258
266
|
// warning whenever injection succeeded, which is right when the host genuinely owns no device id —
|
|
259
267
|
// and wrong when it owns one and simply forgot it HERE. That host used to get a loud warning and
|
|
260
268
|
// now gets a silent third id space: its app events under its own key, this onboarding session under
|
|
@@ -321,6 +329,7 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
321
329
|
storage ? null : { id: makeSessionId(), resumed: false },
|
|
322
330
|
);
|
|
323
331
|
const storageKey = persistKey ?? sessionStorageKey(config.appId);
|
|
332
|
+
const permissionsKey = permissionStorageKey(config.appId);
|
|
324
333
|
|
|
325
334
|
useEffect(() => {
|
|
326
335
|
if (!storage || session) return;
|
|
@@ -351,14 +360,63 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
351
360
|
// host that does not opt in lands on the byte-for-byte legacy clear-on-complete path.
|
|
352
361
|
const handleComplete = useCallback(
|
|
353
362
|
(result: OnboardingResult) => {
|
|
354
|
-
if (storage && shouldClearOnComplete(retainSessionOnComplete))
|
|
363
|
+
if (storage && shouldClearOnComplete(retainSessionOnComplete)) {
|
|
364
|
+
clearPersistedSession(storage, storageKey);
|
|
365
|
+
// The permission record is keyed by session id, so a stale one already reads as empty,
|
|
366
|
+
// this only keeps the adapter from carrying a dead entry forever.
|
|
367
|
+
clearSettledPermissions(storage, permissionsKey);
|
|
368
|
+
}
|
|
355
369
|
onComplete(result);
|
|
356
370
|
},
|
|
357
|
-
[storage, storageKey, retainSessionOnComplete, onComplete],
|
|
371
|
+
[storage, storageKey, permissionsKey, retainSessionOnComplete, onComplete],
|
|
358
372
|
);
|
|
359
373
|
|
|
360
374
|
const sessionId = session?.id ?? "";
|
|
361
375
|
|
|
376
|
+
// ── Permission-screen memory: "shown once per session", kept true across an app KILL ─────────
|
|
377
|
+
//
|
|
378
|
+
// The in-memory guard covers one mount. A resumed session (the whole point of `storage`) is a
|
|
379
|
+
// NEW mount of the SAME session, so without a persisted record it would re-show a screen the user
|
|
380
|
+
// already answered, and on the ask path that is a second attempt at a prompt iOS grants once.
|
|
381
|
+
//
|
|
382
|
+
// Unlike the auto-join key, this does NOT hold the loader gate: it feeds no `llm` dependency, so
|
|
383
|
+
// a late arrival cannot recreate the A2A adapter. The flow simply suppresses permission screens
|
|
384
|
+
// while `permissionsPending` is true, and that window closes long before the first card arrives
|
|
385
|
+
// (one timeout-capped storage read against a backend round trip).
|
|
386
|
+
const wantsPermissionMemory = Boolean(storage) && (permissionScreens?.length ?? 0) > 0;
|
|
387
|
+
const [settledPermissions, setSettledPermissions] = useState<string[] | undefined>(undefined);
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
if (!wantsPermissionMemory || !storage || !sessionId || settledPermissions !== undefined) return;
|
|
390
|
+
let cancelled = false;
|
|
391
|
+
void loadSettledPermissions(storage, permissionsKey, sessionId).then((ids) => {
|
|
392
|
+
if (!cancelled) setSettledPermissions(ids);
|
|
393
|
+
});
|
|
394
|
+
return () => {
|
|
395
|
+
cancelled = true;
|
|
396
|
+
};
|
|
397
|
+
}, [wantsPermissionMemory, storage, permissionsKey, sessionId, settledPermissions]);
|
|
398
|
+
|
|
399
|
+
// Persist the settled set as it grows. Fire-and-forget: a failed write only costs a re-ask on a
|
|
400
|
+
// resume, never a broken flow. Without `storage` there is nothing to write to and the
|
|
401
|
+
// in-memory guard alone carries the once-only contract for this mount.
|
|
402
|
+
// Mirrored in a ref so the handler can compute the next set WITHOUT doing the storage write
|
|
403
|
+
// inside a state updater (React invokes updaters twice under StrictMode; a write belongs outside).
|
|
404
|
+
const settledPermissionsRef = useRef<string[] | undefined>(undefined);
|
|
405
|
+
settledPermissionsRef.current = settledPermissions;
|
|
406
|
+
const handlePermissionSettled = useCallback(
|
|
407
|
+
(id: string) => {
|
|
408
|
+
const current = settledPermissionsRef.current ?? [];
|
|
409
|
+
if (current.includes(id)) return;
|
|
410
|
+
const next = [...current, id];
|
|
411
|
+
settledPermissionsRef.current = next;
|
|
412
|
+
setSettledPermissions(next);
|
|
413
|
+
if (storage && wantsPermissionMemory && sessionId) {
|
|
414
|
+
saveSettledPermissions(storage, permissionsKey, sessionId, next);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
[storage, wantsPermissionMemory, permissionsKey, sessionId],
|
|
418
|
+
);
|
|
419
|
+
|
|
362
420
|
// Session-start snapshot of the bound user id. It seeds the A2A `metadata.userId` so the
|
|
363
421
|
// adapter is built ONCE with whatever id was known at session start. A mid-session userId
|
|
364
422
|
// change must NOT flow through this memo — recreating `llm` recreates the A2A adapter,
|
|
@@ -478,6 +536,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
478
536
|
sessionId={sessionId}
|
|
479
537
|
resumed={session.resumed}
|
|
480
538
|
clientContext={clientContext}
|
|
539
|
+
permissionScreens={permissionScreens}
|
|
540
|
+
settledPermissions={settledPermissions}
|
|
541
|
+
permissionsPending={wantsPermissionMemory && settledPermissions === undefined}
|
|
542
|
+
onPermissionSettled={handlePermissionSettled}
|
|
481
543
|
/>
|
|
482
544
|
</WireAIProvider>
|
|
483
545
|
</IconRegistryProvider>
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* because the kit didn't own them — an awaitable action POST (`wireTrackActionAwait`), its OWN
|
|
6
6
|
* per-open session id (`getWireSessionId`, minted because the kit's `getCurrentSessionId` desynced
|
|
7
7
|
* cross-bundle — defect B), and an await-then-bump revalidation dance (`bumpActivationDecision`).
|
|
8
|
-
* Defect B is fixed
|
|
9
|
-
* `dist`
|
|
8
|
+
* Defect B is fixed — `getCurrentSessionId` is now a `globalThis` singleton, reliable under
|
|
9
|
+
* `dist` — so the kit can own all three behind one thin surface:
|
|
10
10
|
*
|
|
11
11
|
* const wire = createWireActivation({ serverUrl, apiKey }); // or useWireActivation(...)
|
|
12
12
|
* await wire.track("journal_done"); // awaitable POST + auto-revalidate
|
|
@@ -108,7 +108,7 @@ export const createWireActivation = (config: WireActivationConfig): WireActivati
|
|
|
108
108
|
const explicitDeviceKey = clean(config.deviceKey) ?? clean(config.userContext?.deviceKey);
|
|
109
109
|
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
110
110
|
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
111
|
-
// and forgot it here" (the silent third id space
|
|
111
|
+
// and forgot it here" (the silent third id space). Recording only; nothing reads it on this path.
|
|
112
112
|
resolveIdentity({
|
|
113
113
|
value: explicitDeviceKey,
|
|
114
114
|
space: "device",
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* `onEvent`) — the app logs it explicitly on `onComplete` using the constant below, so the
|
|
16
16
|
* funnel name stays canonical.
|
|
17
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
permissionEventName,
|
|
20
|
+
permissionEventProps,
|
|
21
|
+
type WirePermissionEventName,
|
|
22
|
+
} from "../permissions/permissionEvents";
|
|
18
23
|
import type { OnboardingEvent } from "../types";
|
|
19
24
|
|
|
20
25
|
export const WIRE_ONBOARDING_EVENTS = {
|
|
@@ -33,7 +38,12 @@ export type WireOnboardingEventName =
|
|
|
33
38
|
(typeof WIRE_ONBOARDING_EVENTS)[keyof typeof WIRE_ONBOARDING_EVENTS];
|
|
34
39
|
|
|
35
40
|
export type AnalyticsEvent = {
|
|
36
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A permission screen maps to its own canonical `wire_permission_*` name rather than to an
|
|
43
|
+
* onboarding one: it is a distinct funnel (see `permissions/permissionEvents.ts`), and folding it
|
|
44
|
+
* into `wire_onboarding_turn` would make every permission rate unreadable.
|
|
45
|
+
*/
|
|
46
|
+
name: WireOnboardingEventName | WirePermissionEventName;
|
|
37
47
|
params?: Record<string, unknown>;
|
|
38
48
|
};
|
|
39
49
|
|
|
@@ -61,6 +71,11 @@ export const toAnalyticsEvent = (event: OnboardingEvent): AnalyticsEvent => {
|
|
|
61
71
|
};
|
|
62
72
|
case "fallback":
|
|
63
73
|
return { name: WIRE_ONBOARDING_EVENTS.fallback, params: { reason: event.reason } };
|
|
74
|
+
case "permission":
|
|
75
|
+
return {
|
|
76
|
+
name: permissionEventName(event.stage),
|
|
77
|
+
params: permissionEventProps(event.permission, event.status),
|
|
78
|
+
};
|
|
64
79
|
default: {
|
|
65
80
|
const _exhaustive: never = event;
|
|
66
81
|
return _exhaustive;
|
|
@@ -198,7 +198,7 @@ export const createAnalytics = (
|
|
|
198
198
|
: undefined;
|
|
199
199
|
// Record a HOST-supplied key on the process provenance registry, so a `<WireOnboarding>` mount that
|
|
200
200
|
// was NOT given one can tell "this app owns no device id" (fine, inject) from "this app owns one
|
|
201
|
-
// and forgot it here" (the silent third id space
|
|
201
|
+
// and forgot it here" (the silent third id space). Recording only; nothing reads it here.
|
|
202
202
|
resolveIdentity({
|
|
203
203
|
value: hostDeviceKeyAtInit,
|
|
204
204
|
space: "device",
|
|
@@ -333,7 +333,7 @@ export const createAnalytics = (
|
|
|
333
333
|
|
|
334
334
|
const screen = (name: string, props?: AnalyticsProps): void => {
|
|
335
335
|
if (!name) return;
|
|
336
|
-
// Reuse the
|
|
336
|
+
// Reuse the existing screen event shape (question_key='screen'); route through the queue for offline-first.
|
|
337
337
|
const meta = { screen: name, ...(props ?? {}) };
|
|
338
338
|
const clientEvent: ClientEvent = {
|
|
339
339
|
event_type: "app_event",
|