@agent-native/core 0.141.3 → 0.141.4

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 (152) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/analysis-workspace/SKILL.md +1 -1
  3. package/corpus/templates/analytics/actions/gong-calls.ts +56 -18
  4. package/corpus/templates/analytics/changelog/2026-08-05-analytics-queries-keep-every-table-tenant-scoped.md +6 -0
  5. package/corpus/templates/analytics/changelog/2026-08-05-fixed-sign-in-and-dashboard-requests-failing-when-background.md +6 -0
  6. package/corpus/templates/analytics/changelog/2026-08-05-historical-rollups-stay-complete-while-live-events-arrive.md +6 -0
  7. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  8. package/corpus/templates/analytics/server/lib/dashboard-report.ts +2 -0
  9. package/corpus/templates/analytics/server/lib/emails.ts +51 -0
  10. package/corpus/templates/analytics/server/lib/first-party-analytics-health.ts +8 -12
  11. package/corpus/templates/analytics/server/lib/first-party-analytics-rollups.ts +9 -0
  12. package/corpus/templates/analytics/server/lib/first-party-analytics.ts +262 -13
  13. package/corpus/templates/analytics/server/lib/gong.ts +132 -33
  14. package/corpus/templates/analytics/server/plugins/agent-chat.ts +1 -1
  15. package/corpus/templates/analytics/server/plugins/auth.ts +21 -2
  16. package/corpus/templates/analytics/server/plugins/db.ts +11 -3
  17. package/corpus/templates/analytics/server/plugins/transactional-emails.ts +5 -0
  18. package/corpus/templates/calendar/app/components/calendar/InlineEventPickers.tsx +17 -7
  19. package/corpus/templates/calendar/server/lib/booking-emails.ts +154 -70
  20. package/corpus/templates/calendar/server/lib/emails.ts +167 -0
  21. package/corpus/templates/calendar/server/lib/event-guest-notifications.ts +64 -28
  22. package/corpus/templates/calendar/server/plugins/transactional-emails.ts +5 -0
  23. package/corpus/templates/clips/actions/invite-member.ts +39 -16
  24. package/corpus/templates/clips/app/components/player/share-dialog.tsx +73 -1
  25. package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
  26. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +10 -25
  27. package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
  28. package/corpus/templates/clips/changelog/2026-08-05-activity-is-now-the-default-clips-recording-tab-and-ai-setup.md +6 -0
  29. package/corpus/templates/clips/changelog/2026-08-05-clips-can-copy-a-linked-email-preview-for-public-recordings.md +6 -0
  30. package/corpus/templates/clips/changelog/2026-08-05-preparing-recording-now-uses-a-clean-shadow-free-status-card.md +6 -0
  31. package/corpus/templates/clips/desktop/src/styles.css +0 -3
  32. package/corpus/templates/clips/server/lib/emails.ts +256 -0
  33. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +24 -0
  34. package/corpus/templates/clips/server/plugins/transactional-email-catalog.ts +5 -0
  35. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +4 -1
  36. package/corpus/templates/clips/shared/email-preview.ts +61 -0
  37. package/corpus/templates/content/server/lib/comment-notifications.ts +57 -26
  38. package/corpus/templates/content/server/lib/emails.ts +70 -0
  39. package/corpus/templates/content/server/plugins/transactional-emails.ts +5 -0
  40. package/corpus/templates/design/actions/import-design-tokens.ts +11 -8
  41. package/corpus/templates/design/actions/index-design-tokens.ts +13 -6
  42. package/corpus/templates/design/changelog/2026-08-05-design-token-imports-classify-typography-dimensions-correctly.md +6 -0
  43. package/corpus/templates/dispatch/app/routes/transactional-email.$appId.$id.tsx +4 -0
  44. package/corpus/templates/dispatch/app/routes/transactional-email.tsx +4 -0
  45. package/corpus/templates/dispatch/changelog/2026-08-05-transactional-email-catalogs-and-delivery-activity-are-available-again.md +6 -0
  46. package/corpus/templates/factory/AGENTS.md +18 -17
  47. package/corpus/templates/factory/actions/get-factory-automation-health.ts +50 -0
  48. package/corpus/templates/factory/actions/get-triage-config.ts +8 -0
  49. package/corpus/templates/factory/actions/save-triage-config.ts +26 -0
  50. package/corpus/templates/factory/app/i18n/en-US.ts +27 -0
  51. package/corpus/templates/factory/app/routes/factory.tsx +376 -206
  52. package/corpus/templates/factory/changelog/2026-08-05-factory-now-keeps-production-automations-running-shows-sched.md +6 -0
  53. package/corpus/templates/factory/server/db/schema.ts +6 -0
  54. package/corpus/templates/factory/server/plugins/factory-migrations.ts +10 -0
  55. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +141 -2
  56. package/corpus/templates/forms/app/pages/FormsListPage.tsx +41 -21
  57. package/corpus/templates/forms/changelog/2026-08-05-form-links-can-now-be-cmd-clicked-to-open-in-a-new-tab.md +6 -0
  58. package/corpus/templates/forms/server/lib/emails.ts +52 -0
  59. package/corpus/templates/forms/server/lib/response-email.ts +2 -0
  60. package/corpus/templates/forms/server/plugins/transactional-emails.ts +5 -0
  61. package/corpus/templates/plan/actions/request-plan-access.ts +33 -12
  62. package/corpus/templates/plan/server/lib/comment-notifications.ts +53 -27
  63. package/corpus/templates/plan/server/lib/emails.ts +67 -0
  64. package/corpus/templates/plan/server/plugins/transactional-emails.ts +5 -0
  65. package/corpus/templates/slides/actions/import-from-url.ts +1 -0
  66. package/corpus/templates/slides/app/components/editor/NewDeckReferenceStep.tsx +492 -381
  67. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +8 -7
  68. package/corpus/templates/slides/app/components/layout/Layout.tsx +27 -23
  69. package/corpus/templates/slides/app/components/onboarding/FirstDeckOnboardingFlow.tsx +313 -0
  70. package/corpus/templates/slides/app/components/ui/command.tsx +1 -0
  71. package/corpus/templates/slides/app/i18n/en-US.ts +8 -2
  72. package/corpus/templates/slides/app/lib/create-deck-generation.ts +305 -0
  73. package/corpus/templates/slides/app/pages/DeckEditor.tsx +12 -12
  74. package/corpus/templates/slides/app/pages/Index.tsx +127 -137
  75. package/corpus/templates/slides/app/root.tsx +8 -0
  76. package/corpus/templates/slides/changelog/2026-08-05-deck-browsing-keeps-all-and-mine-in-the-header-with-a-tighte.md +6 -0
  77. package/corpus/templates/slides/changelog/2026-08-05-new-deck-creation-now-flows-through-a-clean-full-screen-refe.md +6 -0
  78. package/corpus/templates/slides/changelog/2026-08-05-website-imports-now-include-linked-stylesheets-for-more-accu.md +6 -0
  79. package/corpus/templates/slides/server/lib/comment-notifications.ts +44 -21
  80. package/corpus/templates/slides/server/lib/emails.ts +40 -0
  81. package/corpus/templates/slides/server/plugins/transactional-emails.ts +5 -0
  82. package/dist/brand-kit/index.d.ts +1 -0
  83. package/dist/brand-kit/index.js +1 -0
  84. package/dist/brand-kit/tokens.d.ts +91 -0
  85. package/dist/brand-kit/tokens.js +279 -0
  86. package/dist/brand-kit/types.d.ts +25 -0
  87. package/dist/client/AssistantChat.js +7 -7
  88. package/dist/client/onboarding/FirstRunOnboarding.js +26 -2
  89. package/dist/client/onboarding/first-run-registry.d.ts +25 -0
  90. package/dist/client/onboarding/first-run-registry.js +18 -0
  91. package/dist/client/onboarding/index.d.ts +2 -0
  92. package/dist/client/onboarding/index.js +1 -0
  93. package/dist/collab/routes.d.ts +1 -1
  94. package/dist/deploy/build.d.ts +8 -0
  95. package/dist/deploy/build.js +92 -3
  96. package/dist/deploy/workspace-deploy.js +86 -3
  97. package/dist/email-catalog/actions/list-email-activity.d.ts +5 -0
  98. package/dist/email-catalog/actions/list-email-activity.js +19 -0
  99. package/dist/email-catalog/actions/list-email-engagement.d.ts +5 -0
  100. package/dist/email-catalog/actions/list-email-engagement.js +15 -0
  101. package/dist/email-catalog/actions/list-email-log.d.ts +7 -0
  102. package/dist/email-catalog/actions/list-email-log.js +23 -0
  103. package/dist/email-catalog/actions/list-transactional-emails.d.ts +22 -0
  104. package/dist/email-catalog/actions/list-transactional-emails.js +67 -0
  105. package/dist/email-catalog/actions/render-transactional-email-preview.d.ts +9 -0
  106. package/dist/email-catalog/actions/render-transactional-email-preview.js +25 -0
  107. package/dist/email-catalog/authorize.d.ts +1 -0
  108. package/dist/email-catalog/authorize.js +9 -0
  109. package/dist/email-catalog/log.d.ts +60 -0
  110. package/dist/email-catalog/log.js +142 -0
  111. package/dist/email-catalog/provider-metrics.d.ts +34 -0
  112. package/dist/email-catalog/provider-metrics.js +182 -0
  113. package/dist/email-catalog/registry.d.ts +78 -0
  114. package/dist/email-catalog/registry.js +70 -0
  115. package/dist/email-catalog/schema.d.ts +231 -0
  116. package/dist/email-catalog/schema.js +53 -0
  117. package/dist/email-catalog/system-emails.d.ts +13 -0
  118. package/dist/email-catalog/system-emails.js +67 -0
  119. package/dist/jobs/index.d.ts +2 -0
  120. package/dist/jobs/index.js +2 -0
  121. package/dist/jobs/run-history.js +48 -1
  122. package/dist/jobs/scheduler-dispatch.d.ts +7 -0
  123. package/dist/jobs/scheduler-dispatch.js +7 -0
  124. package/dist/jobs/scheduler-health.d.ts +22 -0
  125. package/dist/jobs/scheduler-health.js +172 -0
  126. package/dist/jobs/scheduler.js +67 -2
  127. package/dist/localization/default-messages.d.ts +45 -0
  128. package/dist/localization/default-messages.js +45 -0
  129. package/dist/mcp/screen-memory-stdio.d.ts +7 -7
  130. package/dist/notifications/routes.d.ts +3 -3
  131. package/dist/observability/routes.d.ts +5 -5
  132. package/dist/org/handlers.js +9 -1
  133. package/dist/progress/routes.d.ts +1 -1
  134. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  135. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  136. package/dist/secrets/routes.d.ts +9 -9
  137. package/dist/server/action-discovery.js +22 -0
  138. package/dist/server/agent-chat/recurring-jobs-runtime.d.ts +7 -1
  139. package/dist/server/agent-chat/recurring-jobs-runtime.js +12 -0
  140. package/dist/server/agent-chat-plugin.d.ts +2 -1
  141. package/dist/server/agent-chat-plugin.js +39 -1
  142. package/dist/server/better-auth-instance.js +17 -2
  143. package/dist/server/design-token-utils.d.ts +6 -1
  144. package/dist/server/design-token-utils.js +252 -59
  145. package/dist/server/email.d.ts +11 -0
  146. package/dist/server/email.js +54 -3
  147. package/dist/server/index.d.ts +1 -0
  148. package/dist/server/index.js +1 -0
  149. package/dist/server/realtime-token.d.ts +1 -1
  150. package/dist/server/transcribe-voice.d.ts +1 -1
  151. package/dist/vite/client.js +20 -14
  152. package/package.json +2 -1
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-08-05
4
+ ---
5
+
6
+ Factory now keeps production automations running, shows scheduler health and run errors, and can email failure diagnostics.
@@ -120,6 +120,12 @@ export const triageConfig = table("factory_config", {
120
120
  sentryProjectSlug: text("sentry_project_slug"),
121
121
  sentryEnvironment: text("sentry_environment"),
122
122
  lastSentrySeenAt: text("last_sentry_seen_at"),
123
+ automationFailureAlertsEnabled: integer("automation_failure_alerts_enabled")
124
+ .notNull()
125
+ .default(1),
126
+ automationFailureAlertEmail: text("automation_failure_alert_email"),
127
+ lastAutomationFailureAlertKey: text("last_automation_failure_alert_key"),
128
+ lastAutomationFailureAlertAt: text("last_automation_failure_alert_at"),
123
129
  createdAt: text("created_at").notNull().default(now()),
124
130
  updatedAt: text("updated_at").notNull().default(now()),
125
131
  ownerEmail: text("owner_email").notNull(),
@@ -248,6 +248,16 @@ const migrations = [
248
248
  ALTER TABLE factory_config ADD COLUMN last_sentry_seen_at TEXT;
249
249
  `,
250
250
  },
251
+ {
252
+ version: 17,
253
+ name: "factory-automation-failure-alert-settings",
254
+ sql: `
255
+ ALTER TABLE factory_config ADD COLUMN automation_failure_alerts_enabled INTEGER NOT NULL DEFAULT 1;
256
+ ALTER TABLE factory_config ADD COLUMN automation_failure_alert_email TEXT;
257
+ ALTER TABLE factory_config ADD COLUMN last_automation_failure_alert_key TEXT;
258
+ ALTER TABLE factory_config ADD COLUMN last_automation_failure_alert_at TEXT;
259
+ `,
260
+ },
251
261
  ];
252
262
 
253
263
  export const runFactoryMigrations = runMigrations(migrations, {
@@ -1,3 +1,5 @@
1
+ import { subscribe } from "@agent-native/core/event-bus";
2
+ import { notify } from "@agent-native/core/notifications";
1
3
  import { resolveOrgIdForEmail } from "@agent-native/core/org";
2
4
  import {
3
5
  organizationResourceOwner,
@@ -5,8 +7,11 @@ import {
5
7
  resourcePut,
6
8
  WORKSPACE_OWNER,
7
9
  } from "@agent-native/core/resources";
8
- import { defineNitroPlugin } from "@agent-native/core/server";
9
- import { and, eq } from "drizzle-orm";
10
+ import {
11
+ defineNitroPlugin,
12
+ runWithRequestContext,
13
+ } from "@agent-native/core/server";
14
+ import { and, eq, isNull, lt, ne, or } from "drizzle-orm";
10
15
 
11
16
  import { getDb } from "../db/index.js";
12
17
  import { triageConfig } from "../db/schema.js";
@@ -14,6 +19,139 @@ import { triageConfig } from "../db/schema.js";
14
19
  const LEGACY_JOB_PATH = "jobs/factory-observation-scheduler.md";
15
20
  const DEFAULT_SLACK_CHANNEL_ID = "C0ATH3CCZT4";
16
21
  const DEFAULT_SLACK_CHANNEL_NAME = "product-agent-native-feedback";
22
+ const FAILURE_ALERT_COOLDOWN_MS = 15 * 60_000;
23
+
24
+ type AutomationRunFinishedEvent = {
25
+ automationRunId: string;
26
+ owner: string;
27
+ automation: string;
28
+ path: string;
29
+ orgId: string | null;
30
+ runId: string | null;
31
+ threadId: string | null;
32
+ status: "success" | "error" | "interrupted";
33
+ error: string | null;
34
+ };
35
+
36
+ let failureAlertSubscription: string | null = null;
37
+
38
+ function factoryPublicUrl(): string | undefined {
39
+ const value = process.env.FACTORY_PUBLIC_URL?.trim(); // guard:allow-env-credential - public callback origin, not a credential
40
+ if (!value || /[\r\n]/.test(value)) return undefined;
41
+ return value.replace(/\/+$/, "");
42
+ }
43
+
44
+ async function notifyFactoryAutomationFailure(
45
+ event: AutomationRunFinishedEvent,
46
+ ): Promise<void> {
47
+ if (
48
+ (event.status !== "error" && event.status !== "interrupted") ||
49
+ !event.orgId ||
50
+ !event.path.startsWith("jobs/factory-")
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ const db = getDb();
56
+ const config = (
57
+ await db
58
+ .select({
59
+ ownerEmail: triageConfig.ownerEmail,
60
+ alertsEnabled: triageConfig.automationFailureAlertsEnabled,
61
+ alertEmail: triageConfig.automationFailureAlertEmail,
62
+ })
63
+ .from(triageConfig)
64
+ .where(
65
+ and(
66
+ eq(triageConfig.id, event.orgId),
67
+ eq(triageConfig.orgId, event.orgId),
68
+ ),
69
+ )
70
+ .limit(1)
71
+ )[0];
72
+ if (!config || config.alertsEnabled !== 1) return;
73
+
74
+ const recipient = (config.alertEmail || config.ownerEmail || "")
75
+ .trim()
76
+ .toLowerCase();
77
+ if (!recipient) return;
78
+
79
+ const error =
80
+ event.error ||
81
+ "The automation ended without recording a terminal result. No delivery was confirmed.";
82
+ const alertKey = `${event.automation}\n${error}`.slice(0, 700);
83
+ const now = new Date();
84
+ const cutoff = new Date(now.getTime() - FAILURE_ALERT_COOLDOWN_MS);
85
+ const claimed = await db
86
+ .update(triageConfig)
87
+ .set({
88
+ lastAutomationFailureAlertKey: alertKey,
89
+ lastAutomationFailureAlertAt: now.toISOString(),
90
+ })
91
+ .where(
92
+ and(
93
+ eq(triageConfig.id, event.orgId),
94
+ eq(triageConfig.orgId, event.orgId),
95
+ eq(triageConfig.automationFailureAlertsEnabled, 1),
96
+ or(
97
+ isNull(triageConfig.lastAutomationFailureAlertKey),
98
+ ne(triageConfig.lastAutomationFailureAlertKey, alertKey),
99
+ isNull(triageConfig.lastAutomationFailureAlertAt),
100
+ lt(triageConfig.lastAutomationFailureAlertAt, cutoff.toISOString()),
101
+ ),
102
+ ),
103
+ )
104
+ .returning({ id: triageConfig.id });
105
+ if (claimed.length === 0) return;
106
+
107
+ const url = factoryPublicUrl();
108
+ const debugTarget = url
109
+ ? `${url}/factory?tab=automations`
110
+ : "Factory > Automations";
111
+ const details = [
112
+ `Automation: ${event.automation}`,
113
+ `Run: ${event.automationRunId}`,
114
+ `Error: ${error}`,
115
+ `Debug: ${debugTarget}`,
116
+ "Next steps: open Scheduler health, then open this run's thread and inspect the last error. If health is stale, inspect the deployed scheduled function and background worker.",
117
+ event.threadId ? `Agent thread: ${event.threadId}` : "",
118
+ event.runId ? `Agent run: ${event.runId}` : "",
119
+ ]
120
+ .filter(Boolean)
121
+ .join("\n");
122
+ await runWithRequestContext(
123
+ { userEmail: config.ownerEmail, orgId: event.orgId },
124
+ () =>
125
+ notify(
126
+ {
127
+ severity: "critical",
128
+ title: `Factory automation failed: ${event.automation}`,
129
+ body: details,
130
+ metadata: {
131
+ emailRecipients: [recipient],
132
+ emailSubject: `Factory automation failed: ${event.automation}`,
133
+ automationRunId: event.automationRunId,
134
+ },
135
+ channels: ["inbox", "email"],
136
+ },
137
+ { owner: config.ownerEmail },
138
+ ),
139
+ );
140
+ }
141
+
142
+ function subscribeToAutomationFailures(): void {
143
+ if (failureAlertSubscription) return;
144
+ failureAlertSubscription = subscribe("automation.run.finished", (payload) =>
145
+ notifyFactoryAutomationFailure(payload as AutomationRunFinishedEvent).catch(
146
+ (error) => {
147
+ console.error(
148
+ "[factory-scheduler-job] automation failure alert failed:",
149
+ error,
150
+ );
151
+ },
152
+ ),
153
+ );
154
+ }
17
155
 
18
156
  type AutomationSeed = {
19
157
  name: string;
@@ -256,6 +394,7 @@ async function ensureSchedulerJobs(): Promise<void> {
256
394
  }
257
395
 
258
396
  export default defineNitroPlugin(async () => {
397
+ subscribeToAutomationFailures();
259
398
  try {
260
399
  await ensureSchedulerJobs();
261
400
  } catch (error) {
@@ -21,7 +21,7 @@ import {
21
21
  IconX,
22
22
  } from "@tabler/icons-react";
23
23
  import { useEffect, useMemo, useState } from "react";
24
- import { useNavigate } from "react-router";
24
+ import { Link, useNavigate } from "react-router";
25
25
  import { toast } from "sonner";
26
26
 
27
27
  import { CloudUpgrade } from "@/components/CloudUpgrade";
@@ -469,6 +469,9 @@ export function FormsListPage() {
469
469
  <div className="forms-list-shell overflow-hidden bg-card">
470
470
  {forms.map((form: any) => {
471
471
  const selected = selectedIds.has(form.id);
472
+ const formHref = isArchive
473
+ ? `/forms/${form.id}/responses`
474
+ : `/forms/${form.id}`;
472
475
  const dateLabel =
473
476
  isArchive && (form as any).deletedAt
474
477
  ? t("forms.deletedDate", {
@@ -481,6 +484,20 @@ export function FormsListPage() {
481
484
  month: "short",
482
485
  day: "numeric",
483
486
  });
487
+ const formDetails = (
488
+ <>
489
+ <div className="flex min-w-0 items-center gap-2">
490
+ <h3 className="min-w-0 flex-1 truncate text-sm font-medium">
491
+ {form.title}
492
+ </h3>
493
+ </div>
494
+ {form.description && (
495
+ <p className="mt-0.5 truncate text-xs text-muted-foreground">
496
+ {form.description}
497
+ </p>
498
+ )}
499
+ </>
500
+ );
484
501
 
485
502
  return (
486
503
  <div
@@ -496,16 +513,22 @@ export function FormsListPage() {
496
513
  role="button"
497
514
  tabIndex={0}
498
515
  aria-pressed={selectionMode ? selected : undefined}
499
- onClick={() => {
516
+ onClick={(event) => {
517
+ if (
518
+ event.defaultPrevented ||
519
+ event.metaKey ||
520
+ event.ctrlKey ||
521
+ event.shiftKey ||
522
+ event.altKey ||
523
+ event.button !== 0
524
+ ) {
525
+ return;
526
+ }
500
527
  if (selectionMode) {
501
528
  toggleSelection(form.id);
502
529
  return;
503
530
  }
504
- navigate(
505
- isArchive
506
- ? `/forms/${form.id}/responses`
507
- : `/forms/${form.id}`,
508
- );
531
+ navigate(formHref);
509
532
  }}
510
533
  onKeyDown={(e) => {
511
534
  if (
@@ -517,11 +540,7 @@ export function FormsListPage() {
517
540
  toggleSelection(form.id);
518
541
  return;
519
542
  }
520
- navigate(
521
- isArchive
522
- ? `/forms/${form.id}/responses`
523
- : `/forms/${form.id}`,
524
- );
543
+ navigate(formHref);
525
544
  }
526
545
  }}
527
546
  >
@@ -538,15 +557,16 @@ export function FormsListPage() {
538
557
  />
539
558
  )}
540
559
  <div className="min-w-0 flex-1">
541
- <div className="flex min-w-0 items-center gap-2">
542
- <h3 className="min-w-0 flex-1 truncate text-sm font-medium">
543
- {form.title}
544
- </h3>
545
- </div>
546
- {form.description && (
547
- <p className="mt-0.5 truncate text-xs text-muted-foreground">
548
- {form.description}
549
- </p>
560
+ {selectionMode ? (
561
+ formDetails
562
+ ) : (
563
+ <Link
564
+ to={formHref}
565
+ className="block min-w-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
566
+ onClick={(event) => event.stopPropagation()}
567
+ >
568
+ {formDetails}
569
+ </Link>
550
570
  )}
551
571
  </div>
552
572
  </div>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-05
4
+ ---
5
+
6
+ Form links can now be Cmd-clicked to open in a new tab
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Catalog entries for the transactional emails Forms sends.
3
+ *
4
+ * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list
5
+ * and preview them without the app having sent anything yet.
6
+ */
7
+
8
+ import { defineTransactionalEmail } from "@agent-native/core/email-catalog";
9
+
10
+ import { renderNewResponseEmail } from "./response-email.js";
11
+
12
+ export const FORMS_NEW_RESPONSE_EMAIL_ID = "forms.new-response";
13
+
14
+ let registered = false;
15
+
16
+ export function registerFormsEmails(): void {
17
+ if (registered) return;
18
+ registered = true;
19
+
20
+ defineTransactionalEmail({
21
+ id: FORMS_NEW_RESPONSE_EMAIL_ID,
22
+ name: "New form response",
23
+ trigger:
24
+ "A response is submitted and persisted for a form whose settings have `emailOnNewResponses` enabled. Delivery is best-effort: a send failure never rejects the submission.",
25
+ recipientLabel: "Form owner",
26
+ recipient:
27
+ "The form's `ownerEmail`, and only that address — the respondent is never emailed. Skipped when the form has no owner address.",
28
+ senderLabel: "Default sender",
29
+ sender:
30
+ "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`, so a reply goes to the sending mailbox rather than to the respondent.",
31
+ preview: () =>
32
+ renderNewResponseEmail({
33
+ formTitle: "Beta feedback",
34
+ fields: [
35
+ { id: "name", type: "text", label: "Name", required: true },
36
+ { id: "email", type: "email", label: "Email", required: true },
37
+ {
38
+ id: "notes",
39
+ type: "textarea",
40
+ label: "What should we fix first?",
41
+ required: false,
42
+ },
43
+ ],
44
+ data: {
45
+ name: "Sam Rivera",
46
+ email: "sam.rivera@example.com",
47
+ notes: "The export button is hard to find on mobile.",
48
+ },
49
+ submittedAt: "2025-03-04T17:20:00.000Z",
50
+ }),
51
+ });
52
+ }
@@ -1,6 +1,7 @@
1
1
  import { emailStrong, renderEmail, sendEmail } from "@agent-native/core/server";
