@agent-native/core 0.69.0 → 0.70.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 (200) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +48 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/changelog/parse.ts +245 -0
  5. package/corpus/core/src/cli/changelog.ts +199 -0
  6. package/corpus/core/src/cli/index.ts +19 -0
  7. package/corpus/core/src/cli/recap.ts +24 -1
  8. package/corpus/core/src/cli/skills.ts +72 -45
  9. package/corpus/core/src/client/CommandMenu.tsx +183 -75
  10. package/corpus/core/src/client/agent-chat-adapter.ts +44 -7
  11. package/corpus/core/src/client/analytics.ts +181 -0
  12. package/corpus/core/src/client/changelog/Changelog.tsx +321 -0
  13. package/corpus/core/src/client/index.ts +11 -0
  14. package/corpus/core/src/client/mcp-apps/McpAppRenderer.tsx +54 -0
  15. package/corpus/core/src/client/settings/useBuilderStatus.ts +82 -20
  16. package/corpus/core/src/mcp/build-server.ts +1 -1
  17. package/corpus/core/src/mcp/embed-app.ts +112 -6
  18. package/corpus/core/src/server/attribution.ts +211 -0
  19. package/corpus/core/src/server/auth.ts +6 -0
  20. package/corpus/core/src/server/better-auth-instance.ts +49 -5
  21. package/corpus/core/src/server/builder-browser.ts +27 -1
  22. package/corpus/core/src/server/core-routes-plugin.ts +58 -0
  23. package/corpus/core/src/server/google-oauth.ts +19 -6
  24. package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
  25. package/corpus/core/src/styles/agent-conversation.css +21 -0
  26. package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  27. package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  28. package/corpus/templates/analytics/CHANGELOG.md +10 -0
  29. package/corpus/templates/analytics/actions/install-dashboard-template.ts +2 -2
  30. package/corpus/templates/analytics/actions/open-traffic-dashboard.ts +1 -1
  31. package/corpus/templates/analytics/actions/update-dashboard.ts +5 -3
  32. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +180 -144
  33. package/corpus/templates/analytics/app/components/layout/Header.tsx +2 -2
  34. package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -5
  35. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +221 -213
  36. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +6 -3
  37. package/corpus/templates/analytics/app/lib/demo-dashboard-path.ts +1 -1
  38. package/corpus/templates/analytics/app/lib/last-opened.ts +2 -1
  39. package/corpus/templates/analytics/app/pages/Settings.tsx +4 -0
  40. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -3
  41. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +2 -2
  42. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +101 -99
  43. package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +1 -1
  44. package/corpus/templates/analytics/app/routes/adhoc.$id.tsx +18 -5
  45. package/corpus/templates/analytics/app/routes/catalog.tsx +1 -1
  46. package/corpus/templates/analytics/app/routes/dashboards.$id.tsx +9 -0
  47. package/corpus/templates/analytics/app/routes/traffic.tsx +1 -1
  48. package/corpus/templates/analytics/changelog/2026-06-23-the-dashboards-and-analyses-sidebar-settings-buttons-now-sta.md +6 -0
  49. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +145 -0
  50. package/corpus/templates/analytics/server/db/index.ts +1 -1
  51. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +9 -2
  52. package/corpus/templates/analytics/server/lib/demo-dashboards.ts +1 -1
  53. package/corpus/templates/analytics/server/plugins/agent-chat.ts +2 -2
  54. package/corpus/templates/analytics/server/plugins/core-routes.ts +6 -6
  55. package/corpus/templates/assets/CHANGELOG.md +10 -0
  56. package/corpus/templates/assets/app/root.tsx +7 -1
  57. package/corpus/templates/assets/app/routes/settings.tsx +6 -0
  58. package/corpus/templates/brain/CHANGELOG.md +10 -0
  59. package/corpus/templates/brain/app/root.tsx +7 -1
  60. package/corpus/templates/brain/app/routes/settings.tsx +8 -1
  61. package/corpus/templates/calendar/CHANGELOG.md +10 -0
  62. package/corpus/templates/calendar/app/pages/Settings.tsx +4 -0
  63. package/corpus/templates/calendar/app/root.tsx +7 -1
  64. package/corpus/templates/chat/CHANGELOG.md +10 -0
  65. package/corpus/templates/chat/app/root.tsx +7 -1
  66. package/corpus/templates/clips/CHANGELOG.md +10 -0
  67. package/corpus/templates/clips/app/components/player/share-dialog.tsx +33 -7
  68. package/corpus/templates/clips/app/components/player/sign-in-prompt-dialog.tsx +8 -0
  69. package/corpus/templates/clips/app/components/player/video-player.tsx +150 -20
  70. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +7 -11
  71. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +31 -0
  72. package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +71 -0
  73. package/corpus/templates/clips/app/root.tsx +7 -1
  74. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +15 -1
  75. package/corpus/templates/clips/app/routes/record.tsx +78 -5
  76. package/corpus/templates/clips/app/routes/share.$shareId.tsx +86 -5
  77. package/corpus/templates/clips/changelog/2026-06-23-clips-shared-in-slack-no-longer-show-a-could-not-start-playb.md +6 -0
  78. package/corpus/templates/clips/changelog/2026-06-23-made-the-play-button-on-shared-and-embedded-clips-scale-with.md +6 -0
  79. package/corpus/templates/clips/changelog/2026-06-23-public-clip-share-links-now-show-whether-they-ll-play-inline.md +6 -0
  80. package/corpus/templates/clips/changelog/2026-06-23-screen-camera-recordings-now-keep-the-presenter-s-face-in-th.md +6 -0
  81. package/corpus/templates/clips/desktop/src/lib/recorder.ts +20 -0
  82. package/corpus/templates/clips/desktop/src/main.tsx +3 -0
  83. package/corpus/templates/clips/desktop/src/overlays/region-record-border.tsx +76 -0
  84. package/corpus/templates/clips/desktop/src/styles.css +49 -0
  85. package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
  86. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +67 -1
  87. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -0
  88. package/corpus/templates/clips/desktop/src-tauri/src/util.rs +4 -1
  89. package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +51 -1
  90. package/corpus/templates/clips/shared/share-attribution.ts +79 -0
  91. package/corpus/templates/content/CHANGELOG.md +10 -0
  92. package/corpus/templates/content/app/root.tsx +7 -1
  93. package/corpus/templates/design/CHANGELOG.md +10 -0
  94. package/corpus/templates/design/actions/generate-design.ts +4 -0
  95. package/corpus/templates/design/app/root.tsx +7 -1
  96. package/corpus/templates/design/app/routes/settings.tsx +10 -2
  97. package/corpus/templates/dispatch/CHANGELOG.md +10 -0
  98. package/corpus/templates/dispatch/app/root.tsx +7 -1
  99. package/corpus/templates/forms/CHANGELOG.md +10 -0
  100. package/corpus/templates/forms/app/root.tsx +7 -1
  101. package/corpus/templates/macros/CHANGELOG.md +10 -0
  102. package/corpus/templates/macros/app/root.tsx +7 -1
  103. package/corpus/templates/mail/CHANGELOG.md +10 -0
  104. package/corpus/templates/mail/app/components/layout/CommandPalette.tsx +3 -0
  105. package/corpus/templates/mail/app/pages/SettingsPage.tsx +27 -0
  106. package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +31 -17
  107. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -4
  108. package/corpus/templates/plan/.agents/skills/visual-recap/SKILL.md +34 -24
  109. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -4
  110. package/corpus/templates/plan/AGENTS.md +4 -0
  111. package/corpus/templates/plan/CHANGELOG.md +10 -0
  112. package/corpus/templates/plan/actions/view-screen.ts +5 -0
  113. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +98 -1
  114. package/corpus/templates/plan/app/pages/PlansPage.tsx +107 -7
  115. package/corpus/templates/plan/app/root.tsx +7 -1
  116. package/corpus/templates/plan/shared/share-attribution.ts +72 -0
  117. package/corpus/templates/slides/CHANGELOG.md +10 -0
  118. package/corpus/templates/slides/app/root.tsx +7 -1
  119. package/corpus/templates/videos/CHANGELOG.md +4 -151
  120. package/corpus/templates/videos/app/root.tsx +7 -1
  121. package/dist/changelog/parse.d.ts +70 -0
  122. package/dist/changelog/parse.d.ts.map +1 -0
  123. package/dist/changelog/parse.js +200 -0
  124. package/dist/changelog/parse.js.map +1 -0
  125. package/dist/cli/changelog.d.ts +4 -0
  126. package/dist/cli/changelog.d.ts.map +1 -0
  127. package/dist/cli/changelog.js +166 -0
  128. package/dist/cli/changelog.js.map +1 -0
  129. package/dist/cli/index.js +18 -0
  130. package/dist/cli/index.js.map +1 -1
  131. package/dist/cli/recap.d.ts.map +1 -1
  132. package/dist/cli/recap.js +22 -1
  133. package/dist/cli/recap.js.map +1 -1
  134. package/dist/cli/skills.d.ts +3 -3
  135. package/dist/cli/skills.d.ts.map +1 -1
  136. package/dist/cli/skills.js +72 -45
  137. package/dist/cli/skills.js.map +1 -1
  138. package/dist/client/CommandMenu.d.ts +17 -1
  139. package/dist/client/CommandMenu.d.ts.map +1 -1
  140. package/dist/client/CommandMenu.js +49 -13
  141. package/dist/client/CommandMenu.js.map +1 -1
  142. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  143. package/dist/client/agent-chat-adapter.js +43 -6
  144. package/dist/client/agent-chat-adapter.js.map +1 -1
  145. package/dist/client/analytics.d.ts +18 -0
  146. package/dist/client/analytics.d.ts.map +1 -1
  147. package/dist/client/analytics.js +177 -0
  148. package/dist/client/analytics.js.map +1 -1
  149. package/dist/client/changelog/Changelog.d.ts +47 -0
  150. package/dist/client/changelog/Changelog.d.ts.map +1 -0
  151. package/dist/client/changelog/Changelog.js +138 -0
  152. package/dist/client/changelog/Changelog.js.map +1 -0
  153. package/dist/client/index.d.ts +2 -1
  154. package/dist/client/index.d.ts.map +1 -1
  155. package/dist/client/index.js +2 -1
  156. package/dist/client/index.js.map +1 -1
  157. package/dist/client/mcp-apps/McpAppRenderer.d.ts +1 -0
  158. package/dist/client/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  159. package/dist/client/mcp-apps/McpAppRenderer.js +42 -1
  160. package/dist/client/mcp-apps/McpAppRenderer.js.map +1 -1
  161. package/dist/client/settings/useBuilderStatus.d.ts +2 -0
  162. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  163. package/dist/client/settings/useBuilderStatus.js +58 -10
  164. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  165. package/dist/mcp/build-server.js +1 -1
  166. package/dist/mcp/build-server.js.map +1 -1
  167. package/dist/mcp/embed-app.d.ts.map +1 -1
  168. package/dist/mcp/embed-app.js +112 -6
  169. package/dist/mcp/embed-app.js.map +1 -1
  170. package/dist/server/attribution.d.ts +83 -0
  171. package/dist/server/attribution.d.ts.map +1 -0
  172. package/dist/server/attribution.js +178 -0
  173. package/dist/server/attribution.js.map +1 -0
  174. package/dist/server/auth.d.ts.map +1 -1
  175. package/dist/server/auth.js +7 -0
  176. package/dist/server/auth.js.map +1 -1
  177. package/dist/server/better-auth-instance.d.ts +9 -1
  178. package/dist/server/better-auth-instance.d.ts.map +1 -1
  179. package/dist/server/better-auth-instance.js +32 -2
  180. package/dist/server/better-auth-instance.js.map +1 -1
  181. package/dist/server/builder-browser.d.ts +4 -0
  182. package/dist/server/builder-browser.d.ts.map +1 -1
  183. package/dist/server/builder-browser.js +20 -1
  184. package/dist/server/builder-browser.js.map +1 -1
  185. package/dist/server/core-routes-plugin.d.ts +25 -0
  186. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  187. package/dist/server/core-routes-plugin.js +38 -0
  188. package/dist/server/core-routes-plugin.js.map +1 -1
  189. package/dist/server/google-oauth.d.ts.map +1 -1
  190. package/dist/server/google-oauth.js +16 -2
  191. package/dist/server/google-oauth.js.map +1 -1
  192. package/dist/shared/mcp-embed-headers.js +1 -1
  193. package/dist/shared/mcp-embed-headers.js.map +1 -1
  194. package/dist/styles/agent-conversation.css +21 -0
  195. package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  196. package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  197. package/package.json +1 -1
  198. package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
  199. package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
  200. /package/corpus/templates/analytics/app/routes/{dashboards.tsx → dashboards._index.tsx} +0 -0
