@agent-native/core 0.68.3 → 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.
- package/corpus/README.md +2 -2
- package/corpus/core/CHANGELOG.md +54 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/changelog/parse.ts +245 -0
- package/corpus/core/src/cli/changelog.ts +199 -0
- package/corpus/core/src/cli/index.ts +19 -0
- package/corpus/core/src/cli/recap.ts +24 -1
- package/corpus/core/src/cli/skills.ts +72 -45
- package/corpus/core/src/client/CommandMenu.tsx +183 -75
- package/corpus/core/src/client/agent-chat-adapter.ts +44 -7
- package/corpus/core/src/client/analytics.ts +181 -0
- package/corpus/core/src/client/changelog/Changelog.tsx +321 -0
- package/corpus/core/src/client/index.ts +11 -0
- package/corpus/core/src/client/mcp-apps/McpAppRenderer.tsx +54 -0
- package/corpus/core/src/client/settings/useBuilderStatus.ts +82 -20
- package/corpus/core/src/extensions/web-search-tool.ts +45 -4
- package/corpus/core/src/mcp/build-server.ts +1 -1
- package/corpus/core/src/mcp/embed-app.ts +112 -6
- package/corpus/core/src/secrets/register-framework-secrets.ts +7 -0
- package/corpus/core/src/server/attribution.ts +211 -0
- package/corpus/core/src/server/auth.ts +6 -0
- package/corpus/core/src/server/better-auth-instance.ts +49 -5
- package/corpus/core/src/server/builder-browser.ts +27 -1
- package/corpus/core/src/server/core-routes-plugin.ts +58 -0
- package/corpus/core/src/server/google-oauth.ts +19 -6
- package/corpus/core/src/shared/mcp-embed-headers.ts +1 -1
- package/corpus/core/src/styles/agent-conversation.css +21 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
- package/corpus/templates/analytics/CHANGELOG.md +10 -0
- package/corpus/templates/analytics/actions/install-dashboard-template.ts +2 -2
- package/corpus/templates/analytics/actions/open-traffic-dashboard.ts +1 -1
- package/corpus/templates/analytics/actions/update-dashboard.ts +5 -3
- package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +180 -144
- package/corpus/templates/analytics/app/components/layout/Header.tsx +2 -2
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -5
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +221 -213
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +6 -3
- package/corpus/templates/analytics/app/lib/demo-dashboard-path.ts +1 -1
- package/corpus/templates/analytics/app/lib/last-opened.ts +2 -1
- package/corpus/templates/analytics/app/pages/Settings.tsx +4 -0
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -3
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +2 -2
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +101 -99
- package/corpus/templates/analytics/app/pages/overview/OverviewPage.tsx +1 -1
- package/corpus/templates/analytics/app/routes/adhoc.$id.tsx +18 -5
- package/corpus/templates/analytics/app/routes/catalog.tsx +1 -1
- package/corpus/templates/analytics/app/routes/dashboards.$id.tsx +9 -0
- package/corpus/templates/analytics/app/routes/traffic.tsx +1 -1
- package/corpus/templates/analytics/changelog/2026-06-23-the-dashboards-and-analyses-sidebar-settings-buttons-now-sta.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +145 -0
- package/corpus/templates/analytics/server/db/index.ts +1 -1
- package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +9 -2
- package/corpus/templates/analytics/server/lib/demo-dashboards.ts +1 -1
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +2 -2
- package/corpus/templates/analytics/server/plugins/core-routes.ts +6 -6
- package/corpus/templates/assets/CHANGELOG.md +10 -0
- package/corpus/templates/assets/app/root.tsx +7 -1
- package/corpus/templates/assets/app/routes/settings.tsx +6 -0
- package/corpus/templates/brain/CHANGELOG.md +10 -0
- package/corpus/templates/brain/app/root.tsx +7 -1
- package/corpus/templates/brain/app/routes/settings.tsx +8 -1
- package/corpus/templates/calendar/CHANGELOG.md +10 -0
- package/corpus/templates/calendar/app/pages/Settings.tsx +4 -0
- package/corpus/templates/calendar/app/root.tsx +7 -1
- package/corpus/templates/chat/CHANGELOG.md +10 -0
- package/corpus/templates/chat/app/root.tsx +7 -1
- package/corpus/templates/clips/CHANGELOG.md +10 -0
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +33 -7
- package/corpus/templates/clips/app/components/player/sign-in-prompt-dialog.tsx +8 -0
- package/corpus/templates/clips/app/components/player/video-player.tsx +150 -20
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +72 -20
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +69 -2
- package/corpus/templates/clips/app/components/sharing/slack-share-hint.tsx +71 -0
- package/corpus/templates/clips/app/lib/recorder-preferences.ts +67 -0
- package/corpus/templates/clips/app/root.tsx +7 -1
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +15 -1
- package/corpus/templates/clips/app/routes/_app.trash.tsx +21 -0
- package/corpus/templates/clips/app/routes/record.tsx +92 -8
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +86 -5
- package/corpus/templates/clips/changelog/2026-06-23-clips-shared-in-slack-no-longer-show-a-could-not-start-playb.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-made-the-play-button-on-shared-and-embedded-clips-scale-with.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-public-clip-share-links-now-show-whether-they-ll-play-inline.md +6 -0
- package/corpus/templates/clips/changelog/2026-06-23-screen-camera-recordings-now-keep-the-presenter-s-face-in-th.md +6 -0
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +20 -0
- package/corpus/templates/clips/desktop/src/main.tsx +3 -0
- package/corpus/templates/clips/desktop/src/overlays/region-record-border.tsx +76 -0
- package/corpus/templates/clips/desktop/src/styles.css +49 -0
- package/corpus/templates/clips/desktop/src-tauri/capabilities/default.json +2 -1
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +67 -1
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +2 -0
- package/corpus/templates/clips/desktop/src-tauri/src/util.rs +4 -1
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +51 -1
- package/corpus/templates/clips/shared/share-attribution.ts +79 -0
- package/corpus/templates/content/CHANGELOG.md +10 -0
- package/corpus/templates/content/app/root.tsx +7 -1
- package/corpus/templates/content/vitest.config.ts +3 -0
- package/corpus/templates/design/CHANGELOG.md +10 -0
- package/corpus/templates/design/actions/generate-design.ts +4 -0
- package/corpus/templates/design/app/root.tsx +7 -1
- package/corpus/templates/design/app/routes/settings.tsx +10 -2
- package/corpus/templates/dispatch/CHANGELOG.md +10 -0
- package/corpus/templates/dispatch/app/root.tsx +7 -1
- package/corpus/templates/forms/CHANGELOG.md +10 -0
- package/corpus/templates/forms/app/root.tsx +7 -1
- package/corpus/templates/macros/CHANGELOG.md +10 -0
- package/corpus/templates/macros/app/root.tsx +7 -1
- package/corpus/templates/mail/CHANGELOG.md +10 -0
- package/corpus/templates/mail/app/components/layout/CommandPalette.tsx +3 -0
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +27 -0
- package/corpus/templates/plan/.agents/skills/visual-plan/SKILL.md +31 -17
- package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +7 -4
- package/corpus/templates/plan/.agents/skills/visual-recap/SKILL.md +34 -24
- package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +7 -4
- package/corpus/templates/plan/AGENTS.md +4 -0
- package/corpus/templates/plan/CHANGELOG.md +10 -0
- package/corpus/templates/plan/actions/view-screen.ts +5 -0
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +98 -1
- package/corpus/templates/plan/app/pages/PlansPage.tsx +107 -7
- package/corpus/templates/plan/app/root.tsx +7 -1
- package/corpus/templates/plan/shared/share-attribution.ts +72 -0
- package/corpus/templates/slides/CHANGELOG.md +10 -0
- package/corpus/templates/slides/app/root.tsx +7 -1
- package/corpus/templates/videos/CHANGELOG.md +4 -151
- package/corpus/templates/videos/app/root.tsx +7 -1
- package/dist/changelog/parse.d.ts +70 -0
- package/dist/changelog/parse.d.ts.map +1 -0
- package/dist/changelog/parse.js +200 -0
- package/dist/changelog/parse.js.map +1 -0
- package/dist/cli/changelog.d.ts +4 -0
- package/dist/cli/changelog.d.ts.map +1 -0
- package/dist/cli/changelog.js +166 -0
- package/dist/cli/changelog.js.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +22 -1
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/skills.d.ts +3 -3
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +72 -45
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/CommandMenu.d.ts +17 -1
- package/dist/client/CommandMenu.d.ts.map +1 -1
- package/dist/client/CommandMenu.js +49 -13
- package/dist/client/CommandMenu.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +43 -6
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/analytics.d.ts +18 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js +177 -0
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/changelog/Changelog.d.ts +47 -0
- package/dist/client/changelog/Changelog.d.ts.map +1 -0
- package/dist/client/changelog/Changelog.js +138 -0
- package/dist/client/changelog/Changelog.js.map +1 -0
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +2 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/mcp-apps/McpAppRenderer.d.ts +1 -0
- package/dist/client/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/client/mcp-apps/McpAppRenderer.js +42 -1
- package/dist/client/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/client/settings/useBuilderStatus.d.ts +2 -0
- package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
- package/dist/client/settings/useBuilderStatus.js +58 -10
- package/dist/client/settings/useBuilderStatus.js.map +1 -1
- package/dist/extensions/web-search-tool.d.ts +5 -3
- package/dist/extensions/web-search-tool.d.ts.map +1 -1
- package/dist/extensions/web-search-tool.js +36 -4
- package/dist/extensions/web-search-tool.js.map +1 -1
- package/dist/mcp/build-server.js +1 -1
- package/dist/mcp/build-server.js.map +1 -1
- package/dist/mcp/embed-app.d.ts.map +1 -1
- package/dist/mcp/embed-app.js +112 -6
- package/dist/mcp/embed-app.js.map +1 -1
- package/dist/secrets/register-framework-secrets.d.ts.map +1 -1
- package/dist/secrets/register-framework-secrets.js +6 -0
- package/dist/secrets/register-framework-secrets.js.map +1 -1
- package/dist/server/attribution.d.ts +83 -0
- package/dist/server/attribution.d.ts.map +1 -0
- package/dist/server/attribution.js +178 -0
- package/dist/server/attribution.js.map +1 -0
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +7 -0
- package/dist/server/auth.js.map +1 -1
- package/dist/server/better-auth-instance.d.ts +9 -1
- package/dist/server/better-auth-instance.d.ts.map +1 -1
- package/dist/server/better-auth-instance.js +32 -2
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/builder-browser.d.ts +4 -0
- package/dist/server/builder-browser.d.ts.map +1 -1
- package/dist/server/builder-browser.js +20 -1
- package/dist/server/builder-browser.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts +25 -0
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +38 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/google-oauth.d.ts.map +1 -1
- package/dist/server/google-oauth.js +16 -2
- package/dist/server/google-oauth.js.map +1 -1
- package/dist/shared/mcp-embed-headers.js +1 -1
- package/dist/shared/mcp-embed-headers.js.map +1 -1
- package/dist/styles/agent-conversation.css +21 -0
- package/dist/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
- package/dist/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
- package/package.json +1 -1
- package/src/templates/workspace-core/.agents/skills/changelog/SKILL.md +111 -0
- package/src/templates/workspace-core/.agents/skills/tracking/SKILL.md +46 -14
- /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 {
|