@agent-native/core 0.216.0 → 0.218.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 (65) hide show
  1. package/dist/app-config/analytics.js +1 -1
  2. package/dist/client/agent-chat/index.d.ts +1 -1
  3. package/dist/client/analytics-session.d.ts +7 -0
  4. package/dist/client/analytics-session.js +27 -1
  5. package/dist/client/analytics.js +110 -15
  6. package/dist/client/consent.d.ts +12 -0
  7. package/dist/client/consent.js +132 -2
  8. package/dist/client/onboarding/index.d.ts +1 -0
  9. package/dist/client/onboarding/index.js +1 -0
  10. package/dist/client/onboarding/use-onboarding-checklist.d.ts +23 -0
  11. package/dist/client/onboarding/use-onboarding-checklist.js +115 -0
  12. package/dist/client/session-replay.d.ts +1 -1
  13. package/dist/client/session-replay.js +142 -3
  14. package/dist/client/use-chat-thread-list.d.ts +3 -0
  15. package/dist/client/use-chat-thread-list.js +23 -7
  16. package/dist/collab/routes.d.ts +1 -1
  17. package/dist/localization/consent-banner-messages.d.ts +16 -0
  18. package/dist/localization/consent-banner-messages.js +149 -0
  19. package/dist/localization/core-messages.js +4 -0
  20. package/dist/localization/default-messages.d.ts +1 -0
  21. package/dist/localization/default-messages.js +2 -0
  22. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  23. package/dist/notifications/routes.d.ts +1 -1
  24. package/dist/observability/routes.d.ts +7 -7
  25. package/dist/onboarding/index.d.ts +2 -1
  26. package/dist/onboarding/index.js +1 -0
  27. package/dist/onboarding/plugin.js +55 -46
  28. package/dist/onboarding/status.d.ts +15 -0
  29. package/dist/onboarding/status.js +59 -0
  30. package/dist/onboarding/types.d.ts +12 -0
  31. package/dist/onboarding/types.js +2 -1
  32. package/dist/org/workspace-app-access.js +19 -9
  33. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  34. package/dist/provider-api/actions/provider-api.d.ts +11 -11
  35. package/dist/resources/handlers.d.ts +1 -1
  36. package/dist/secrets/routes.d.ts +10 -10
  37. package/dist/server/agent-capabilities.js +13 -6
  38. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  39. package/dist/server/analytics.d.ts +0 -1
  40. package/dist/server/analytics.js +81 -20
  41. package/dist/server/builder-api-auth.d.ts +7 -0
  42. package/dist/server/builder-api-auth.js +24 -0
  43. package/dist/server/builder-oauth.d.ts +5 -1
  44. package/dist/server/builder-oauth.js +5 -0
  45. package/dist/server/first-touch-route.d.ts +2 -2
  46. package/dist/server/index.d.ts +1 -1
  47. package/dist/server/index.js +1 -1
  48. package/dist/server/realtime-token.d.ts +1 -1
  49. package/dist/shared/consent.d.ts +2 -0
  50. package/dist/shared/consent.js +4 -0
  51. package/dist/templates/builder-code-starter/template-layer.json +1 -0
  52. package/dist/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  53. package/dist/triggers/actions/manage-automation.d.ts +1 -1
  54. package/dist/usage/actions/get-usage-metrics.js +3 -3
  55. package/docs/content/components.mdx +19 -18
  56. package/docs/content/deployment-environment-variables.mdx +1 -1
  57. package/docs/content/environment-variables.mdx +1 -1
  58. package/docs/content/sharing.mdx +4 -0
  59. package/docs/content/toolkit-sharing.mdx +31 -51
  60. package/migration-manifest.json +1 -1
  61. package/package.json +5 -3
  62. package/src/templates/builder-code-starter/template-layer.json +1 -0
  63. package/src/templates/chat/app/components/layout/Sidebar.tsx +62 -101
  64. package/dist/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
  65. package/src/templates/chat/app/lib/sidebar-thread-state.ts +0 -29
@@ -32,6 +32,6 @@ export const analyticsConfig = z.object({
32
32
  .optional()
33
33
  .meta({
34
34
  env: "AGENT_NATIVE_HEAD_SCRIPT_SRC",
35
- doc: "HTTPS URL of a script loaded with `defer` on every server-rendered page, for deployment-specific tooling such as support or feedback widgets.",
35
+ doc: "HTTPS URL of a script added to every server-rendered page, for deployment-specific tooling such as support or feedback widgets. Where the consent banner shows, it waits for analytics consent, like Google Analytics.",
36
36
  }),
37
37
  });