@@ -61,6 +61,44 @@ const LLM_CONNECTION_CACHE_TTL_MS = 5 * 60 * 1000;
61
61
  // session over multiple visits.
62
62
  const SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
63
63
 
64
+ // First-touch referral attribution (viral attribution). Captured once on the
65
+ // visitor's first page load and persisted across the signup boundary so the
66
+ // server-side `signup` event can record where the user came from. First-write
67
+ // wins — an existing value is never overwritten.
68
+ const FIRST_TOUCH_STORAGE_KEY = "an_attribution";
69
+ const FIRST_TOUCH_COOKIE_NAME = "an_ft";
70
+ // 30 days, matching the session cookie lifetime — long enough to bridge a
71
+ // "land today, sign up next week" path without retaining attribution forever.
72
+ const FIRST_TOUCH_COOKIE_MAX_AGE_SECONDS = 2592000;
73
+ const FIRST_TOUCH_MAX_FIELD_LENGTH = 120;
74
+ // Keep the serialized cookie well under the ~4KB browser cap; we bail rather
75
+ // than write a runaway cookie if some field combination blows past this.
76
+ const FIRST_TOUCH_MAX_COOKIE_BYTES = 1500;
77
+ const FIRST_TOUCH_QUERY_FIELDS = [
78
+ "ref",
79
+ "via",
80
+ "utm_source",
81
+ "utm_medium",
82
+ "utm_campaign",
83
+ "utm_content",
84
+ "utm_term",
85
+ ] as const;
86
+
87
+ let _firstTouchCaptured = false;
88
+
89
+ export interface FirstTouchAttribution {
90
+ ref?: string;
91
+ via?: string;
92
+ utm_source?: string;
93
+ utm_medium?: string;
94
+ utm_campaign?: string;
95
+ utm_content?: string;
96
+ utm_term?: string;
97
+ landing_path?: string;
98
+ landing_referrer?: string;
99
+ landed_at?: string;
100
+ }
101
+
64
102
  function generateVisitorId(): string {
65
103
  try {
66
104
  if (
@@ -214,6 +252,148 @@ function getOrCreateSessionId(): string | undefined {
214
252
  return id;
215
253
  }
216
254
 
255
+ function truncateFirstTouchField(value: string | null | undefined): string {
256
+ if (!value) return "";
257
+ const trimmed = value.trim();
258
+ if (!trimmed) return "";
259
+ return trimmed.slice(0, FIRST_TOUCH_MAX_FIELD_LENGTH);
260
+ }
261
+
262
+ /**
263
+ * Extract just the host of a referrer URL — never the full URL or query
264
+ * string (those can carry tokens). Returns "" when there's no usable host or
265
+ * the referrer is same-origin (a same-site navigation isn't a referral).
266
+ */
267
+ function scrubReferrerHost(referrer: string | undefined): string {
268
+ if (!referrer) return "";
269
+ try {
270
+ const url = new URL(referrer);
271
+ const host = url.host;
272
+ if (!host) return "";
273
+ if (
274
+ typeof window !== "undefined" &&
275
+ host.toLowerCase() === window.location.host.toLowerCase()
276
+ ) {
277
+ return "";
278
+ }
279
+ return truncateFirstTouchField(host);
280
+ } catch {
281
+ return "";
282
+ }
283
+ }
284
+
285
+ function buildFirstTouchAttribution(): FirstTouchAttribution {
286
+ const attribution: FirstTouchAttribution = {};
287
+ let params: URLSearchParams | null = null;
288
+ try {
289
+ params = new URLSearchParams(window.location.search);
290
+ } catch {
291
+ params = null;
292
+ }
293
+ if (params) {
294
+ for (const field of FIRST_TOUCH_QUERY_FIELDS) {
295
+ const value = truncateFirstTouchField(params.get(field));
296
+ if (value) attribution[field] = value;
297
+ }
298
+ }
299
+ const landingPath = truncateFirstTouchField(window.location.pathname);
300
+ if (landingPath) attribution.landing_path = landingPath;
301
+ const landingReferrer =
302
+ typeof document !== "undefined" ? scrubReferrerHost(document.referrer) : "";
303
+ if (landingReferrer) attribution.landing_referrer = landingReferrer;
304
+ attribution.landed_at = new Date().toISOString();
305
+ return attribution;
306
+ }
307
+
308
+ function readFirstTouchCookie(): string | null {
309
+ if (typeof document === "undefined") return null;
310
+ try {
311
+ const cookies = document.cookie ? document.cookie.split(";") : [];
312
+ for (const part of cookies) {
313
+ const eq = part.indexOf("=");
314
+ if (eq === -1) continue;
315
+ const name = part.slice(0, eq).trim();
316
+ if (name === FIRST_TOUCH_COOKIE_NAME) {
317
+ return part.slice(eq + 1).trim();
318
+ }
319
+ }
320
+ } catch {
321
+ // document.cookie can throw in sandboxed iframes — best-effort.
322
+ }
323
+ return null;
324
+ }
325
+
326
+ function writeFirstTouchCookie(encodedValue: string): void {
327
+ if (typeof document === "undefined") return;
328
+ // Non-sensitive, written by client JS, so no HttpOnly. SameSite=Lax keeps it
329
+ // on top-level navigations (which is how share links arrive) without leaking
330
+ // it to cross-site subresource requests.
331
+ const cookie =
332
+ `${FIRST_TOUCH_COOKIE_NAME}=${encodedValue}; path=/; ` +
333
+ `max-age=${FIRST_TOUCH_COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
334
+ if (cookie.length > FIRST_TOUCH_MAX_COOKIE_BYTES) return;
335
+ try {
336
+ document.cookie = cookie;
337
+ } catch {
338
+ // best-effort
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Capture the visitor's first-touch referral attribution exactly once. Reads
344
+ * the current URL query params + landing info and, IF no attribution is
345
+ * already stored (first-write-wins), persists it to both `localStorage`
346
+ * (`an_attribution`) and the first-party `an_ft` cookie. Fully defensive and
347
+ * SSR-safe — any failure is swallowed so it can never break app boot.
348
+ */
349
+ function captureFirstTouchAttribution(): void {
350
+ if (_firstTouchCaptured) return;
351
+ _firstTouchCaptured = true;
352
+ if (typeof window === "undefined") return;
353
+ try {
354
+ const existing = safeStorageGet(FIRST_TOUCH_STORAGE_KEY);
355
+ if (existing) {
356
+ // Already captured in a prior visit. Backfill the cookie if it expired
357
+ // or was cleared so the signup boundary still sees first-touch data, but
358
+ // never overwrite the stored value itself (first-write-wins).
359
+ if (!readFirstTouchCookie()) {
360
+ try {
361
+ writeFirstTouchCookie(encodeURIComponent(existing));
362
+ } catch {
363
+ // ignore
364
+ }
365
+ }
366
+ return;
367
+ }
368
+ const attribution = buildFirstTouchAttribution();
369
+ const json = JSON.stringify(attribution);
370
+ safeStorageSet(FIRST_TOUCH_STORAGE_KEY, json);
371
+ writeFirstTouchCookie(encodeURIComponent(json));
372
+ } catch {
373
+ // Attribution is best-effort telemetry; never let it break boot.
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Return the parsed first-touch referral attribution captured for this
379
+ * visitor, or `null` when none is stored. Reads from `localStorage`
380
+ * (`an_attribution`). SSR-safe and defensive.
381
+ */
382
+ export function getFirstTouchAttribution(): FirstTouchAttribution | null {
383
+ if (typeof window === "undefined") return null;
384
+ try {
385
+ const raw = safeStorageGet(FIRST_TOUCH_STORAGE_KEY);
386
+ if (!raw) return null;
387
+ const parsed = JSON.parse(raw) as unknown;
388
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
389
+ return null;
390
+ }
391
+ return parsed as FirstTouchAttribution;
392
+ } catch {
393
+ return null;
394
+ }
395
+ }
396
+
217
397
  function isLocalAnalyticsHostname(hostname: string | undefined): boolean {
218
398
  const h = (hostname || "").toLowerCase();
219
399
  return (
@@ -531,6 +711,7 @@ export function configureTracking(options: {
531
711
  if (typeof window !== "undefined") {
532
712
  ensureSentry();
533
713
  ensureAmplitude();
714
+ captureFirstTouchAttribution();
534
715
  installLlmConnectionRefresh();
535
716
  installPageviewTracking();
536
717
  }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Changelog UI — renders an app's CHANGELOG.md as an in-app "What's new"
3
+ * surface. Core owns all of this; a template just passes its own
4
+ * `CHANGELOG.md?raw` content in (Vite inlines it at build time, so this works
5
+ * on every host with no runtime file access or server route).
6
+ *
7
+ * Surfaces:
8
+ * - <ChangelogDialog> a self-contained modal listing every release.
9
+ * - <ChangelogSettingsCard> a settings-page card with the latest updates.
10
+ * - useChangelogSeen() tracks the last release a user has seen (so the
11
+ * command menu can show an "unseen" dot).
12
+ *
13
+ * The command menu's built-in `changelog` prop (see CommandMenu.tsx) wires the
14
+ * dialog automatically — most templates never touch these directly.
15
+ */
16
+
17
+ import React, { useEffect, useMemo, useState } from "react";
18
+ import { IconX, IconHistory } from "@tabler/icons-react";
19
+
20
+ import { parseChangelog, type ChangelogEntry } from "../../changelog/parse.js";
21
+ import {
22
+ markdownModule,
23
+ remarkGfmFn,
24
+ useMarkdownReady,
25
+ markdownUrlTransform,
26
+ } from "../chat/markdown-renderer.js";
27
+ import { cn } from "../utils.js";
28
+
29
+ // ─── Date formatting ──────────────────────────────────────────────────────────
30
+
31
+ function formatEntryHeading(entry: ChangelogEntry): string {
32
+ if (entry.date) {
33
+ // Parse as a plain calendar date (avoid TZ shifting YYYY-MM-DD back a day).
34
+ const [y, m, d] = entry.date.split("-").map(Number);
35
+ if (y && m && d) {
36
+ const formatted = new Date(y, m - 1, d).toLocaleDateString(undefined, {
37
+ year: "numeric",
38
+ month: "long",
39
+ day: "numeric",
40
+ });
41
+ return entry.version ? `${entry.version} · ${formatted}` : formatted;
42
+ }
43
+ }
44
+ return entry.title;
45
+ }
46
+
47
+ // ─── Markdown body ────────────────────────────────────────────────────────────
48
+ // A small, self-contained renderer for a release body. Avoids depending on the
49
+ // typography plugin (`prose`) being generated in the host template by applying
50
+ // explicit utility classes that Tailwind scans from core's compiled output.
51
+
52
+ const changelogMarkdownComponents = {
53
+ h3: (props: React.HTMLAttributes<HTMLHeadingElement>) => (
54
+ <h3
55
+ {...props}
56
+ className="mb-1.5 mt-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground first:mt-0"
57
+ />
58
+ ),
59
+ ul: (props: React.HTMLAttributes<HTMLUListElement>) => (
60
+ <ul
61
+ {...props}
62
+ className="mb-2 ml-1 list-disc space-y-1 pl-4 text-sm text-foreground marker:text-muted-foreground"
63
+ />
64
+ ),
65
+ ol: (props: React.HTMLAttributes<HTMLOListElement>) => (
66
+ <ol
67
+ {...props}
68
+ className="mb-2 ml-1 list-decimal space-y-1 pl-4 text-sm text-foreground marker:text-muted-foreground"
69
+ />
70
+ ),
71
+ li: (props: React.HTMLAttributes<HTMLLIElement>) => (
72
+ <li {...props} className="leading-relaxed" />
73
+ ),
74
+ p: (props: React.HTMLAttributes<HTMLParagraphElement>) => (
75
+ <p {...props} className="mb-2 text-sm leading-relaxed text-foreground" />
76
+ ),
77
+ a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
78
+ <a
79
+ {...props}
80
+ target="_blank"
81
+ rel="noreferrer"
82
+ className="font-medium underline underline-offset-2"
83
+ />
84
+ ),
85
+ code: (props: React.HTMLAttributes<HTMLElement>) => (
86
+ <code
87
+ {...props}
88
+ className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]"
89
+ />
90
+ ),
91
+ };
92
+
93
+ function ChangelogBody({ markdown }: { markdown: string }) {
94
+ const ready = useMarkdownReady();
95
+ const ReactMarkdown = markdownModule?.default;
96
+ const gfm = remarkGfmFn;
97
+
98
+ if (!ready || !ReactMarkdown || !gfm) {
99
+ // The react-markdown chunk loads on module eval; this is typically only one
100
+ // frame. Show readable plain text rather than nothing in the meantime.
101
+ return (
102
+ <div className="whitespace-pre-wrap text-sm text-foreground">
103
+ {markdown}
104
+ </div>
105
+ );
106
+ }
107
+ return (
108
+ <ReactMarkdown
109
+ remarkPlugins={[gfm]}
110
+ components={changelogMarkdownComponents}
111
+ urlTransform={markdownUrlTransform}
112
+ >
113
+ {markdown}
114
+ </ReactMarkdown>
115
+ );
116
+ }
117
+
118
+ // ─── Unseen tracking ──────────────────────────────────────────────────────────
119
+
120
+ function seenStorageKey(appKey: string): string {
121
+ return `an:changelog-seen:${appKey}`;
122
+ }
123
+
124
+ /**
125
+ * Tracks the latest release a user has already seen (per browser, via
126
+ * localStorage). Returns whether there's an unseen release and a `markSeen`
127
+ * callback to clear the indicator once the changelog is opened.
128
+ */
129
+ export function useChangelogSeen(
130
+ appKey: string,
131
+ latestId: string | undefined,
132
+ ): { unseen: boolean; markSeen: () => void } {
133
+ const [seenId, setSeenId] = useState<string | null>(null);
134
+ const [hydrated, setHydrated] = useState(false);
135
+
136
+ useEffect(() => {
137
+ try {
138
+ setSeenId(window.localStorage.getItem(seenStorageKey(appKey)));
139
+ } catch {
140
+ // Private mode / disabled storage — treat as "nothing seen yet".
141
+ }
142
+ setHydrated(true);
143
+ }, [appKey]);
144
+
145
+ const markSeen = React.useCallback(() => {
146
+ if (!latestId) return;
147
+ setSeenId(latestId);
148
+ try {
149
+ window.localStorage.setItem(seenStorageKey(appKey), latestId);
150
+ } catch {
151
+ // Ignore storage failures; the dot just won't persist.
152
+ }
153
+ }, [appKey, latestId]);
154
+
155
+ // Don't flag "unseen" until hydrated, and never on a first-ever visit (no
156
+ // stored value) — only once the user has seen *something* and a newer
157
+ // release appears. This avoids nagging brand-new users.
158
+ const unseen =
159
+ hydrated && !!latestId && seenId !== null && seenId !== latestId;
160
+
161
+ return { unseen, markSeen };
162
+ }
163
+
164
+ // ─── Shared markup ────────────────────────────────────────────────────────────
165
+
166
+ function ChangelogEntries({
167
+ entries,
168
+ emptyText,
169
+ }: {
170
+ entries: ChangelogEntry[];
171
+ emptyText: string;
172
+ }) {
173
+ if (entries.length === 0) {
174
+ return <p className="text-sm text-muted-foreground">{emptyText}</p>;
175
+ }
176
+ return (
177
+ <div className="space-y-6">
178
+ {entries.map((entry) => (
179
+ <section key={entry.id}>
180
+ <h4 className="mb-2 text-sm font-semibold text-foreground">
181
+ {formatEntryHeading(entry)}
182
+ </h4>
183
+ <ChangelogBody markdown={entry.body} />
184
+ </section>
185
+ ))}
186
+ </div>
187
+ );
188
+ }
189
+
190
+ // ─── Dialog ───────────────────────────────────────────────────────────────────
191
+
192
+ export interface ChangelogDialogProps {
193
+ open: boolean;
194
+ onOpenChange: (open: boolean) => void;
195
+ /** Raw CHANGELOG.md contents (e.g. `import md from "../CHANGELOG.md?raw"`). */
196
+ markdown: string;
197
+ /** Dialog heading. Default: "What's new". */
198
+ title?: string;
199
+ }
200
+
201
+ export function ChangelogDialog({
202
+ open,
203
+ onOpenChange,
204
+ markdown,
205
+ title = "What's new",
206
+ }: ChangelogDialogProps) {
207
+ const entries = useMemo(() => parseChangelog(markdown), [markdown]);
208
+
209
+ useEffect(() => {
210
+ if (!open) return;
211
+ const onKey = (e: KeyboardEvent) => {
212
+ if (e.key === "Escape") {
213
+ e.preventDefault();
214
+ onOpenChange(false);
215
+ }
216
+ };
217
+ document.addEventListener("keydown", onKey);
218
+ return () => document.removeEventListener("keydown", onKey);
219
+ }, [open, onOpenChange]);
220
+
221
+ if (!open) return null;
222
+
223
+ return (
224
+ <div
225
+ className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4"
226
+ onMouseDown={(e) => {
227
+ if (e.target === e.currentTarget) onOpenChange(false);
228
+ }}
229
+ >
230
+ <div
231
+ role="dialog"
232
+ aria-modal="true"
233
+ aria-label={title}
234
+ className="mt-[8vh] flex max-h-[80vh] w-full max-w-xl flex-col rounded-lg border border-border bg-popover text-popover-foreground shadow-lg"
235
+ >
236
+ <div className="flex items-center justify-between border-b border-border px-5 py-3.5">
237
+ <div className="flex items-center gap-2">
238
+ <IconHistory className="h-4 w-4 text-muted-foreground" />
239
+ <h3 className="text-sm font-semibold">{title}</h3>
240
+ </div>
241
+ <button
242
+ type="button"
243
+ onClick={() => onOpenChange(false)}
244
+ aria-label="Close"
245
+ className="rounded-sm p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
246
+ >
247
+ <IconX className="h-4 w-4" />
248
+ </button>
249
+ </div>
250
+ <div className="overflow-y-auto px-5 py-4">
251
+ <ChangelogEntries
252
+ entries={entries}
253
+ emptyText="No updates have been published yet."
254
+ />
255
+ </div>
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ // ─── Settings card ────────────────────────────────────────────────────────────
262
+
263
+ export interface ChangelogSettingsCardProps {
264
+ /** Raw CHANGELOG.md contents (e.g. `import md from "../CHANGELOG.md?raw"`). */
265
+ markdown: string;
266
+ /** How many recent releases to show inline before "View all". Default: 2. */
267
+ limit?: number;
268
+ /** Card heading. Default: "What's new". */
269
+ title?: string;
270
+ className?: string;
271
+ }
272
+
273
+ export function ChangelogSettingsCard({
274
+ markdown,
275
+ limit = 2,
276
+ title = "What's new",
277
+ className,
278
+ }: ChangelogSettingsCardProps) {
279
+ const entries = useMemo(() => parseChangelog(markdown), [markdown]);
280
+ const [dialogOpen, setDialogOpen] = useState(false);
281
+
282
+ if (entries.length === 0) return null;
283
+
284
+ const shown = entries.slice(0, limit);
285
+ const hasMore = entries.length > shown.length;
286
+
287
+ return (
288
+ <div
289
+ className={cn(
290
+ "rounded-lg border border-border bg-card text-card-foreground",
291
+ className,
292
+ )}
293
+ >
294
+ <div className="flex items-center gap-2 border-b border-border px-5 py-4">
295
+ <IconHistory className="h-4 w-4 text-muted-foreground" />
296
+ <h3 className="text-sm font-semibold">{title}</h3>
297
+ </div>
298
+ <div className="px-5 py-4">
299
+ <ChangelogEntries entries={shown} emptyText="No updates yet." />
300
+ {hasMore && (
301
+ <button
302
+ type="button"
303
+ onClick={() => setDialogOpen(true)}
304
+ className="mt-4 text-sm font-medium text-foreground underline underline-offset-2"
305
+ >
306
+ View all updates
307
+ </button>
308
+ )}
309
+ </div>
310
+ <ChangelogDialog
311
+ open={dialogOpen}
312
+ onOpenChange={setDialogOpen}
313
+ markdown={markdown}
314
+ title={title}
315
+ />
316
+ </div>
317
+ );
318
+ }
319
+
320
+ export { parseChangelog };
321
+ export type { ChangelogEntry };
@@ -643,10 +643,12 @@ export {
643
643
  trackEvent,
644
644
  trackSessionStatus,
645
645
  configureTracking,
646
+ getFirstTouchAttribution,
646
647
  setSentryUser,
647
648
  captureError,
648
649
  captureClientException,
649
650
  type ClientCaptureContext,
651
+ type FirstTouchAttribution,
650
652
  } from "./analytics.js";
651
653
  export { track } from "./track.js";
652
654
  export {
@@ -713,6 +715,15 @@ export {
713
715
  type CommandItemProps,
714
716
  type CommandShortcutProps,
715
717
  } from "./CommandMenu.js";
718
+ export {
719
+ ChangelogDialog,
720
+ ChangelogSettingsCard,
721
+ useChangelogSeen,
722
+ parseChangelog,
723
+ type ChangelogDialogProps,
724
+ type ChangelogSettingsCardProps,
725
+ type ChangelogEntry,
726
+ } from "./changelog/Changelog.js";
716
727
  export {
717
728
  DevOverlay,
718
729
  useDevOverlayShortcut,
@@ -13,6 +13,7 @@ import { agentNativePath } from "../api-path.js";
13
13
  import { cn } from "../utils.js";
14
14
 
15
15
  export const DEFAULT_MCP_APP_IFRAME_HEIGHT = 650;
16
+ export const MCP_APP_INITIALIZE_TIMEOUT_MS = 8000;
16
17
  const MIN_IFRAME_HEIGHT = 220;
17
18
  const VIEWPORT_MARGIN = 16;
18
19
  const SANDBOX_FLAGS = "allow-scripts allow-forms allow-popups";
@@ -46,6 +47,7 @@ export function McpAppRenderer({ app, className }: McpAppRendererProps) {
46
47
  () => (resourceHtml ? injectCsp(resourceHtml, csp) : ""),
47
48
  [resourceHtml, csp],
48
49
  );
50
+ const externalOpenUrl = useMemo(() => openUrlFromMcpApp(app), [app]);
49
51
 
50
52
  useEffect(() => {
51
53
  desiredHeightRef.current = DEFAULT_MCP_APP_IFRAME_HEIGHT;
@@ -115,6 +117,11 @@ export function McpAppRenderer({ app, className }: McpAppRendererProps) {
115
117
  if (!iframe?.contentWindow || !srcDoc || loadedSrcDoc !== srcDoc) return;
116
118
 
117
119
  let closed = false;
120
+ const initializeTimer = window.setTimeout(() => {
121
+ if (closed) return;
122
+ setReady(false);
123
+ setError("MCP App did not finish initializing.");
124
+ }, MCP_APP_INITIALIZE_TIMEOUT_MS);
118
125
  const bridge = new AppBridge(
119
126
  null,
120
127
  { name: "Agent Native", version: "1.0.0" },
@@ -144,7 +151,11 @@ export function McpAppRenderer({ app, className }: McpAppRendererProps) {
144
151
  });
145
152
  bridge.addEventListener("initialized", () => {
146
153
  if (closed) return;
154
+ clearTimeout(initializeTimer);
147
155
  setReady(true);
156
+ // Clear any error the initialize timeout may have set before a slow app
157
+ // finished — otherwise the error overlay stays stuck over a working app.
158
+ setError(null);
148
159
  void bridge.sendToolInput({ arguments: app.toolInput });
149
160
  void bridge.sendToolResult(app.toolResult as CallToolResult);
150
161
  });
@@ -194,12 +205,14 @@ export function McpAppRenderer({ app, className }: McpAppRendererProps) {
194
205
  setError(null);
195
206
  bridge.connect(transport).catch((err: any) => {
196
207
  if (!closed) {
208
+ clearTimeout(initializeTimer);
197
209
  setError(err?.message ?? "Failed to initialize MCP App.");
198
210
  }
199
211
  });
200
212
 
201
213
  return () => {
202
214
  closed = true;
215
+ clearTimeout(initializeTimer);
203
216
  setReady(false);
204
217
  void bridge
205
218
  .teardownResource({}, { timeout: 500 })
@@ -244,6 +257,17 @@ export function McpAppRenderer({ app, className }: McpAppRendererProps) {
244
257
  <div className="agent-mcp-app__error">
245
258
  <IconAlertTriangle size={15} />
246
259
  <span>{error}</span>
260
+ {externalOpenUrl && (
261
+ <button
262
+ type="button"
263
+ className="agent-mcp-app__open"
264
+ onClick={() =>
265
+ window.open(externalOpenUrl, "_blank", "noopener,noreferrer")
266
+ }
267
+ >
268
+ Open in new tab
269
+ </button>
270
+ )}
247
271
  </div>
248
272
  )}
249
273
  <iframe
@@ -281,6 +305,36 @@ function htmlFromResource(
281
305
  }
282
306
  }
283
307
 
308
+ function recordValue(value: unknown): Record<string, unknown> | null {
309
+ return value && typeof value === "object" && !Array.isArray(value)
310
+ ? (value as Record<string, unknown>)
311
+ : null;
312
+ }
313
+
314
+ function openUrlRecordValue(value: unknown): string | null {
315
+ const record = recordValue(value);
316
+ const webUrl = record?.webUrl;
317
+ return typeof webUrl === "string" && isSafeExternalUrl(webUrl)
318
+ ? webUrl
319
+ : null;
320
+ }
321
+
322
+ function openUrlFromMcpApp(app: AgentMcpAppPayload): string | null {
323
+ const result = recordValue(app.toolResult);
324
+ const meta = recordValue(result?._meta);
325
+ const fromMeta = openUrlRecordValue(meta?.["agent-native/openLink"]);
326
+ if (fromMeta) return fromMeta;
327
+
328
+ const structuredContent = recordValue(result?.structuredContent);
329
+ const fromStructured = openUrlRecordValue(structuredContent?.openLink);
330
+ if (fromStructured) return fromStructured;
331
+
332
+ const directUrl = structuredContent?.url ?? result?.url;
333
+ return typeof directUrl === "string" && isSafeExternalUrl(directUrl)
334
+ ? directUrl
335
+ : null;
336
+ }
337
+
284
338
  export function supportedMcpAppPermissions(
285
339
  permissions: McpUiResourcePermissions | undefined,
286
340
  ): McpUiResourcePermissions {