@agent-native/core 0.77.4 → 0.77.7

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 (192) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +31 -0
  3. package/corpus/core/package.json +3 -3
  4. package/corpus/core/src/agent/production-agent.ts +35 -0
  5. package/corpus/core/src/agent/run-store.ts +2 -0
  6. package/corpus/core/src/agent/thread-data-builder.ts +21 -2
  7. package/corpus/core/src/cli/create.ts +35 -1
  8. package/corpus/core/src/cli/skills.ts +8 -0
  9. package/corpus/core/src/client/AgentPanel.tsx +57 -25
  10. package/corpus/core/src/client/MultiTabAssistantChat.tsx +31 -1
  11. package/corpus/core/src/client/analytics.ts +24 -2
  12. package/corpus/core/src/client/changelog/Changelog.tsx +22 -10
  13. package/corpus/core/src/client/extensions/ExtensionsSidebarSection.tsx +405 -35
  14. package/corpus/core/src/client/use-agent-engine-configured.ts +29 -15
  15. package/corpus/core/src/client/use-chat-threads.ts +104 -55
  16. package/corpus/core/src/server/analytics.ts +27 -3
  17. package/corpus/core/src/sharing/access.ts +35 -13
  18. package/corpus/core/src/sharing/actions/share-resource.ts +32 -9
  19. package/corpus/core/src/sharing/actions/unshare-resource.ts +25 -2
  20. package/corpus/core/src/sharing/registry.ts +9 -0
  21. package/corpus/core/src/vite/client.ts +3 -0
  22. package/corpus/templates/analytics/actions/data-source-status.ts +25 -14
  23. package/corpus/templates/analytics/app/components/layout/NewAnalysisDialog.tsx +2 -2
  24. package/corpus/templates/analytics/app/lib/data-source-status.ts +47 -6
  25. package/corpus/templates/analytics/app/lib/data-sources.ts +14 -3
  26. package/corpus/templates/analytics/app/pages/DataSources.tsx +24 -5
  27. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/PanelEditorDialog.tsx +15 -43
  28. package/corpus/templates/analytics/changelog/2026-06-25-fixed-hubspot-data-source-status-so-private-app-tokens-legac.md +6 -0
  29. package/corpus/templates/analytics/netlify.toml +1 -0
  30. package/corpus/templates/analytics/server/lib/credential-keys.ts +3 -1
  31. package/corpus/templates/analytics/server/routes/api/test-connection.post.ts +12 -3
  32. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +5 -2
  33. package/corpus/templates/assets/changelog/2026-06-25-create-now-returns-from-agent-settings-to-the-asset-chat-eve.md +6 -0
  34. package/corpus/templates/assets/netlify.toml +1 -0
  35. package/corpus/templates/brain/netlify.toml +1 -0
  36. package/corpus/templates/calendar/app/i18n-data.ts +12 -0
  37. package/corpus/templates/calendar/app/lib/clipboard.ts +58 -0
  38. package/corpus/templates/calendar/app/pages/AvailabilitySettings.tsx +15 -9
  39. package/corpus/templates/calendar/app/pages/BookingLinksPage.tsx +6 -2
  40. package/corpus/templates/calendar/changelog/2026-06-25-booking-link-copy-buttons-no-longer-fail-when-clipboard-perm.md +6 -0
  41. package/corpus/templates/calendar/netlify.toml +1 -0
  42. package/corpus/templates/chat/netlify.toml +1 -0
  43. package/corpus/templates/clips/AGENTS.md +9 -7
  44. package/corpus/templates/clips/actions/add-vocabulary-term.ts +2 -2
  45. package/corpus/templates/clips/actions/archive-recording.ts +5 -2
  46. package/corpus/templates/clips/actions/clear-edits.ts +5 -2
  47. package/corpus/templates/clips/actions/create-folder.ts +7 -2
  48. package/corpus/templates/clips/actions/delete-folder.ts +7 -4
  49. package/corpus/templates/clips/actions/delete-recording-permanent.ts +5 -2
  50. package/corpus/templates/clips/actions/finalize-recording.ts +5 -2
  51. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -1
  52. package/corpus/templates/clips/actions/list-notifications.ts +9 -5
  53. package/corpus/templates/clips/actions/list-organization-state.ts +2 -1
  54. package/corpus/templates/clips/actions/list-recordings.ts +4 -1
  55. package/corpus/templates/clips/actions/move-recording.ts +5 -1
  56. package/corpus/templates/clips/actions/rename-folder.ts +6 -3
  57. package/corpus/templates/clips/actions/request-transcript.ts +5 -2
  58. package/corpus/templates/clips/actions/restore-recording.ts +5 -2
  59. package/corpus/templates/clips/actions/save-browser-diagnostics.ts +21 -8
  60. package/corpus/templates/clips/actions/set-chapters.ts +5 -2
  61. package/corpus/templates/clips/actions/set-thumbnail.ts +5 -2
  62. package/corpus/templates/clips/actions/split-recording.ts +5 -2
  63. package/corpus/templates/clips/actions/stitch-recordings.ts +6 -2
  64. package/corpus/templates/clips/actions/trash-recording.ts +5 -2
  65. package/corpus/templates/clips/actions/undo-edit.ts +5 -2
  66. package/corpus/templates/clips/actions/view-screen.ts +2 -1
  67. package/corpus/templates/clips/app/components/library/recording-card.tsx +24 -7
  68. package/corpus/templates/clips/app/i18n/ar-SA.ts +14 -0
  69. package/corpus/templates/clips/app/i18n/de-DE.ts +14 -0
  70. package/corpus/templates/clips/app/i18n/en-US.ts +14 -0
  71. package/corpus/templates/clips/app/i18n/es-ES.ts +14 -0
  72. package/corpus/templates/clips/app/i18n/fr-FR.ts +14 -0
  73. package/corpus/templates/clips/app/i18n/hi-IN.ts +14 -0
  74. package/corpus/templates/clips/app/i18n/ja-JP.ts +14 -0
  75. package/corpus/templates/clips/app/i18n/ko-KR.ts +14 -0
  76. package/corpus/templates/clips/app/i18n/pt-BR.ts +14 -0
  77. package/corpus/templates/clips/app/i18n/zh-CN.ts +14 -0
  78. package/corpus/templates/clips/app/lib/browser-diagnostics-capture.ts +5 -1
  79. package/corpus/templates/clips/app/root.tsx +2 -2
  80. package/corpus/templates/clips/app/routes/_app.extensions.$id.tsx +4 -1
  81. package/corpus/templates/clips/app/routes/_app.extensions._index.tsx +4 -1
  82. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +12 -5
  83. package/corpus/templates/clips/changelog/2026-06-25-chrome-extension-recordings-now-upload-large-capture-bursts.md +6 -0
  84. package/corpus/templates/clips/changelog/2026-06-25-console-diagnostics-keep-more-useful-non-secret-details-when.md +6 -0
  85. package/corpus/templates/clips/changelog/2026-06-25-fixed-owner-recognition-so-clips-created-from-desktop-and-we.md +6 -0
  86. package/corpus/templates/clips/changelog/2026-06-25-localized-clips-settings-changelog-and-extension-sidebar-lab.md +6 -0
  87. package/corpus/templates/clips/chrome-extension/package.json +3 -0
  88. package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
  89. package/corpus/templates/clips/chrome-extension/src/background.ts +85 -9
  90. package/corpus/templates/clips/chrome-extension/src/content-script.ts +55 -0
  91. package/corpus/templates/clips/chrome-extension/src/offscreen.ts +79 -3
  92. package/corpus/templates/clips/chrome-extension/src/options.ts +10 -0
  93. package/corpus/templates/clips/chrome-extension/src/overlay.ts +6 -0
  94. package/corpus/templates/clips/chrome-extension/src/permission.ts +8 -1
  95. package/corpus/templates/clips/chrome-extension/src/popup.ts +65 -39
  96. package/corpus/templates/clips/chrome-extension/src/sentry.ts +206 -0
  97. package/corpus/templates/clips/chrome-extension/vite.config.ts +47 -0
  98. package/corpus/templates/clips/desktop/src/overlays/countdown.tsx +4 -2
  99. package/corpus/templates/clips/desktop/src/styles.css +15 -1
  100. package/corpus/templates/clips/netlify.toml +1 -0
  101. package/corpus/templates/clips/server/db/index.ts +1 -0
  102. package/corpus/templates/clips/server/lib/google-calendar-oauth.ts +35 -23
  103. package/corpus/templates/clips/server/lib/public-agent-context.ts +8 -1
  104. package/corpus/templates/clips/server/lib/recordings.ts +18 -2
  105. package/corpus/templates/clips/server/lib/slack-oauth.ts +9 -6
  106. package/corpus/templates/clips/server/plugins/onboarding-calendar.ts +6 -2
  107. package/corpus/templates/clips/server/routes/api/public-recording.get.ts +2 -2
  108. package/corpus/templates/clips/server/routes/api/recordings/[recordingId]/thumbnail.post.ts +5 -2
  109. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +5 -2
  110. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +5 -2
  111. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +5 -2
  112. package/corpus/templates/clips/shared/browser-diagnostics.ts +14 -4
  113. package/corpus/templates/content/netlify.toml +1 -0
  114. package/corpus/templates/design/netlify.toml +1 -0
  115. package/corpus/templates/dispatch/netlify.toml +1 -0
  116. package/corpus/templates/forms/netlify.toml +1 -0
  117. package/corpus/templates/macros/netlify.toml +1 -0
  118. package/corpus/templates/mail/app/components/email/EmailList.tsx +7 -3
  119. package/corpus/templates/mail/app/components/email/EmailThread.tsx +4 -1
  120. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +10 -2
  121. package/corpus/templates/mail/app/hooks/use-compose-state.ts +15 -5
  122. package/corpus/templates/mail/app/hooks/use-google-auth.ts +47 -9
  123. package/corpus/templates/mail/changelog/2026-06-25-account-avatars-stay-stable-while-mail-refreshes-google-acco.md +6 -0
  124. package/corpus/templates/mail/changelog/2026-06-25-mail-background-draft-saves-and-thread-prefetches-no-longer.md +6 -0
  125. package/corpus/templates/mail/netlify.toml +1 -0
  126. package/corpus/templates/plan/.agents/skills/visual-plan/references/canvas.md +8 -0
  127. package/corpus/templates/plan/netlify.toml +1 -0
  128. package/corpus/templates/slides/netlify.toml +1 -0
  129. package/corpus/templates/videos/netlify.toml +1 -0
  130. package/dist/agent/production-agent.d.ts.map +1 -1
  131. package/dist/agent/production-agent.js +31 -0
  132. package/dist/agent/production-agent.js.map +1 -1
  133. package/dist/agent/run-store.d.ts +2 -0
  134. package/dist/agent/run-store.d.ts.map +1 -1
  135. package/dist/agent/run-store.js +2 -0
  136. package/dist/agent/run-store.js.map +1 -1
  137. package/dist/agent/thread-data-builder.d.ts.map +1 -1
  138. package/dist/agent/thread-data-builder.js +23 -2
  139. package/dist/agent/thread-data-builder.js.map +1 -1
  140. package/dist/cli/create.d.ts.map +1 -1
  141. package/dist/cli/create.js +28 -1
  142. package/dist/cli/create.js.map +1 -1
  143. package/dist/cli/skills.d.ts +1 -1
  144. package/dist/cli/skills.d.ts.map +1 -1
  145. package/dist/cli/skills.js +8 -0
  146. package/dist/cli/skills.js.map +1 -1
  147. package/dist/client/AgentPanel.d.ts +8 -2
  148. package/dist/client/AgentPanel.d.ts.map +1 -1
  149. package/dist/client/AgentPanel.js +29 -15
  150. package/dist/client/AgentPanel.js.map +1 -1
  151. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  152. package/dist/client/MultiTabAssistantChat.js +4 -4
  153. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  154. package/dist/client/analytics.d.ts.map +1 -1
  155. package/dist/client/analytics.js +22 -2
  156. package/dist/client/analytics.js.map +1 -1
  157. package/dist/client/changelog/Changelog.d.ts +7 -2
  158. package/dist/client/changelog/Changelog.d.ts.map +1 -1
  159. package/dist/client/changelog/Changelog.js +9 -7
  160. package/dist/client/changelog/Changelog.js.map +1 -1
  161. package/dist/client/extensions/ExtensionsSidebarSection.d.ts.map +1 -1
  162. package/dist/client/extensions/ExtensionsSidebarSection.js +339 -11
  163. package/dist/client/extensions/ExtensionsSidebarSection.js.map +1 -1
  164. package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
  165. package/dist/client/use-agent-engine-configured.js +23 -13
  166. package/dist/client/use-agent-engine-configured.js.map +1 -1
  167. package/dist/client/use-chat-threads.d.ts +4 -0
  168. package/dist/client/use-chat-threads.d.ts.map +1 -1
  169. package/dist/client/use-chat-threads.js +49 -4
  170. package/dist/client/use-chat-threads.js.map +1 -1
  171. package/dist/collab/routes.d.ts +1 -1
  172. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  173. package/dist/observability/routes.d.ts +7 -7
  174. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  175. package/dist/server/analytics.d.ts +4 -0
  176. package/dist/server/analytics.d.ts.map +1 -1
  177. package/dist/server/analytics.js +19 -3
  178. package/dist/server/analytics.js.map +1 -1
  179. package/dist/sharing/access.d.ts.map +1 -1
  180. package/dist/sharing/access.js +26 -12
  181. package/dist/sharing/access.js.map +1 -1
  182. package/dist/sharing/actions/share-resource.js +20 -9
  183. package/dist/sharing/actions/share-resource.js.map +1 -1
  184. package/dist/sharing/actions/unshare-resource.js +13 -2
  185. package/dist/sharing/actions/unshare-resource.js.map +1 -1
  186. package/dist/sharing/registry.d.ts +9 -0
  187. package/dist/sharing/registry.d.ts.map +1 -1
  188. package/dist/sharing/registry.js.map +1 -1
  189. package/dist/vite/client.d.ts.map +1 -1
  190. package/dist/vite/client.js +1 -0
  191. package/dist/vite/client.js.map +1 -1
  192. package/package.json +3 -3