@@ -19,7 +19,7 @@ export { AGENT_NATIVE_MCP_APP_HOST_MESSAGE_TYPES, getMcpAppHostContext, initiali
19
19
  export { buildDynamicAgentSuggestions, dedupeSuggestions, mergeAgentSuggestions, normalizeAgentDynamicSuggestionsConfig, useAgentDynamicSuggestions, useAgentDynamicSuggestionsResult, type AgentDynamicSuggestionContext, type AgentDynamicSuggestionsConfig, type AgentDynamicSuggestionsOption, } from "../dynamic-suggestions.js";
20
20
  export { clearChatStorage } from "../chat/storage.js";
21
21
  export { isAssistantChatHistoryVersion, coerceAssistantChatHistoryDate, type AssistantChatHistoryDate, type AssistantChatHistoryVersion, } from "../chat/assistant-chat-history-version.js";
22
- export { ChatThreadListConfigError, useChatThreadList, type UseChatThreadListOptions, type UseChatThreadListResult, } from "../use-chat-thread-list.js";
22
+ export { ChatThreadListConfigError, useChatThreadList, type UseChatThreadListOptions, type UseChatThreadListResult, type ChatThreadListRefreshOutcome, } from "../use-chat-thread-list.js";
23
23
  export { ChatThreadListError, fetchChatThreadListPage, notifyChatThreadsUpdated, useChatThreads, type ChatThreadAccessBasis, type ChatThreadListPage, type ChatThreadListRow, type ChatThreadListPageRequest, type ChatThreadOwnership, type ChatThreadVisibility, type ChatThreadScope, type ChatThreadSnapshot, type ChatThreadSummary, type ChatThreadData, type ChatThreadShareLink, type ChatThreadShareState, type UseChatThreadsOptions, } from "../use-chat-threads.js";
24
24
  export { type AgentCallProgress, type ContentPart, } from "../sse-event-processor.js";
25
25
  export { useRunStuckDetection, useAbortRun, type RunStuckState, type UseRunStuckDetectionOptions, } from "../use-run-stuck-detection.js";
@@ -1,5 +1,12 @@
1
1
  /** One id for this page load, shared by every pageview until it reloads. */
2
2
  export declare function getAnalyticsPageLoadId(): string;
3
+ /**
4
+ * The visitor id, `an_aid`, waits for analytics consent where the banner
5
+ * applies, and for the server's region rather than the time zone's guess,
6
+ * since a sent id can't be recalled. Until then this returns `undefined` and
7
+ * stores nothing, so events still count but can't be tied to one visitor
8
+ * across pages.
9
+ */
3
10
  export declare function getOrCreateAnalyticsAnonymousId(): string | undefined;
4
11
  export declare function setAnalyticsSessionId(sessionId: string): string | undefined;
5
12
  export declare function clearAnalyticsSessionId(): void;
@@ -1,5 +1,6 @@
1
- import { serializeAnalyticsAnonymousIdCookie } from "../shared/analytics-anonymous-id.js";
1
+ import { ANALYTICS_ANONYMOUS_ID_COOKIE_NAME, serializeAnalyticsAnonymousIdCookie, } from "../shared/analytics-anonymous-id.js";
2
2
  import { ANALYTICS_SESSION_ID_COOKIE_NAME, ANALYTICS_SESSION_ID_MAX_LENGTH, normalizeAnalyticsSessionId, serializeAnalyticsSessionIdCookie, } from "../shared/analytics-session-id.js";
3
+ import { consentDecision, whenConsentAllows } from "./consent.js";
3
4
  const ANONYMOUS_ID_STORAGE_KEY = "agent-native.anonymous_id";
4
5
  const SESSION_ID_STORAGE_KEY = "agent-native.session_id";
5
6
  const SESSION_ID_PIN_STORAGE_KEY = "agent-native.session_id_pin";
@@ -72,9 +73,34 @@ export function getAnalyticsPageLoadId() {
72
73
  pageLoadId ??= generateVisitorId();
73
74
  return pageLoadId;
74
75
  }
76
+ let anonymousIdConsentWatched = false;
77
+ function clearAnalyticsAnonymousId() {
78
+ safeStorageRemove(ANONYMOUS_ID_STORAGE_KEY);
79
+ try {
80
+ document.cookie = `${ANALYTICS_ANONYMOUS_ID_COOKIE_NAME}=; path=/; max-age=0; SameSite=Lax`;
81
+ }
82
+ catch {
83
+ // coercion-ok: a frame that can't read cookies never stored one.
84
+ }
85
+ }
86
+ /**
87
+ * The visitor id, `an_aid`, waits for analytics consent where the banner
88
+ * applies, and for the server's region rather than the time zone's guess,
89
+ * since a sent id can't be recalled. Until then this returns `undefined` and
90
+ * stores nothing, so events still count but can't be tied to one visitor
91
+ * across pages.
92
+ */
75
93
  export function getOrCreateAnalyticsAnonymousId() {
76
94
  if (typeof window === "undefined")
77
95
  return undefined;
96
+ if (!anonymousIdConsentWatched) {
97
+ anonymousIdConsentWatched = true;
98
+ whenConsentAllows("performance", () => { }, {
99
+ revoke: clearAnalyticsAnonymousId,
100
+ });
101
+ }
102
+ if (consentDecision("performance") !== true)
103
+ return undefined;
78
104
  let id = safeStorageGet(ANONYMOUS_ID_STORAGE_KEY);
79
105
  if (!id) {
80
106
  id = generateVisitorId();
@@ -16,7 +16,7 @@ import { agentNativePath } from "./api-path.js";
16
16
  import { agentNativeApiDisabledReason } from "./api-surface.js";
17
17
  import { injectedAgentNativeConfig } from "./app-config.js";
18
18
  import { clientBuildId } from "./build-compatibility.js";
19
- import { consentAllowsNow, whenConsentAllows } from "./consent.js";
19
+ import { consentAllowsNow, consentDecision, getConsentChoice, whenConsentAllows, whenConsentDecided, } from "./consent.js";
20
20
  import { clientFailureContext } from "./failure-report.js";
21
21
  import { replayEndpointFromAnalyticsEndpoint } from "./session-replay-endpoint.js";
22
22
  import { scheduleAfterPaint } from "./use-after-paint.js";
@@ -39,6 +39,7 @@ let _amplitudeModule = null;
39
39
  let _amplitudeLoadPromise = null;
40
40
  let _amplitudeApiKey = null;
41
41
  let _pendingAmplitudeEvents = [];
42
+ let _amplitudeConsent = "unasked";
42
43
  let _sentryInitialized = false;
43
44
  let _sentryModule = null;
44
45
  let _sentryLoadPromise = null;
@@ -881,20 +882,82 @@ function isSyntheticBrowserTraffic() {
881
882
  return (typeof window !== "undefined" &&
882
883
  isSyntheticTrafficValue(window.__AGENT_NATIVE_SYNTHETIC_TRAFFIC__));
883
884
  }
885
+ /**
886
+ * Amplitude keeps its device id in a cookie on the widest domain it can set,
887
+ * and its marketing touch and unsent events beside it, all named from the
888
+ * API key. A reject clears them at every level of this host.
889
+ */
890
+ function clearAmplitudeStorage(apiKey) {
891
+ const key = apiKey.slice(0, 10);
892
+ const names = [
893
+ `AMP_${key}`,
894
+ `AMP_MKTG_${key}`,
895
+ `AMP_MKTG_ORIGINAL_${key}`,
896
+ `AMP_unsent_${key}`,
897
+ `amp_${apiKey.slice(0, 6)}`,
898
+ ];
899
+ try {
900
+ const labels = window.location.hostname.split(".");
901
+ const domains = [""];
902
+ for (let i = 0; i < labels.length - 1; i += 1) {
903
+ domains.push(`; domain=.${labels.slice(i).join(".")}`);
904
+ }
905
+ for (const name of names) {
906
+ for (const domain of domains) {
907
+ document.cookie = `${name}=; path=/; max-age=0${domain}`;
908
+ }
909
+ }
910
+ }
911
+ catch {
912
+ // coercion-ok: a sandboxed iframe can't read or write cookies, so
913
+ // Amplitude stored none here.
914
+ }
915
+ for (const name of names)
916
+ safeStorageRemove(name);
917
+ }
884
918
  function ensureAmplitude() {
885
919
  if (isSyntheticBrowserTraffic())
886
920
  return false;
887
- if (_amplitudeInitialized)
888
- return true;
889
921
  const key = import.meta.env
890
922
  ?.VITE_AMPLITUDE_API_KEY;
891
923
  if (!key)
892
924
  return false;
893
925
  _amplitudeApiKey = key;
926
+ if (_amplitudeConsent !== "granted") {
927
+ if (_amplitudeConsent === "unasked") {
928
+ _amplitudeConsent = "waiting";
929
+ // Amplitude keeps a device id in the browser, so where the banner
930
+ // applies it waits for performance cookies.
931
+ whenConsentAllows("performance", () => {
932
+ _amplitudeConsent = "granted";
933
+ // Held before the region answered: with a choice on record, the
934
+ // visitor had to choose, so what came before it never goes out.
935
+ if (getConsentChoice())
936
+ _pendingAmplitudeEvents = [];
937
+ _amplitudeModule?.setOptOut(false);
938
+ ensureAmplitude();
939
+ }, {
940
+ revoke: () => {
941
+ _amplitudeConsent = "revoked";
942
+ _pendingAmplitudeEvents = [];
943
+ _amplitudeModule?.setOptOut(true);
944
+ // A later accept starts a new device id, not the one in memory.
945
+ _amplitudeModule?.reset();
946
+ clearAmplitudeStorage(key);
947
+ },
948
+ });
949
+ }
950
+ return false;
951
+ }
952
+ if (_amplitudeInitialized)
953
+ return true;
894
954
  if (_amplitudeLoadPromise)
895
955
  return false;
896
956
  _amplitudeLoadPromise = loadOptionalPeer("@amplitude/analytics-browser", () => import("@amplitude/analytics-browser"))
897
957
  .then((module) => {
958
+ // A reject while the SDK loaded: the next accept initializes it.
959
+ if (_amplitudeConsent !== "granted")
960
+ return null;
898
961
  module.init(key, { autocapture: false });
899
962
  _amplitudeModule = module;
900
963
  _amplitudeInitialized = true;
@@ -2155,15 +2218,30 @@ function sendAgentNativeAnalytics(name, properties) {
2155
2218
  ?.VITE_AGENT_NATIVE_ANALYTICS_ENDPOINT ||
2156
2219
  AGENT_NATIVE_ANALYTICS_DEFAULT_ENDPOINT, window.location.hostname);
2157
2220
  const userId = typeof properties.userId === "string" ? properties.userId : undefined;
2158
- const body = JSON.stringify({
2159
- publicKey,
2160
- event: name,
2161
- properties,
2162
- userId,
2163
- anonymousId: getOrCreateAnonymousId(),
2164
- sessionId: getOrCreateSessionId(),
2165
- timestamp: new Date().toISOString(),
2221
+ const sessionId = getOrCreateSessionId();
2222
+ const timestamp = new Date().toISOString();
2223
+ whenConsentDecided((decided) => {
2224
+ const anonymousId = decided ? getOrCreateAnonymousId() : undefined;
2225
+ postAgentNativeAnalytics(endpoint, JSON.stringify({
2226
+ publicKey,
2227
+ event: name,
2228
+ // The visitor id waits for analytics consent; mark the event so
2229
+ // reports can tell it from one that lost its id some other way, and
2230
+ // a refusal from a page that closed before the region was known.
2231
+ properties: anonymousId
2232
+ ? properties
2233
+ : {
2234
+ ...properties,
2235
+ analytics_consent: decided ? "withheld" : "unknown",
2236
+ },
2237
+ userId,
2238
+ anonymousId,
2239
+ sessionId,
2240
+ timestamp,
2241
+ }));
2166
2242
  });
2243
+ }
2244
+ function postAgentNativeAnalytics(endpoint, body) {
2167
2245
  try {
2168
2246
  if (navigator.sendBeacon) {
2169
2247
  const sent = navigator.sendBeacon(endpoint, body);
@@ -2187,17 +2265,34 @@ function createTrackingAliasId() {
2187
2265
  ? crypto.randomUUID()
2188
2266
  : undefined;
2189
2267
  }
2268
+ function pageHasGoogleTags() {
2269
+ const holder = window;
2270
+ return Boolean(holder.__AN_CONSENT_LAYERS__ ||
2271
+ window.__AGENT_NATIVE_GA_GTAG__ ||
2272
+ window.gtag);
2273
+ }
2190
2274
  function emitBrowserTrackingEvent(name, props, options = {}) {
2191
2275
  const { agentNativeProperties = props, gtagProperties = props, sendGtag = true, } = options;
2192
2276
  const amplitudeProps = amplitudeEventProperties(name, props);
2193
- if (sendGtag) {
2194
- const gtag = window.__AGENT_NATIVE_GA_GTAG__ ?? window.gtag;
2195
- gtag?.("event", name.replace(/\s+/g, "_"), gtagProperties);
2277
+ // Without Google tags there is nothing to hold, and asking for the region
2278
+ // would be a request a scoped embed (an MCP App widget) may not make.
2279
+ if (sendGtag && pageHasGoogleTags()) {
2280
+ whenConsentDecided(() => {
2281
+ if (consentDecision("performance") !== true)
2282
+ return;
2283
+ const gtag = window.__AGENT_NATIVE_GA_GTAG__ ?? window.gtag;
2284
+ gtag?.("event", name.replace(/\s+/g, "_"), gtagProperties);
2285
+ });
2196
2286
  }
2197
2287
  if (ensureAmplitude()) {
2198
2288
  _amplitudeModule?.track(name, amplitudeProps);
2199
2289
  }
2200
- else if (_amplitudeApiKey) {
2290
+ else if (_amplitudeApiKey &&
2291
+ // Only while it loads after an accept, or before the region is known;
2292
+ // the region's answer clears what's held if the visitor must choose.
2293
+ (_amplitudeConsent === "granted" ||
2294
+ (_amplitudeConsent !== "revoked" &&
2295
+ consentDecision("performance") === null))) {
2201
2296
  if (_pendingAmplitudeEvents.length < 100) {
2202
2297
  _pendingAmplitudeEvents.push([name, amplitudeProps]);
2203
2298
  }
@@ -13,6 +13,12 @@ export declare function resolveConsentRegion(): Promise<ConsentRegion>;
13
13
  * whatever this let through.
14
14
  */
15
15
  export declare function consentAllowsNow(category: ConsentCategory): boolean;
16
+ /**
17
+ * Whether `category` is allowed, from the visitor's choice or the server's
18
+ * region; `null` until one of them is known, where `consentAllowsNow` would
19
+ * guess from the time zone. For what `revoke` can't undo once it's sent.
20
+ */
21
+ export declare function consentDecision(category: ConsentCategory): boolean | null;
16
22
  export interface WhenConsentAllowsOptions {
17
23
  /**
18
24
  * Run now on the time zone's guess instead of waiting for the server, and
@@ -27,6 +33,12 @@ export interface WhenConsentAllowsOptions {
27
33
  * and inside it when the visitor accepts. A later "reject" calls `revoke`.
28
34
  */
29
35
  export declare function whenConsentAllows(category: ConsentCategory, run: () => void, options?: WhenConsentAllowsOptions): void;
36
+ /**
37
+ * Run `send` once the visitor's choice or the server's region is known, so
38
+ * the time zone's guess never sends what consent covers. `decided` is false
39
+ * when the page closes first; send then without what consent covers.
40
+ */
41
+ export declare function whenConsentDecided(send: (decided: boolean) => void): void;
30
42
  /** Store the visitor's choice for every app on this site, and apply it. */
31
43
  export declare function setConsentChoice(choice: ConsentChoice): void;
32
44
  /** Reopen the banner so the visitor can change their choice. */
@@ -8,6 +8,8 @@ const REGION_STORAGE_KEY = "an_consent_region";
8
8
  * injects into the page, queue `[category, run]` here.
9
9
  */
10
10
  const SCRIPT_QUEUE_KEY = "__AN_CONSENT_QUEUE__";
11
+ /** The Google data layers the server's Consent Mode default script set up. */
12
+ const GOOGLE_CONSENT_LAYERS_KEY = "__AN_CONSENT_LAYERS__";
11
13
  // One state per page, however many copies of this module a bundle carries.
12
14
  const STATE_KEY = Symbol.for("agent-native.client.consent");
13
15
  function state() {
@@ -18,6 +20,7 @@ function state() {
18
20
  waiters: [],
19
21
  listeners: new Set(),
20
22
  settingsOpen: false,
23
+ pageChoice: null,
21
24
  });
22
25
  }
23
26
  function readCookie(name) {
@@ -39,7 +42,7 @@ function readCookie(name) {
39
42
  }
40
43
  /** The visitor's stored choice, or `null` before they've made one. */
41
44
  export function getConsentChoice() {
42
- return parseConsent(readCookie(CONSENT_COOKIE_NAME));
45
+ return (parseConsent(readCookie(CONSENT_COOKIE_NAME)) ?? state().pageChoice ?? null);
43
46
  }
44
47
  function cookieDomain() {
45
48
  const host = typeof window === "undefined" ? "" : window.location.hostname;
@@ -183,21 +186,69 @@ export function consentAllowsNow(category) {
183
186
  void resolveConsentRegion();
184
187
  return allows(category, regionGuess());
185
188
  }
189
+ /**
190
+ * Whether `category` is allowed, from the visitor's choice or the server's
191
+ * region; `null` until one of them is known, where `consentAllowsNow` would
192
+ * guess from the time zone. For what `revoke` can't undo once it's sent.
193
+ */
194
+ export function consentDecision(category) {
195
+ if (typeof window === "undefined")
196
+ return false;
197
+ const choice = getConsentChoice();
198
+ if (choice)
199
+ return choice[category];
200
+ const region = state().region ?? readCachedRegion();
201
+ if (region)
202
+ return region === "none";
203
+ void resolveConsentRegion();
204
+ return null;
205
+ }
206
+ /**
207
+ * Google's tags can't be unloaded, but they follow Consent Mode: a reject
208
+ * stops what's already on the page from storing or sending identifiers.
209
+ */
210
+ function updateGoogleConsent(region) {
211
+ const holder = window;
212
+ const layers = holder[GOOGLE_CONSENT_LAYERS_KEY];
213
+ if (!Array.isArray(layers))
214
+ return;
215
+ const grant = (category) => allows(category, region) ? "granted" : "denied";
216
+ const update = {
217
+ analytics_storage: grant("performance"),
218
+ ad_storage: grant("targeting"),
219
+ ad_user_data: grant("targeting"),
220
+ ad_personalization: grant("targeting"),
221
+ };
222
+ for (const name of layers) {
223
+ if (typeof name !== "string")
224
+ continue;
225
+ const layer = (holder[name] ??= []);
226
+ // gtag reads an `arguments` object from the data layer, never an array.
227
+ (function gtag(..._args) {
228
+ layer.push(arguments);
229
+ })("consent", "update", update);
230
+ }
231
+ }
186
232
  function settle() {
187
233
  const region = state().region;
234
+ if (region || getConsentChoice())
235
+ updateGoogleConsent(region);
188
236
  for (const waiter of state().waiters) {
189
237
  const allowed = allows(waiter.category, region);
190
238
  if (allowed && !waiter.ran) {
191
239
  waiter.ran = true;
192
240
  isolate(waiter.run);
193
241
  }
194
- else if (!allowed && region) {
242
+ else if (!allowed && (region || getConsentChoice())) {
195
243
  // Also clears what an earlier page stored before the banner existed.
196
244
  waiter.ran = false;
197
245
  if (waiter.revoke)
198
246
  isolate(waiter.revoke);
199
247
  }
200
248
  }
249
+ // A choice stored here or in another tab decides before the region does.
250
+ if (region || getConsentChoice())
251
+ releaseHeldForDecision?.(true);
201
252
  }
202
253
  /**
203
254
  * One callback throwing must not skip the waiters after it, or a later revoke
@@ -234,15 +285,93 @@ export function whenConsentAllows(category, run, options = {}) {
234
285
  waiter.ran = true;
235
286
  isolate(run);
236
287
  }
288
+ else if (state().region || getConsentChoice()) {
289
+ // A reject decided before this caller asked still clears what it stored.
290
+ if (options.revoke)
291
+ isolate(options.revoke);
292
+ }
237
293
  if (!state().region)
238
294
  void resolveConsentRegion();
239
295
  }
296
+ let heldForDecision = null;
297
+ let releaseHeldForDecision = null;
298
+ /**
299
+ * Run `send` once the visitor's choice or the server's region is known, so
300
+ * the time zone's guess never sends what consent covers. `decided` is false
301
+ * when the page closes first; send then without what consent covers.
302
+ */
303
+ export function whenConsentDecided(send) {
304
+ if (consentDecision("performance") !== null) {
305
+ send(true);
306
+ return;
307
+ }
308
+ if (!heldForDecision) {
309
+ const held = [];
310
+ heldForDecision = held;
311
+ const release = (decided) => {
312
+ if (heldForDecision !== held)
313
+ return;
314
+ heldForDecision = null;
315
+ releaseHeldForDecision = null;
316
+ window.removeEventListener("pagehide", onPageHide);
317
+ for (const run of held)
318
+ run(decided);
319
+ };
320
+ const onPageHide = () => release(false);
321
+ releaseHeldForDecision = release;
322
+ window.addEventListener("pagehide", onPageHide);
323
+ void resolveConsentRegion().then(() => release(true));
324
+ }
325
+ heldForDecision.push(send);
326
+ }
327
+ const CONSENT_CHANNEL_NAME = "agent-native-consent";
328
+ let seenChoice = null;
329
+ let consentChannel = null;
330
+ /**
331
+ * Apply a choice another tab or app stored: at once in other tabs of this
332
+ * app, and on the next focus for another app on the site.
333
+ */
334
+ function recheckStoredChoice() {
335
+ const raw = readCookie(CONSENT_COOKIE_NAME);
336
+ if (raw === seenChoice)
337
+ return;
338
+ seenChoice = raw;
339
+ state().settingsOpen = false;
340
+ settle();
341
+ notify();
342
+ }
343
+ function watchOtherTabs() {
344
+ if (typeof window === "undefined" || typeof document === "undefined")
345
+ return;
346
+ seenChoice = readCookie(CONSENT_COOKIE_NAME);
347
+ window.addEventListener?.("focus", recheckStoredChoice);
348
+ document.addEventListener?.("visibilitychange", recheckStoredChoice);
349
+ if (typeof BroadcastChannel !== "function")
350
+ return;
351
+ try {
352
+ consentChannel = new BroadcastChannel(CONSENT_CHANNEL_NAME);
353
+ consentChannel.onmessage = recheckStoredChoice;
354
+ }
355
+ catch {
356
+ // coercion-ok: without the channel, other tabs recheck on focus.
357
+ }
358
+ }
240
359
  /** Store the visitor's choice for every app on this site, and apply it. */
241
360
  export function setConsentChoice(choice) {
242
361
  if (typeof document === "undefined")
243
362
  return;
244
363
  writeChoiceCookie(choice);
245
364
  storeChoiceOnServer(choice);
365
+ seenChoice = readCookie(CONSENT_COOKIE_NAME);
366
+ // Only a browser that won't keep the cookie falls back to this page's
367
+ // copy; a cookie the visitor clears later means they're asked again.
368
+ state().pageChoice = seenChoice === null ? choice : null;
369
+ try {
370
+ consentChannel?.postMessage("changed");
371
+ }
372
+ catch {
373
+ // coercion-ok: other tabs still recheck on focus.
374
+ }
246
375
  state().settingsOpen = false;
247
376
  if (!state().region)
248
377
  state().region = "consent";
@@ -281,3 +410,4 @@ function drainScriptQueue() {
281
410
  push(...queued);
282
411
  }
283
412
  drainScriptQueue();
413
+ watchOtherTabs();
@@ -1,4 +1,5 @@
1
1
  export { useOnboarding, type UseOnboardingResult } from "./use-onboarding.js";
2
+ export { useOnboardingChecklist, type UseOnboardingChecklistResult, } from "./use-onboarding-checklist.js";
2
3
  export { isFirstRunOnboardingEnabled } from "./first-run-enabled.js";
3
4
  export { dispatchFirstRunOnboardingStatus, fetchFirstRunOnboardingStatus, FIRST_RUN_ONBOARDING_STATUS_RESOLVED_EVENT, } from "./first-run-status.js";
4
5
  export { useOnboardingPreviewMode, useOnboardingPreviewStep, ONBOARDING_PREVIEW_STORAGE_KEY, ONBOARDING_PREVIEW_QUERY_PARAM, ONBOARDING_PREVIEW_QUERY_VALUE, ONBOARDING_PREVIEW_STEP_QUERY_PARAM, ONBOARDING_PREVIEW_STEPS, getOnboardingPreviewStep, isOnboardingPreviewQuery, } from "./use-preview-mode.js";
@@ -1,4 +1,5 @@
1
1
  export { useOnboarding } from "./use-onboarding.js";
2
+ export { useOnboardingChecklist, } from "./use-onboarding-checklist.js";
2
3
  export { isFirstRunOnboardingEnabled } from "./first-run-enabled.js";
3
4
  export { dispatchFirstRunOnboardingStatus, fetchFirstRunOnboardingStatus, FIRST_RUN_ONBOARDING_STATUS_RESOLVED_EVENT, } from "./first-run-status.js";
4
5
  export { useOnboardingPreviewMode, useOnboardingPreviewStep, ONBOARDING_PREVIEW_STORAGE_KEY, ONBOARDING_PREVIEW_QUERY_PARAM, ONBOARDING_PREVIEW_QUERY_VALUE, ONBOARDING_PREVIEW_STEP_QUERY_PARAM, ONBOARDING_PREVIEW_STEPS, getOnboardingPreviewStep, isOnboardingPreviewQuery, } from "./use-preview-mode.js";
@@ -0,0 +1,23 @@
1
+ import type { OnboardingStepStatus } from "../../onboarding/types.js";
2
+ export interface UseOnboardingChecklistResult {
3
+ steps: OnboardingStepStatus[];
4
+ completeCount: number;
5
+ totalCount: number;
6
+ dismissed: boolean;
7
+ collapsed: boolean;
8
+ /** `loading` only before the first answer; a refresh keeps the last one. */
9
+ status: "loading" | "ready" | "error";
10
+ error: Error | null;
11
+ refresh: () => Promise<unknown>;
12
+ dismiss: () => Promise<void>;
13
+ reopen: () => Promise<void>;
14
+ setCollapsed: (collapsed: boolean) => Promise<void>;
15
+ }
16
+ /**
17
+ * Reads one named onboarding checklist (steps registered with
18
+ * `registerOnboardingStep({ checklist })`). Rows are re-read whenever a local
19
+ * action or an agent tool finishes, so a step checks itself off after the work
20
+ * that completes it. Dismissal is stored by the onboarding plugin and the
21
+ * collapsed state in application state, so both follow the user across devices.
22
+ */
23
+ export declare function useOnboardingChecklist(checklist: string): UseOnboardingChecklistResult;
@@ -0,0 +1,115 @@
1
+ import { useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
2
+ import { useCallback } from "react";
3
+ import { agentNativePath } from "../api-path.js";
4
+ import { readClientAppState, writeClientAppState, } from "../application-state.js";
5
+ import { useChangeVersions } from "../use-change-version.js";
6
+ const CHECKLIST_TIMEOUT_MS = 10_000;
7
+ function summaryKey(checklist) {
8
+ return ["onboarding-checklist", checklist];
9
+ }
10
+ function collapsedKey(checklist) {
11
+ return ["onboarding-checklist-collapsed", checklist];
12
+ }
13
+ function collapsedStateKey(checklist) {
14
+ return `onboarding:collapsed:${checklist}`;
15
+ }
16
+ function checklistUrl(path, checklist) {
17
+ return agentNativePath(`/_agent-native/onboarding/${path}?checklist=${encodeURIComponent(checklist)}`);
18
+ }
19
+ async function fetchChecklist(checklist, signal) {
20
+ const controller = new AbortController();
21
+ const abort = () => controller.abort();
22
+ signal.addEventListener("abort", abort);
23
+ const timer = setTimeout(abort, CHECKLIST_TIMEOUT_MS);
24
+ try {
25
+ const response = await fetch(checklistUrl("summary", checklist), {
26
+ signal: controller.signal,
27
+ });
28
+ if (!response.ok)
29
+ throw new Error(`checklist: ${response.status}`);
30
+ const body = (await response.json());
31
+ return { steps: body.steps, dismissed: !!body.dismissed };
32
+ }
33
+ finally {
34
+ clearTimeout(timer);
35
+ signal.removeEventListener("abort", abort);
36
+ }
37
+ }
38
+ async function postChecklist(path, checklist) {
39
+ const response = await fetch(checklistUrl(path, checklist), {
40
+ method: "POST",
41
+ });
42
+ if (!response.ok)
43
+ throw new Error(`checklist ${path}: ${response.status}`);
44
+ }
45
+ function patchSummary(queryClient, checklist, patch) {
46
+ const snapshot = queryClient.getQueriesData({
47
+ queryKey: summaryKey(checklist),
48
+ });
49
+ queryClient.setQueriesData({ queryKey: summaryKey(checklist) }, (current) => (current ? { ...current, ...patch } : current));
50
+ return snapshot;
51
+ }
52
+ /**
53
+ * Reads one named onboarding checklist (steps registered with
54
+ * `registerOnboardingStep({ checklist })`). Rows are re-read whenever a local
55
+ * action or an agent tool finishes, so a step checks itself off after the work
56
+ * that completes it. Dismissal is stored by the onboarding plugin and the
57
+ * collapsed state in application state, so both follow the user across devices.
58
+ */
59
+ export function useOnboardingChecklist(checklist) {
60
+ const queryClient = useQueryClient();
61
+ const changeVersion = useChangeVersions(["action"]);
62
+ const summary = useQuery({
63
+ queryKey: [...summaryKey(checklist), changeVersion],
64
+ queryFn: ({ signal }) => fetchChecklist(checklist, signal),
65
+ placeholderData: (previous) => previous,
66
+ });
67
+ const collapsedQuery = useQuery({
68
+ queryKey: collapsedKey(checklist),
69
+ queryFn: async () => {
70
+ const value = await readClientAppState(collapsedStateKey(checklist));
71
+ return value?.collapsed === true;
72
+ },
73
+ staleTime: Infinity,
74
+ });
75
+ const dismissMutation = useMutation({
76
+ mutationFn: (dismissed) => postChecklist(dismissed ? "dismiss" : "reopen", checklist),
77
+ onMutate: (dismissed) => ({
78
+ previous: patchSummary(queryClient, checklist, { dismissed }),
79
+ }),
80
+ onError: (_error, _dismissed, context) => {
81
+ for (const [key, data] of context?.previous ?? []) {
82
+ queryClient.setQueryData(key, data);
83
+ }
84
+ },
85
+ onSettled: () => queryClient.invalidateQueries({ queryKey: summaryKey(checklist) }),
86
+ });
87
+ const collapseMutation = useMutation({
88
+ mutationFn: (collapsed) => writeClientAppState(collapsedStateKey(checklist), { collapsed }),
89
+ onMutate: (collapsed) => {
90
+ const previous = queryClient.getQueryData(collapsedKey(checklist));
91
+ queryClient.setQueryData(collapsedKey(checklist), collapsed);
92
+ return { previous };
93
+ },
94
+ onError: (_error, _collapsed, context) => {
95
+ queryClient.setQueryData(collapsedKey(checklist), context?.previous);
96
+ },
97
+ });
98
+ const dismiss = useCallback(() => dismissMutation.mutateAsync(true), [dismissMutation]);
99
+ const reopen = useCallback(() => dismissMutation.mutateAsync(false), [dismissMutation]);
100
+ const setCollapsed = useCallback((collapsed) => collapseMutation.mutateAsync(collapsed).then(() => undefined), [collapseMutation]);
101
+ const steps = summary.data?.steps ?? [];
102
+ return {
103
+ steps,
104
+ completeCount: steps.filter((step) => step.complete).length,
105
+ totalCount: steps.length,
106
+ dismissed: summary.data?.dismissed ?? false,
107
+ collapsed: collapsedQuery.data ?? false,
108
+ status: summary.data ? "ready" : summary.isError ? "error" : "loading",
109
+ error: summary.error,
110
+ refresh: () => queryClient.invalidateQueries({ queryKey: summaryKey(checklist) }),
111
+ dismiss,
112
+ reopen,
113
+ setCollapsed,
114
+ };
115
+ }
@@ -64,7 +64,7 @@ export interface SessionReplayOptions {
64
64
  }
65
65
  export interface SessionReplayStartResult {
66
66
  started: boolean;
67
- reason?: "disabled" | "not-browser" | "missing-public-key" | "missing-session-id" | "missing-user-id" | "sampled-out" | "url-blocked" | "already-active" | "import-failed" | "record-failed";
67
+ reason?: "disabled" | "not-browser" | "missing-public-key" | "missing-session-id" | "missing-user-id" | "sampled-out" | "url-blocked" | "already-active" | "import-failed" | "record-failed" | "consent-required";
68
68
  replayId?: string;
69
69
  sessionId?: string;
70
70
  sampled?: boolean;