2
2
 
3
3
  import type { FormField } from "../../shared/types.js";
4
+ import { FORMS_NEW_RESPONSE_EMAIL_ID } from "./emails.js";
4
5
 
5
6
  export interface NewResponseEmailArgs {
6
7
  to: string;
@@ -61,5 +62,6 @@ export async function sendNewResponseEmail(
61
62
  await sendEmail({
62
63
  to: args.to,
63
64
  ...renderNewResponseEmail(args),
65
+ templateId: FORMS_NEW_RESPONSE_EMAIL_ID,
64
66
  });
65
67
  }
@@ -0,0 +1,5 @@
1
+ import { registerFormsEmails } from "../lib/emails.js";
2
+
3
+ export default function registerTransactionalEmails(): void {
4
+ registerFormsEmails();
5
+ }
@@ -22,6 +22,8 @@ import {
22
22
  } from "../server/lib/local-identity.js";
23
23
  import { newId, nowIso, planPath, writeEvent } from "../server/plans.js";
24
24
 
25
+ export const PLAN_ACCESS_REQUEST_EMAIL_ID = "plan.access-request";
26
+
25
27
  function httpError(message: string, statusCode: number): Error {
26
28
  return Object.assign(new Error(message), { statusCode });
27
29
  }
@@ -49,6 +51,28 @@ function absolutePlanUrl(planId: string, kind: "plan" | "recap"): string {
49
51
  }
50
52
  }