@@ -13,7 +13,11 @@ import { getQuery, type H3Event } from "h3";
13
13
 
14
14
  import { getDb, schema } from "../db/index.js";
15
15
  import { exchangeCode, getUserInfo } from "./google-calendar-client.js";
16
- import { getActiveOrganizationId } from "./recordings.js";
16
+ import {
17
+ getActiveOrganizationId,
18
+ normalizeOwnerEmail,
19
+ ownerEmailMatches,
20
+ } from "./recordings.js";
17
21
 
18
22
  export const CLIPS_GOOGLE_OAUTH_APP_ID = process.env.APP_NAME || "clips";
19
23
 
@@ -54,6 +58,7 @@ export async function handleGoogleCalendarCallback(
54
58
  "Your session expired during the OAuth flow. Sign in again and retry.",
55
59
  );
56
60
  }
61
+ const ownerEmail = normalizeOwnerEmail(userEmail);
57
62
 
58
63
  const clientId = process.env.GOOGLE_CLIENT_ID;
59
64
  const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
@@ -63,7 +68,7 @@ export async function handleGoogleCalendarCallback(
63
68
  );
64
69
  }
65
70
 
66
- return runWithRequestContext({ userEmail }, async () => {
71
+ return runWithRequestContext({ userEmail: ownerEmail }, async () => {
67
72
  const { redirectUri, returnUrl } = state;
68
73
 
69
74
  // 1. Exchange code -> tokens.
@@ -82,8 +87,29 @@ export async function handleGoogleCalendarCallback(
82
87
  const externalAccountId = profile.id;
83
88
  const accountEmail = profile.email;
84
89
 
85
- // 3. Persist tokens in app_secrets (encrypted at rest). NEVER write
86
- // tokens onto the calendar_accounts row.
90
+ // 3. Find an existing account case-insensitively so email-casing changes
91
+ // don't create duplicate calendar connections.
92
+ const db = getDb();
93
+ const orgId = await getActiveOrganizationId().catch(() => undefined);
94
+ const now = new Date().toISOString();
95
+ const [existing] = await db
96
+ .select({
97
+ id: schema.calendarAccounts.id,
98
+ ownerEmail: schema.calendarAccounts.ownerEmail,
99
+ })
100
+ .from(schema.calendarAccounts)
101
+ .where(
102
+ and(
103
+ eq(schema.calendarAccounts.provider, "google"),
104
+ eq(schema.calendarAccounts.externalAccountId, externalAccountId),
105
+ ownerEmailMatches(schema.calendarAccounts.ownerEmail, ownerEmail),
106
+ ),
107
+ );
108
+
109
+ // 4. Persist tokens in app_secrets (encrypted at rest). NEVER write
110
+ // tokens onto the calendar_accounts row. Existing rows may have stored
111
+ // mixed-case owner emails, so keep their secret scope stable.
112
+ const secretScopeEmail = existing?.ownerEmail ?? ownerEmail;
87
113
  const accessKey = calendarSecretKey("google", externalAccountId, "access");
88
114
  const refreshKey = calendarSecretKey(
89
115
  "google",
@@ -101,7 +127,7 @@ export async function handleGoogleCalendarCallback(
101
127
  scope: tokens.scope,
102
128
  }),
103
129
  scope: "user",
104
- scopeId: userEmail,
130
+ scopeId: secretScopeEmail,
105
131
  description: `Google Calendar access token for ${accountEmail}`,
106
132
  });
107
133
  if (tokens.refresh_token) {
@@ -109,26 +135,12 @@ export async function handleGoogleCalendarCallback(
109
135
  key: refreshKey,
110
136
  value: tokens.refresh_token,
111
137
  scope: "user",
112
- scopeId: userEmail,
138
+ scopeId: secretScopeEmail,
113
139
  description: `Google Calendar refresh token for ${accountEmail}`,
114
140
  });
115
141
  }
116
142
 
117
- // 4. Upsert the calendar_accounts row.
118
- const db = getDb();
119
- const orgId = await getActiveOrganizationId().catch(() => undefined);
120
- const now = new Date().toISOString();
121
- const [existing] = await db
122
- .select({ id: schema.calendarAccounts.id })
123
- .from(schema.calendarAccounts)
124
- .where(
125
- and(
126
- eq(schema.calendarAccounts.provider, "google"),
127
- eq(schema.calendarAccounts.externalAccountId, externalAccountId),
128
- eq(schema.calendarAccounts.ownerEmail, userEmail),
129
- ),
130
- );
131
-
143
+ // 5. Upsert the calendar_accounts row.
132
144
  if (existing) {
133
145
  await db
134
146
  .update(schema.calendarAccounts)
@@ -160,13 +172,13 @@ export async function handleGoogleCalendarCallback(
160
172
  lastSyncError: null,
161
173
  createdAt: now,
162
174
  updatedAt: now,
163
- ownerEmail: userEmail,
175
+ ownerEmail,
164
176
  orgId: orgId ?? null,
165
177
  visibility: "private",
166
178
  } as any);
167
179
  }
168
180
 
169
- return oauthCallbackResponse(event, accountEmail || userEmail, {
181
+ return oauthCallbackResponse(event, accountEmail || ownerEmail, {
170
182
  desktop,
171
183
  addAccount: true, // close-tab page; never switch the active session
172
184
  returnUrl,
@@ -51,6 +51,13 @@ export type PublicAgentAccessResult =
51
51
 
52
52
  const DEFAULT_MAX_AGENT_FRAME_MEDIA_BYTES = 200 * 1024 * 1024;
53
53
 
54
+ function sameOwnerEmail(
55
+ a: string | null | undefined,
56
+ b: string | null | undefined,
57
+ ): boolean {
58
+ return !!a && !!b && a.trim().toLowerCase() === b.trim().toLowerCase();
59
+ }
60
+
54
61
  export class RecordingMediaFetchError extends Error {
55
62
  constructor(
56
63
  message: string,
@@ -195,7 +202,7 @@ export async function loadPublicAgentAccess(
195
202
 
196
203
  const session = await getSession(event).catch(() => null);
197
204
  const viewerIsOwner = Boolean(
198
- session?.email && session.email === recording.ownerEmail,
205
+ session?.email && sameOwnerEmail(session.email, recording.ownerEmail),
199
206
  );
200
207
 
201
208
  if (recording.visibility !== "public" && !viewerIsOwner) {
@@ -13,7 +13,23 @@ import { getDb, schema } from "../db/index.js";
13
13
  export function getCurrentOwnerEmail(): string {
14
14
  const email = getRequestUserEmail();
15
15
  if (!email) throw new Error("no authenticated user");
16
- return email;
16
+ return normalizeOwnerEmail(email);
17
+ }
18
+
19
+ export function normalizeOwnerEmail(email: string): string {
20
+ return email.trim().toLowerCase();
21
+ }
22
+
23
+ export function ownerEmailMatches(column: unknown, email: string) {
24
+ return sql`lower(${column as any}) = ${normalizeOwnerEmail(email)}`;
25
+ }
26
+
27
+ export function sameOwnerEmail(
28
+ a: string | null | undefined,
29
+ b: string | null | undefined,
30
+ ): boolean {
31
+ if (!a || !b) return false;
32
+ return normalizeOwnerEmail(a) === normalizeOwnerEmail(b);
17
33
  }
18
34
 
19
35
  export async function getEventOwnerContext(event: H3Event): Promise<{
@@ -258,7 +274,7 @@ export async function getRecordingOrThrow(id: string): Promise<RecordingRow> {
258
274
  eq(schema.recordings.id, id),
259
275
  // visibility check happens at the action layer via the framework
260
276
  // sharing helpers; this is just the ownership-or-visible fallback.
261
- eq(schema.recordings.ownerEmail, ownerEmail),
277
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
262
278
  ),
263
279
  );
264
280
  if (!row) throw new Error(`Recording not found: ${id}`);
@@ -25,6 +25,8 @@ import { getDb, schema } from "../db/index.js";
25
25
  import {
26
26
  getActiveOrganizationId,
27
27
  getOrganizationRoleForEmail,
28
+ ownerEmailMatches,
29
+ sameOwnerEmail,
28
30
  } from "./recordings.js";
29
31
  import type { SlackLinkSharedPayload } from "./slack-unfurls.js";
30
32
 
@@ -388,12 +390,13 @@ export async function listVisibleSlackInstallations(options: {
388
390
  userEmail: string;
389
391
  orgId?: string | null;
390
392
  }): Promise<SlackInstallationListItem[]> {
393
+ const ownerWhere = ownerEmailMatches(
394
+ schema.slackInstallations.ownerEmail,
395
+ options.userEmail,
396
+ );
391
397
  const scopeWhere = options.orgId
392
- ? or(
393
- eq(schema.slackInstallations.orgId, options.orgId),
394
- eq(schema.slackInstallations.ownerEmail, options.userEmail),
395
- )
396
- : eq(schema.slackInstallations.ownerEmail, options.userEmail);
398
+ ? or(eq(schema.slackInstallations.orgId, options.orgId), ownerWhere)
399
+ : ownerWhere;
397
400
 
398
401
  return getDb()
399
402
  .select({
@@ -484,7 +487,7 @@ export async function disconnectSlackInstallation(options: {
484
487
  .limit(1);
485
488
  if (!row) return null;
486
489
 
487
- const canDisconnectOwn = row.ownerEmail === options.userEmail;
490
+ const canDisconnectOwn = sameOwnerEmail(row.ownerEmail, options.userEmail);
488
491
  let canDisconnectOrg = false;
489
492
  if (row.orgId) {
490
493
  const role = await getOrganizationRoleForEmail(
@@ -79,11 +79,15 @@ export default async (): Promise<void> => {
79
79
  () => ({ db: null as any, schema: null as any }),
80
80
  );
81
81
  if (!db || !schema?.calendarAccounts) return false;
82
- const { eq } = await import("drizzle-orm");
82
+ const { ownerEmailMatches } = await import(
83
+ "../lib/recordings.js" as string
84
+ );
83
85
  const rows = await db
84
86
  .select({ id: schema.calendarAccounts.id })
85
87
  .from(schema.calendarAccounts)
86
- .where(eq(schema.calendarAccounts.ownerEmail, userEmail))
88
+ .where(
89
+ ownerEmailMatches(schema.calendarAccounts.ownerEmail, userEmail),
90
+ )
87
91
  .limit(1);
88
92
  return rows.length > 0;
89
93
  } catch {
@@ -33,7 +33,7 @@ import {
33
33
  } from "../../../shared/transcript-segments.js";
34
34
  import { getDb, schema } from "../../db/index.js";
35
35
  import { resolvePlayerVideoUrl } from "../../lib/player-video-url.js";
36
- import { parseSpaceIds } from "../../lib/recordings.js";
36
+ import { parseSpaceIds, sameOwnerEmail } from "../../lib/recordings.js";
37
37
  import { verifySharePassword } from "../../lib/share-password.js";
38
38
 
39
39
  function appPath(path: string): string {
@@ -142,7 +142,7 @@ export default defineEventHandler(async (event) => {
142
142
 
143
143
  const session = await getSession(event).catch(() => null);
144
144
  const viewerIsOwner = Boolean(
145
- session?.email && session.email === rec.ownerEmail,
145
+ session?.email && sameOwnerEmail(session.email, rec.ownerEmail),
146
146
  );
147
147
 
148
148
  if (rec.visibility !== "public" && !viewerIsOwner) {
@@ -24,7 +24,10 @@ import {
24
24
 
25
25
  import { parseEdits } from "../../../../../app/lib/timestamp-mapping.js";
26
26
  import { getDb, schema } from "../../../../db/index.js";
27
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
27
+ import {
28
+ getEventOwnerContext,
29
+ ownerEmailMatches,
30
+ } from "../../../../lib/recordings.js";
28
31
 
29
32
  const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
30
33
 
@@ -85,7 +88,7 @@ export default defineEventHandler(async (event: H3Event) => {
85
88
  .where(
86
89
  and(
87
90
  eq(schema.recordings.id, recordingId),
88
- eq(schema.recordings.ownerEmail, ownerEmail),
91
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
89
92
  ),
90
93
  );
91
94
 
@@ -20,7 +20,10 @@ import {
20
20
  } from "h3";
21
21
 
22
22
  import { getDb, schema } from "../../../../db/index.js";
23
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
23
+ import {
24
+ getEventOwnerContext,
25
+ ownerEmailMatches,
26
+ } from "../../../../lib/recordings.js";
24
27
 
25
28
  export default defineEventHandler(async (event: H3Event) => {
26
29
  const recordingId = getRouterParam(event, "recordingId");
@@ -47,7 +50,7 @@ export default defineEventHandler(async (event: H3Event) => {
47
50
  .where(
48
51
  and(
49
52
  eq(schema.recordings.id, recordingId),
50
- eq(schema.recordings.ownerEmail, ownerEmail),
53
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
51
54
  ),
52
55
  );
53
56
 
@@ -35,7 +35,10 @@ import {
35
35
  import finalizeRecording from "../../../../../actions/finalize-recording.js";
36
36
  import { getDb, schema } from "../../../../db/index.js";
37
37
  import { debugLog } from "../../../../lib/debug.js";
38
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
38
+ import {
39
+ getEventOwnerContext,
40
+ ownerEmailMatches,
41
+ } from "../../../../lib/recordings.js";
39
42
  import {
40
43
  shouldRejectVideoUploadWithoutStorage,
41
44
  STORAGE_SETUP_REQUIRED_REASON,
@@ -156,7 +159,7 @@ export default defineEventHandler(async (event: H3Event) => {
156
159
  .where(
157
160
  and(
158
161
  eq(schema.recordings.id, recordingId),
159
- eq(schema.recordings.ownerEmail, ownerEmail),
162
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
160
163
  ),
161
164
  );
162
165
 
@@ -40,7 +40,10 @@ import {
40
40
  } from "h3";
41
41
 
42
42
  import { getDb, schema } from "../../../../db/index.js";
43
- import { getEventOwnerContext } from "../../../../lib/recordings.js";
43
+ import {
44
+ getEventOwnerContext,
45
+ ownerEmailMatches,
46
+ } from "../../../../lib/recordings.js";
44
47
 
45
48
  interface CompressionMeta {
46
49
  originalBytes?: number;
@@ -97,7 +100,7 @@ export default defineEventHandler(async (event: H3Event) => {
97
100
  .where(
98
101
  and(
99
102
  eq(schema.recordings.id, recordingId),
100
- eq(schema.recordings.ownerEmail, ownerEmail),
103
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
101
104
  ),
102
105
  );
103
106
 
@@ -20,14 +20,24 @@ const UNQUOTED_SECRET_VALUE_RE = new RegExp(
20
20
  "gi",
21
21
  );
22
22
 
23
- export function redactBrowserDiagnosticString(value: string): string {
24
- return value
23
+ export interface RedactBrowserDiagnosticStringOptions {
24
+ /** Redact every query value. Use for URL fields; leave off for console text. */
25
+ redactQueryValues?: boolean;
26
+ }
27
+
28
+ export function redactBrowserDiagnosticString(
29
+ value: string,
30
+ options: RedactBrowserDiagnosticStringOptions = {},
31
+ ): string {
32
+ const redacted = value
25
33
  .replace(AUTHORIZATION_SCHEME_RE, "$1$2<redacted>")
26
34
  .replace(/\b(bearer|basic)\s+[a-z0-9._~+/-]+=*/gi, "$1 <redacted>")
27
35
  .replace(DOUBLE_QUOTED_SECRET_VALUE_RE, '$1$2$1$3"<redacted>"')
28
36
  .replace(SINGLE_QUOTED_SECRET_VALUE_RE, "$1$2$1$3'<redacted>'")
29
- .replace(UNQUOTED_SECRET_VALUE_RE, "$1$2$1$3<redacted>")
30
- .replace(/([?&][^=\s&?#]+)=([^&\s#]+)/g, "$1=<redacted>");
37
+ .replace(UNQUOTED_SECRET_VALUE_RE, "$1$2$1$3<redacted>");
38
+ return options.redactQueryValues
39
+ ? redacted.replace(/([?&][^=\s&?#]+)=([^&\s#]+)/g, "$1=<redacted>")
40
+ : redacted;
31
41
  }
32
42
 
33
43
  export type BrowserDiagnosticConsoleLevel =
@@ -5,6 +5,7 @@ publish = "templates/content/dist"
5
5
  functions = "templates/content/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
  AGENT_RUN_SOFT_TIMEOUT_MS = "55000"
@@ -5,6 +5,7 @@ publish = "templates/design/dist"
5
5
  functions = "templates/design/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,6 +5,7 @@ publish = "templates/dispatch/dist"
5
5
  functions = "templates/dispatch/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,6 +5,7 @@ publish = "templates/forms/dist"
5
5
  functions = "templates/forms/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,5 +5,6 @@ publish = "templates/macros/dist"
5
5
  functions = "templates/macros/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
@@ -569,7 +569,9 @@ export function EmailList({
569
569
  // Enter on a single focused row is a single-thread action — clear any
570
570
  // in-progress multi-selection so shortcuts in detail view start fresh.
571
571
  setSelectedIds(new Set());
572
- void ensureThread(targetThreadId, thread.latestMessage.accountEmail);
572
+ void ensureThread(targetThreadId, thread.latestMessage.accountEmail).catch(
573
+ () => {},
574
+ );
573
575
  onNavigateThread?.(targetThreadId);
574
576
  navigate(`/${view}/${targetThreadId}${routeSearchSuffix}`);
575
577
  if (thread.hasUnread) {
@@ -618,7 +620,9 @@ export function EmailList({
618
620
  // instant down the list.
619
621
  const nextTid =
620
622
  nextThread.latestMessage.threadId || nextThread.latestMessage.id;
621
- void ensureThread(nextTid, nextThread.latestMessage.accountEmail);
623
+ void ensureThread(nextTid, nextThread.latestMessage.accountEmail).catch(
624
+ () => {},
625
+ );
622
626
  } else {
623
627
  setFocusedId(null);
624
628
  }
@@ -1065,7 +1069,7 @@ export function EmailList({
1065
1069
  onDraftOpen(email);
1066
1070
  return;
1067
1071
  }
1068
- void ensureThread(targetThreadId, email.accountEmail);
1072
+ void ensureThread(targetThreadId, email.accountEmail).catch(() => {});
1069
1073
  onNavigateThread?.(targetThreadId);
1070
1074
  navigate(`/${view}/${targetThreadId}${routeSearchSuffix}`);
1071
1075
  if (thread.hasUnread) {
@@ -408,7 +408,10 @@ export function EmailThread({
408
408
  (t.latestMessage.threadId || t.latestMessage.id) === nextThreadId,
409
409
  );
410
410
  setSelectedIds?.(new Set());
411
- void ensureThread(nextThreadId, nextThread?.latestMessage.accountEmail);
411
+ void ensureThread(
412
+ nextThreadId,
413
+ nextThread?.latestMessage.accountEmail,
414
+ ).catch(() => {});
412
415
  onNavigateThread?.(nextThreadId);
413
416
  navigate(`/${view}/${nextThreadId}${routeSearchSuffix}`);
414
417
  },
@@ -99,13 +99,21 @@ function AccountAvatar({
99
99
  fallbackClassName: string;
100
100
  }) {
101
101
  const [imageFailed, setImageFailed] = useState(false);
102
+ const [stablePhotoUrl, setStablePhotoUrl] = useState(photoUrl ?? null);
103
+
104
+ useEffect(() => {
105
+ if (!photoUrl || photoUrl === stablePhotoUrl) return;
106
+ setStablePhotoUrl(photoUrl);
107
+ setImageFailed(false);
108
+ }, [photoUrl, stablePhotoUrl]);
109
+
102
110
  const shouldLoadRemoteAvatar =
103
- !!photoUrl && !isMcpEmbedSurface() && !imageFailed;
111
+ !!stablePhotoUrl && !isMcpEmbedSurface() && !imageFailed;
104
112
 
105
113
  if (shouldLoadRemoteAvatar) {
106
114
  return (
107
115
  <img
108
- src={photoUrl}
116
+ src={stablePhotoUrl}
109
117
  alt=""
110
118
  className={imageClassName}
111
119
  referrerPolicy="no-referrer"
@@ -80,6 +80,16 @@ async function saveDraftToEmails(
80
80
  return result?.draftId;
81
81
  }
82
82
 
83
+ export async function saveDraftToEmailsBestEffort(
84
+ draft: ComposeState,
85
+ ): Promise<string | undefined> {
86
+ try {
87
+ return await saveDraftToEmails(draft);
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
83
93
  export function useComposeState() {
84
94
  const qc = useQueryClient();
85
95
  const [activeId, setActiveId] = useState<string | null>(null);
@@ -225,7 +235,7 @@ export function useComposeState() {
225
235
  ).find((d) => d.id === id);
226
236
  if (!current || !hasDraftContent(current)) return;
227
237
 
228
- saveDraftToEmails(current).then((draftId) => {
238
+ void saveDraftToEmailsBestEffort(current).then((draftId) => {
229
239
  if (draftId && draftId !== current.savedDraftId) {
230
240
  // Store the Gmail draft ID back so subsequent saves update rather than create
231
241
  qc.setQueryData<ComposeState[]>(["compose-drafts"], (old) =>
@@ -306,8 +316,8 @@ export function useComposeState() {
306
316
 
307
317
  // Auto-save to persistent drafts if there's any content
308
318
  if (draft && hasDraftContent(draft)) {
309
- saveDraftToEmails(draft).then(() => {
310
- qc.invalidateQueries({ queryKey: ["emails"] });
319
+ void saveDraftToEmailsBestEffort(draft).then((draftId) => {
320
+ if (draftId) qc.invalidateQueries({ queryKey: ["emails"] });
311
321
  });
312
322
  }
313
323
 
@@ -377,8 +387,8 @@ export function useComposeState() {
377
387
  // Save all drafts with content
378
388
  for (const draft of currentDrafts) {
379
389
  if (hasDraftContent(draft)) {
380
- saveDraftToEmails(draft).then(() => {
381
- qc.invalidateQueries({ queryKey: ["emails"] });
390
+ void saveDraftToEmailsBestEffort(draft).then((draftId) => {
391
+ if (draftId) qc.invalidateQueries({ queryKey: ["emails"] });
382
392
  });
383
393
  }
384
394
  }
@@ -2,14 +2,50 @@ import { agentNativePath, oauthRedirectUri } from "@agent-native/core/client";
2
2
  import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
3
3
  import { useEffect } from "react";
4
4
 
5
- interface GoogleAuthStatus {
5
+ export interface GoogleAuthAccount {
6
+ email: string;
7
+ displayName?: string;
8
+ expiresAt?: string;
9
+ photoUrl?: string;
10
+ }
11
+
12
+ export interface GoogleAuthStatus {
6
13
  connected: boolean;
7
- accounts: Array<{
8
- email: string;
9
- displayName?: string;
10
- expiresAt?: string;
11
- photoUrl?: string;
12
- }>;
14
+ accounts: GoogleAuthAccount[];
15
+ }
16
+
17
+ const stablePhotoUrls = new Map<string, string>();
18
+
19
+ export function mergeStableGoogleAuthStatus(
20
+ status: GoogleAuthStatus,
21
+ photoCache: Map<string, string> = stablePhotoUrls,
22
+ ): GoogleAuthStatus {
23
+ if (!status.connected || status.accounts.length === 0) {
24
+ photoCache.clear();
25
+ return status;
26
+ }
27
+
28
+ const accountEmails = new Set(
29
+ status.accounts.map((account) => account.email),
30
+ );
31
+ for (const email of photoCache.keys()) {
32
+ if (!accountEmails.has(email)) photoCache.delete(email);
33
+ }
34
+
35
+ let changed = false;
36
+ const accounts = status.accounts.map((account) => {
37
+ if (account.photoUrl) {
38
+ photoCache.set(account.email, account.photoUrl);
39
+ return account;
40
+ }
41
+
42
+ const cachedPhotoUrl = photoCache.get(account.email);
43
+ if (!cachedPhotoUrl) return account;
44
+ changed = true;
45
+ return { ...account, photoUrl: cachedPhotoUrl };
46
+ });
47
+
48
+ return changed ? { ...status, accounts } : status;
13
49
  }
14
50
 
15
51
  /**
@@ -86,8 +122,10 @@ export function useGoogleAuthStatus() {
86
122
  return useQuery<GoogleAuthStatus>({
87
123
  queryKey: ["google-status"],
88
124
  queryFn: async () => {
89
- return fetchJson<GoogleAuthStatus>(
90
- agentNativePath("/_agent-native/google/status"),
125
+ return mergeStableGoogleAuthStatus(
126
+ await fetchJson<GoogleAuthStatus>(
127
+ agentNativePath("/_agent-native/google/status"),
128
+ ),
91
129
  );
92
130
  },
93
131
  });
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-25
4
+ ---
5
+
6
+ Account avatars stay stable while Mail refreshes Google account status.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-25
4
+ ---
5
+
6
+ Background draft saves and thread prefetches no longer show as inbox crashes when Gmail returns a transient error.
@@ -5,6 +5,7 @@ publish = "templates/mail/dist"
5
5
  functions = "templates/mail/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -26,6 +26,14 @@ or cross either artboard, remove the label and explain the transition with a
26
26
  nearby annotation instead. Before handoff, inspect the top canvas at default zoom
27
27
  and move any frame whose label, connector, or annotation crosses another frame.
28
28
 
29
+ **Board-unit spacing defaults.** The canvas coordinate system uses approximately 2 board units per screen pixel. `browser` frames occupy roughly 700 × 600 board units; `desktop` frames roughly 900 × 700 board units. Apply these minimum x/y gaps when placing frames explicitly — any less and frames will touch or overlap:
30
+
31
+ - x-gap between `browser` frames: **≥ 1100** (700-unit frame + 400-unit gutter)
32
+ - x-gap between `desktop` frames: **≥ 1300** (900-unit frame + 400-unit gutter)
33
+ - y-gap between rows of any surface: **≥ 1400** (includes frame height + section header + buffer)
34
+
35
+ When in doubt, use larger values — the canvas auto-zooms to fit everything.
36
+
29
37
  **Canvas annotations are designer notes on the artboard.** When a top canvas is
30
38
  present, sprinkle Figma-style notes near the frames they explain: a short
31
39
  heading, supporting text, and bullets — plain text layers, never bordered or
@@ -5,6 +5,7 @@ publish = "templates/plan/dist"
5
5
  functions = "templates/plan/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11
 
@@ -5,6 +5,7 @@ publish = "templates/slides/dist"
5
5
  functions = "templates/slides/.netlify/functions-internal"
6
6
 
7
7
  [build.environment]
8
+ GA_MEASUREMENT_ID = "G-ESF7FYXGN9"
8
9
  NITRO_PRESET = "netlify"
9
10
  NPM_CONFIG_PRODUCTION = "false"
10
11