51
53
 
54
+ export function renderPlanAccessRequestEmail(input: {
55
+ requesterName: string;
56
+ requesterEmail: string;
57
+ planTitle: string;
58
+ url: string;
59
+ }) {
60
+ const subject = `${input.requesterName} requested access to "${input.planTitle}"`;
61
+ return {
62
+ subject,
63
+ ...renderEmail({
64
+ preheader: subject,
65
+ heading: "Access request",
66
+ paragraphs: [
67
+ `${emailStrong(input.requesterName)} (${emailStrong(input.requesterEmail)}) requested access to ${emailStrong(input.planTitle)}.`,
68
+ "Open the plan and use Share to grant access if this request should be approved.",
69
+ ],
70
+ cta: { label: "Open plan", url: input.url },
71
+ footer: "You received this because you own this Agent-Native Plan.",
72
+ }),
73
+ };
74
+ }
75
+
52
76
  async function notifyOwner(input: {
53
77
  planId: string;
54
78
  planKind: "plan" | "recap";
@@ -62,19 +86,16 @@ async function notifyOwner(input: {
62
86
  return false;
63
87
  }
64
88
 
65
- const url = absolutePlanUrl(input.planId, input.planKind);
66
- const subject = `${input.requesterName} requested access to "${input.planTitle}"`;
67
- const { html, text } = renderEmail({
68
- preheader: subject,
69
- heading: "Access request",
70
- paragraphs: [
71
- `${emailStrong(input.requesterName)} (${emailStrong(input.requesterEmail)}) requested access to ${emailStrong(input.planTitle)}.`,
72
- "Open the plan and use Share to grant access if this request should be approved.",
73
- ],
74
- cta: { label: "Open plan", url },
75
- footer: "You received this because you own this Agent-Native Plan.",
89
+ await sendEmail({
90
+ ...renderPlanAccessRequestEmail({
91
+ requesterName: input.requesterName,
92
+ requesterEmail: input.requesterEmail,
93
+ planTitle: input.planTitle,
94
+ url: absolutePlanUrl(input.planId, input.planKind),
95
+ }),
96
+ to: input.ownerEmail,
97
+ templateId: PLAN_ACCESS_REQUEST_EMAIL_ID,
76
98
  });
77
- await sendEmail({ to: input.ownerEmail, subject, html, text });
78
99
  return true;
79
100
  }
80
101
 
@@ -14,6 +14,8 @@ import {
14
14
  import type { PlanBundle, PlanComment } from "../../shared/types.js";
15
15
  import { getDb, schema } from "../db/index.js";
16
16
 
17
+ export const PLAN_COMMENT_EMAIL_ID = "plan.plan-comment";
18
+
17
19
  type CommentNotificationInput = {
18
20
  bundle: PlanBundle;
19
21
  insertedCommentIds: string[];
@@ -205,40 +207,64 @@ export function planCommentNotificationRecipients(input: {
205
207
  return Array.from(recipients.values());
206
208
  }
207
209
 
208
- async function sendPlanCommentNotification(input: {
209
- recipient: NotificationRecipient;
210
- comment: PlanComment;
210
+ export function renderPlanCommentEmail(input: {
211
+ actor: string;
212
+ app: string;
211
213
  planTitle: string;
212
- planId: string;
214
+ planUrl: string;
215
+ message: string;
216
+ isReply: boolean;
217
+ reason: NotificationRecipient["reason"];
213
218
  }) {
214
- const actor = actorName(input.comment);
215
- const app = appName();
216
- const isReply = Boolean(input.comment.parentCommentId);
217
- const subject = isReply
218
- ? `${actor} replied to a comment on "${input.planTitle}"`
219
- : `${actor} commented on "${input.planTitle}"`;
220
219
  const actionText =
221
- input.recipient.reason === "mention"
220
+ input.reason === "mention"
222
221
  ? "mentioned you in a comment on"
223
- : input.recipient.reason === "plan-owner"
222
+ : input.reason === "plan-owner"
224
223
  ? "left a comment on your plan"
225
224
  : "replied in a comment thread you participated in";
226
- const { html, text } = renderEmail({
227
- preheader: `${actor} ${actionText} on ${app}.`,
228
- heading: isReply ? "New reply on your plan" : "New comment on your plan",
229
- paragraphs: [
230
- `${emailStrong(actor)} ${actionText} ${emailStrong(input.planTitle)}.`,
231
- `Comment: "${commentExcerpt(input.comment.message)}"`,
232
- ],
233
- cta: { label: "Open plan", url: planUrl(input.planId) },
234
- footer:
235
- input.recipient.reason === "plan-owner"
236
- ? "You received this because you own this plan."
237
- : input.recipient.reason === "mention"
238
- ? "You received this because you were mentioned in this comment."
239
- : "You received this because you participated in this comment thread.",
225
+ return {
226
+ subject: input.isReply
227
+ ? `${input.actor} replied to a comment on "${input.planTitle}"`
228
+ : `${input.actor} commented on "${input.planTitle}"`,
229
+ ...renderEmail({
230
+ preheader: `${input.actor} ${actionText} on ${input.app}.`,
231
+ heading: input.isReply
232
+ ? "New reply on your plan"
233
+ : "New comment on your plan",
234
+ paragraphs: [
235
+ `${emailStrong(input.actor)} ${actionText} ${emailStrong(input.planTitle)}.`,
236
+ `Comment: "${commentExcerpt(input.message)}"`,
237
+ ],
238
+ cta: { label: "Open plan", url: input.planUrl },
239
+ footer:
240
+ input.reason === "plan-owner"
241
+ ? "You received this because you own this plan."
242
+ : input.reason === "mention"
243
+ ? "You received this because you were mentioned in this comment."
244
+ : "You received this because you participated in this comment thread.",
245
+ }),
246
+ };
247
+ }
248
+
249
+ async function sendPlanCommentNotification(input: {
250
+ recipient: NotificationRecipient;
251
+ comment: PlanComment;
252
+ planTitle: string;
253
+ planId: string;
254
+ }) {
255
+ await sendEmail({
256
+ ...renderPlanCommentEmail({
257
+ actor: actorName(input.comment),
258
+ app: appName(),
259
+ planTitle: input.planTitle,
260
+ planUrl: planUrl(input.planId),
261
+ message: input.comment.message,
262
+ isReply: Boolean(input.comment.parentCommentId),
263
+ reason: input.recipient.reason,
264
+ }),
265
+ to: input.recipient.email,
266
+ templateId: PLAN_COMMENT_EMAIL_ID,
240
267
  });
241
- await sendEmail({ to: input.recipient.email, subject, html, text });
242
268
  }
243
269
 
244
270
  export async function notifyPlanCommentRecipients({
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Catalog entries for the transactional emails Plan sends.
3
+ *
4
+ * Registered from `server/plugins/transactional-emails.ts` so Dispatch can list
5
+ * and preview them without the app having sent anything yet.
6
+ */
7
+
8
+ import { defineTransactionalEmail } from "@agent-native/core/email-catalog";
9
+
10
+ import {
11
+ PLAN_ACCESS_REQUEST_EMAIL_ID,
12
+ renderPlanAccessRequestEmail,
13
+ } from "../../actions/request-plan-access.js";
14
+ import {
15
+ PLAN_COMMENT_EMAIL_ID,
16
+ renderPlanCommentEmail,
17
+ } from "./comment-notifications.js";
18
+
19
+ let registered = false;
20
+
21
+ export function registerPlanEmails(): void {
22
+ if (registered) return;
23
+ registered = true;
24
+
25
+ defineTransactionalEmail({
26
+ id: PLAN_COMMENT_EMAIL_ID,
27
+ name: "Plan comment",
28
+ trigger:
29
+ "A human (never the agent) posts a comment or reply on a plan or recap. The subject changes for a reply and the body changes with why the recipient was picked; all variants send under this id.",
30
+ recipientLabel: "Owner, mentions, authors",
31
+ recipient:
32
+ "The plan owner, everyone mentioned in the comment, and — for a reply — every other human author in the same thread. The comment's own author, synthetic QA addresses, and the owner when the comment mentions the source author are all dropped. One email per remaining address.",
33
+ senderLabel: "Default sender",
34
+ sender:
35
+ "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`.",
36
+ preview: () =>
37
+ renderPlanCommentEmail({
38
+ actor: "Sam Rivera",
39
+ app: "Agent-Native Plan",
40
+ planTitle: "Checkout rewrite",
41
+ planUrl: "https://example.com/plans/plan_sample",
42
+ message: "Can we split the migration into two deploys?",
43
+ isReply: false,
44
+ reason: "plan-owner",
45
+ }),
46
+ });
47
+
48
+ defineTransactionalEmail({
49
+ id: PLAN_ACCESS_REQUEST_EMAIL_ID,
50
+ name: "Plan access request",
51
+ trigger:
52
+ "A signed-in user who cannot resolve access calls `request-plan-access` on a private plan URL. Skipped when email is not configured or the requester is the owner.",
53
+ recipientLabel: "Plan owner",
54
+ recipient:
55
+ "The plan's `ownerEmail` column. Anonymous public viewers and guest author identities cannot reach this path.",
56
+ senderLabel: "Default sender",
57
+ sender:
58
+ "The configured default sender. This call site sets no `from`, `fromName`, `replyTo`, or `appSender`, so the owner cannot reply straight to the requester.",
59
+ preview: () =>
60
+ renderPlanAccessRequestEmail({
61
+ requesterName: "Sam Rivera",
62
+ requesterEmail: "sam.rivera@example.com",
63
+ planTitle: "Checkout rewrite",
64
+ url: "https://example.com/plans/plan_sample",
65
+ }),
66
+ });
67
+ }
@@ -0,0 +1,5 @@
1
+ import { registerPlanEmails } from "../lib/emails.js";
2
+
3
+ export default function registerTransactionalEmails(): void {
4
+ registerPlanEmails();
5
+ }
@@ -6,6 +6,7 @@ export default defineAction({
6
6
  description:
7
7
  "Analyze a website URL to extract design tokens (colors, fonts, metadata) " +
8
8
  "for use in creating or updating a design project. " +
9
+ "Follows bounded, SSRF-safe linked stylesheets and reports any stylesheet failures. " +
9
10
  "Returns extracted CSS variables, font faces, colors, and meta information.",
10
11
  schema: z.object({
11
12
  url: z.string().describe("Website URL to analyze"),