@agent-native/core 0.130.0 → 0.130.2

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 (108) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/integrations/a2a-continuation-processor.ts +481 -122
  5. package/corpus/core/src/integrations/a2a-continuations-store.ts +345 -14
  6. package/corpus/core/src/integrations/adapters/slack.ts +243 -53
  7. package/corpus/core/src/integrations/integration-campaign-recovery.ts +4 -1
  8. package/corpus/core/src/integrations/plugin.ts +39 -21
  9. package/corpus/core/src/integrations/task-queue-stats.ts +146 -0
  10. package/corpus/core/src/integrations/types.ts +25 -4
  11. package/corpus/core/src/integrations/webhook-handler.ts +18 -5
  12. package/corpus/core/src/server/email-template.ts +43 -13
  13. package/corpus/core/src/sharing/actions/share-resource.ts +26 -4
  14. package/corpus/core/src/sharing/index.ts +1 -0
  15. package/corpus/core/src/sharing/registry.ts +23 -0
  16. package/corpus/templates/clips/app/components/editor/editor-toolbar.tsx +3 -1
  17. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  18. package/corpus/templates/clips/app/components/meetings/day-header.tsx +1 -1
  19. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +1 -22
  20. package/corpus/templates/clips/app/components/workspace/branding-editor.tsx +26 -1
  21. package/corpus/templates/clips/app/components/workspace/organization-identity-card.tsx +107 -0
  22. package/corpus/templates/clips/app/i18n/en-US.ts +3 -4
  23. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +0 -10
  24. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +23 -51
  25. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +1 -8
  26. package/corpus/templates/clips/changelog/2026-07-29-clearing-all-recording-edits-no-longer-leaves-the-editor-unr.md +6 -0
  27. package/corpus/templates/clips/changelog/2026-07-29-clip-share-emails-now-open-with-who-shared-the-clip-add-a-su.md +6 -0
  28. package/corpus/templates/clips/changelog/2026-07-29-finished-meeting-pills-now-have-a-clear-close-button.md +6 -0
  29. package/corpus/templates/clips/changelog/2026-07-29-meeting-cards-no-longer-flicker-or-disappear-when-hovered-in.md +6 -0
  30. package/corpus/templates/clips/changelog/2026-07-29-organization-name-logo-brand-color-and-default-visibility-no.md +6 -0
  31. package/corpus/templates/clips/changelog/2026-07-29-transcript-lines-on-the-meeting-page-no-longer-jump-to-the-r.md +6 -0
  32. package/corpus/templates/clips/changelog/2026-07-29-when-a-meeting-wraps-up-the-recording-pill-now-shows-a-green.md +6 -0
  33. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +6 -3
  34. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +112 -32
  35. package/corpus/templates/clips/desktop/src/styles.css +42 -2
  36. package/corpus/templates/clips/server/db/index.ts +6 -0
  37. package/corpus/templates/clips/server/lib/share-email-hero.ts +33 -0
  38. package/corpus/templates/clips/shared/clips-ai-prefs.ts +0 -1
  39. package/corpus/templates/dispatch/changelog/2026-07-26-slack-replies-recover-after-connected-app-updates.md +6 -0
  40. package/corpus/templates/slides/.agents/skills/design-systems/SKILL.md +15 -0
  41. package/corpus/templates/slides/actions/delete-design-system.ts +145 -0
  42. package/corpus/templates/slides/actions/list-design-systems.ts +94 -2
  43. package/corpus/templates/slides/app/components/design-system/DesignSystemCard.tsx +43 -1
  44. package/corpus/templates/slides/app/hooks/use-design-systems.ts +2 -0
  45. package/corpus/templates/slides/app/i18n/en-US.ts +7 -0
  46. package/corpus/templates/slides/app/pages/DesignSystems.tsx +56 -1
  47. package/corpus/templates/slides/changelog/2026-07-28-delete-a-design-system-you-own-from-the-design-systems-page-.md +6 -0
  48. package/dist/collab/awareness.d.ts +2 -2
  49. package/dist/collab/awareness.d.ts.map +1 -1
  50. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  51. package/dist/integrations/a2a-continuation-processor.d.ts +5 -0
  52. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  53. package/dist/integrations/a2a-continuation-processor.js +293 -79
  54. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  55. package/dist/integrations/a2a-continuations-store.d.ts +23 -1
  56. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  57. package/dist/integrations/a2a-continuations-store.js +249 -15
  58. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  59. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  60. package/dist/integrations/adapters/slack.js +155 -31
  61. package/dist/integrations/adapters/slack.js.map +1 -1
  62. package/dist/integrations/integration-campaign-recovery.d.ts.map +1 -1
  63. package/dist/integrations/integration-campaign-recovery.js +3 -1
  64. package/dist/integrations/integration-campaign-recovery.js.map +1 -1
  65. package/dist/integrations/plugin.d.ts.map +1 -1
  66. package/dist/integrations/plugin.js +29 -12
  67. package/dist/integrations/plugin.js.map +1 -1
  68. package/dist/integrations/task-queue-stats.d.ts +12 -0
  69. package/dist/integrations/task-queue-stats.d.ts.map +1 -1
  70. package/dist/integrations/task-queue-stats.js +106 -0
  71. package/dist/integrations/task-queue-stats.js.map +1 -1
  72. package/dist/integrations/types.d.ts +24 -6
  73. package/dist/integrations/types.d.ts.map +1 -1
  74. package/dist/integrations/types.js.map +1 -1
  75. package/dist/integrations/webhook-handler.d.ts.map +1 -1
  76. package/dist/integrations/webhook-handler.js +12 -3
  77. package/dist/integrations/webhook-handler.js.map +1 -1
  78. package/dist/notifications/routes.d.ts +3 -3
  79. package/dist/observability/routes.d.ts +3 -3
  80. package/dist/server/email-template.d.ts +4 -0
  81. package/dist/server/email-template.d.ts.map +1 -1
  82. package/dist/server/email-template.js +25 -8
  83. package/dist/server/email-template.js.map +1 -1
  84. package/dist/server/realtime-token.d.ts +1 -1
  85. package/dist/server/transcribe-voice.d.ts +1 -1
  86. package/dist/sharing/actions/share-resource.d.ts.map +1 -1
  87. package/dist/sharing/actions/share-resource.js +23 -4
  88. package/dist/sharing/actions/share-resource.js.map +1 -1
  89. package/dist/sharing/index.d.ts +1 -1
  90. package/dist/sharing/index.d.ts.map +1 -1
  91. package/dist/sharing/index.js.map +1 -1
  92. package/dist/sharing/registry.d.ts +23 -0
  93. package/dist/sharing/registry.d.ts.map +1 -1
  94. package/dist/sharing/registry.js.map +1 -1
  95. package/package.json +1 -1
  96. package/src/integrations/a2a-continuation-processor.ts +481 -122
  97. package/src/integrations/a2a-continuations-store.ts +345 -14
  98. package/src/integrations/adapters/slack.ts +243 -53
  99. package/src/integrations/integration-campaign-recovery.ts +4 -1
  100. package/src/integrations/plugin.ts +39 -21
  101. package/src/integrations/task-queue-stats.ts +146 -0
  102. package/src/integrations/types.ts +25 -4
  103. package/src/integrations/webhook-handler.ts +18 -5
  104. package/src/server/email-template.ts +43 -13
  105. package/src/sharing/actions/share-resource.ts +26 -4
  106. package/src/sharing/index.ts +1 -0
  107. package/src/sharing/registry.ts +23 -0
  108. package/corpus/templates/clips/app/routes/_app.settings.organization.tsx +0 -232
@@ -8,6 +8,7 @@
8
8
  * columns before the SELECTs execute.
9
9
  */
10
10
  import { getDbExec } from "../db/client.js";
11
+ import { ensureA2AContinuationsTable } from "./a2a-continuations-store.js";
11
12
  import { ensurePendingTasksTable } from "./pending-tasks-store.js";
12
13
 
13
14
  export interface RecentFailure {
@@ -23,6 +24,18 @@ export interface TaskQueueStats {
23
24
  completed_last_hour: number;
24
25
  failed_last_hour: number;
25
26
  oldest_pending_age_seconds: number;
27
+ waiting_campaigns: number;
28
+ active_a2a_continuations: number;
29
+ oldest_active_a2a_age_seconds: number;
30
+ terminal_a2a_without_delivery: number;
31
+ recent_a2a_orphans: Array<{
32
+ continuation_id: string;
33
+ integration_task_id: string;
34
+ status: string;
35
+ attempts: number;
36
+ age_seconds: number;
37
+ reason_class: string;
38
+ }>;
26
39
  recent_failures: RecentFailure[];
27
40
  recent_tasks: Array<{
28
41
  id: string;
@@ -46,6 +59,11 @@ const ZERO_STATS: TaskQueueStats = {
46
59
  completed_last_hour: 0,
47
60
  failed_last_hour: 0,
48
61
  oldest_pending_age_seconds: 0,
62
+ waiting_campaigns: 0,
63
+ active_a2a_continuations: 0,
64
+ oldest_active_a2a_age_seconds: 0,
65
+ terminal_a2a_without_delivery: 0,
66
+ recent_a2a_orphans: [],
49
67
  recent_failures: [],
50
68
  recent_tasks: [],
51
69
  };
@@ -66,11 +84,14 @@ export async function getTaskQueueStats(
66
84
  scope: TaskQueueStatsScope,
67
85
  ): Promise<TaskQueueStats> {
68
86
  await ensurePendingTasksTable();
87
+ await ensureA2AContinuationsTable();
69
88
  const client = getDbExec();
70
89
  const now = Date.now();
71
90
  const oneHourAgo = now - 60 * 60 * 1000;
72
91
  const scopeSql = `owner_email = ?
73
92
  AND ((org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR org_id = ?)`;
93
+ const joinedScopeSql = `t.owner_email = ?
94
+ AND ((t.org_id IS NULL AND CAST(? AS TEXT) IS NULL) OR t.org_id = ?)`;
74
95
  const scopeArgs = [scope.ownerEmail, scope.orgId, scope.orgId];
75
96
 
76
97
  try {
@@ -178,6 +199,17 @@ export async function getTaskQueueStats(
178
199
  ),
179
200
  }),
180
201
  );
202
+ const waitingCampaigns = await readWaitingCampaignCount(
203
+ client,
204
+ joinedScopeSql,
205
+ scopeArgs,
206
+ );
207
+ const a2aStats = await readA2AContinuationStats(
208
+ client,
209
+ joinedScopeSql,
210
+ scopeArgs,
211
+ now,
212
+ );
181
213
 
182
214
  return {
183
215
  pending,
@@ -185,6 +217,8 @@ export async function getTaskQueueStats(
185
217
  completed_last_hour: completedLastHour,
186
218
  failed_last_hour: failedLastHour,
187
219
  oldest_pending_age_seconds: oldestPendingAgeSeconds,
220
+ waiting_campaigns: waitingCampaigns,
221
+ ...a2aStats,
188
222
  recent_failures: recentFailures,
189
223
  recent_tasks: recentTasks,
190
224
  };
@@ -195,3 +229,115 @@ export async function getTaskQueueStats(
195
229
  throw err;
196
230
  }
197
231
  }
232
+
233
+ async function readWaitingCampaignCount(
234
+ client: ReturnType<typeof getDbExec>,
235
+ scopeSql: string,
236
+ scopeArgs: Array<string | null>,
237
+ ): Promise<number> {
238
+ try {
239
+ const result = await client.execute({
240
+ sql: `SELECT COUNT(*) AS c
241
+ FROM integration_campaigns c
242
+ INNER JOIN integration_pending_tasks t
243
+ ON t.id = c.integration_task_id
244
+ WHERE ${scopeSql}
245
+ AND c.status = 'waiting'`,
246
+ args: scopeArgs,
247
+ });
248
+ return Number(result.rows[0]?.c ?? 0);
249
+ } catch (err) {
250
+ if (isMissingTableError(err)) return 0;
251
+ throw err;
252
+ }
253
+ }
254
+
255
+ async function readA2AContinuationStats(
256
+ client: ReturnType<typeof getDbExec>,
257
+ scopeSql: string,
258
+ scopeArgs: Array<string | null>,
259
+ now: number,
260
+ ): Promise<
261
+ Pick<
262
+ TaskQueueStats,
263
+ | "active_a2a_continuations"
264
+ | "oldest_active_a2a_age_seconds"
265
+ | "terminal_a2a_without_delivery"
266
+ | "recent_a2a_orphans"
267
+ >
268
+ > {
269
+ try {
270
+ const live = await client.execute({
271
+ sql: `SELECT
272
+ COUNT(CASE WHEN c.status IN ('pending', 'processing', 'delivering') THEN 1 END) AS active_count,
273
+ MIN(CASE WHEN c.status IN ('pending', 'processing', 'delivering') THEN c.created_at END) AS oldest_created_at,
274
+ COUNT(CASE WHEN c.status IN ('completed', 'failed')
275
+ AND c.terminal_delivery_confirmed_at IS NULL THEN 1 END) AS orphan_count
276
+ FROM integration_a2a_continuations c
277
+ INNER JOIN integration_pending_tasks t
278
+ ON t.id = c.integration_task_id
279
+ WHERE ${scopeSql}`,
280
+ args: scopeArgs,
281
+ });
282
+ const orphaned = await client.execute({
283
+ sql: `SELECT c.id, c.integration_task_id, c.status, c.attempts,
284
+ c.created_at, c.error_message
285
+ FROM integration_a2a_continuations c
286
+ INNER JOIN integration_pending_tasks t
287
+ ON t.id = c.integration_task_id
288
+ WHERE ${scopeSql}
289
+ AND c.status IN ('completed', 'failed')
290
+ AND c.terminal_delivery_confirmed_at IS NULL
291
+ ORDER BY c.updated_at DESC
292
+ LIMIT 5`,
293
+ args: scopeArgs,
294
+ });
295
+ const activeCount = Number(live.rows[0]?.active_count ?? 0);
296
+ const orphanCount = Number(live.rows[0]?.orphan_count ?? 0);
297
+ const oldestCreatedAt = Number(live.rows[0]?.oldest_created_at ?? now);
298
+ const recentOrphans = (orphaned.rows as Array<Record<string, unknown>>).map(
299
+ (row) => ({
300
+ continuation_id: String(row.id ?? ""),
301
+ integration_task_id: String(row.integration_task_id ?? ""),
302
+ status: String(row.status ?? ""),
303
+ attempts: Number(row.attempts ?? 0),
304
+ age_seconds: Math.max(
305
+ 0,
306
+ Math.floor((now - Number(row.created_at ?? now)) / 1000),
307
+ ),
308
+ reason_class: classifyA2AOrphanReason(row.error_message),
309
+ }),
310
+ );
311
+ return {
312
+ active_a2a_continuations: activeCount,
313
+ oldest_active_a2a_age_seconds:
314
+ activeCount > 0
315
+ ? Math.max(0, Math.floor((now - oldestCreatedAt) / 1000))
316
+ : 0,
317
+ terminal_a2a_without_delivery: orphanCount,
318
+ recent_a2a_orphans: recentOrphans,
319
+ };
320
+ } catch (err) {
321
+ if (isMissingTableError(err)) {
322
+ return {
323
+ active_a2a_continuations: 0,
324
+ oldest_active_a2a_age_seconds: 0,
325
+ terminal_a2a_without_delivery: 0,
326
+ recent_a2a_orphans: [],
327
+ };
328
+ }
329
+ throw err;
330
+ }
331
+ }
332
+
333
+ function classifyA2AOrphanReason(value: unknown): string {
334
+ const message = String(value ?? "");
335
+ if (!message) return "missing_terminal_delivery";
336
+ if (/timeout|timed out|abort/i.test(message)) return "timeout";
337
+ if (/token|secret|auth|credential/i.test(message)) return "authentication";
338
+ if (/database|sql|store|persist/i.test(message)) return "persistence";
339
+ if (/slack|provider|deliver|stream|response/i.test(message)) {
340
+ return "provider_delivery";
341
+ }
342
+ return "processor_failure";
343
+ }
@@ -134,6 +134,19 @@ export interface PlatformDeliveryReceipt {
134
134
  messageRefs?: string[];
135
135
  }
136
136
 
137
+ export interface PlatformDeliveryOptions {
138
+ /** Provider-owned message reference that should be updated in place. */
139
+ placeholderRef?: string;
140
+ /** Stable logical delivery key for provider-side retry reconciliation. */
141
+ idempotencyKey?: string;
142
+ /** Earliest provider timestamp (epoch ms) that can contain this delivery. */
143
+ reconcileAfter?: number;
144
+ /** Abort provider I/O before the caller releases its durable delivery claim. */
145
+ signal?: AbortSignal;
146
+ /** Do not fall back to a fresh post if the stable target cannot be updated. */
147
+ strictTargetRef?: boolean;
148
+ }
149
+
137
150
  /**
138
151
  * Proactive outbound message target for a platform.
139
152
  * Used when the agent needs to send to a saved destination instead of replying
@@ -201,12 +214,20 @@ export interface PlatformRunProgress {
201
214
  * credentials, or provider payload.
202
215
  */
203
216
  ref?: PlatformRunProgressRef;
217
+ /** Stable provider message target that can receive the terminal answer. */
218
+ responseTargetRef?: string;
204
219
  /** Receive normalized agent events. Implementations should throttle writes. */
205
- onEvent(event: AgentChatEvent): Promise<void> | void;
220
+ onEvent(
221
+ event: AgentChatEvent,
222
+ opts?: { signal?: AbortSignal },
223
+ ): Promise<void> | void;
206
224
  /** Finalize the provider-native progress surface with the answer. */
207
- complete(message: OutgoingMessage): Promise<void | PlatformDeliveryReceipt>;
225
+ complete(
226
+ message: OutgoingMessage,
227
+ opts?: { signal?: AbortSignal },
228
+ ): Promise<void | PlatformDeliveryReceipt>;
208
229
  /** Mark the provider-native surface failed and leave a retryable explanation. */
209
- fail?(message: string): Promise<void>;
230
+ fail?(message: string, opts?: { signal?: AbortSignal }): Promise<void>;
210
231
  }
211
232
 
212
233
  /**
@@ -322,7 +343,7 @@ export interface PlatformAdapter {
322
343
  sendResponse(
323
344
  message: OutgoingMessage,
324
345
  context: IncomingMessage,
325
- opts?: { placeholderRef?: string },
346
+ opts?: PlatformDeliveryOptions,
326
347
  ): Promise<void | PlatformDeliveryReceipt>;
327
348
 
328
349
  /**
@@ -67,7 +67,8 @@ import {
67
67
  import { runWithRequestContext } from "../server/request-context.js";
68
68
  import { normalizeReasoningEffortForRequest } from "../shared/reasoning-effort.js";
69
69
  import { A2A_CONTINUATION_QUEUED_MARKER } from "./a2a-continuation-marker.js";
70
- import { hasActiveA2AContinuationsForIntegrationTask } from "./a2a-continuations-store.js";
70
+ import { reconcileTerminalA2AParentIfDisabled } from "./a2a-continuation-processor.js";
71
+ import { getA2AContinuationTaskOutcome } from "./a2a-continuations-store.js";
71
72
  import {
72
73
  clearIntegrationAwaitingInput,
73
74
  setIntegrationAwaitingInput,
@@ -1086,10 +1087,22 @@ async function processIncomingMessage(
1086
1087
  JSON.parse(campaign.row.checkpoint).waitingForA2A === true;
1087
1088
  } catch {}
1088
1089
  if (waitingForA2A) {
1089
- const activeA2A = await hasActiveA2AContinuationsForIntegrationTask(
1090
- opts.taskId,
1091
- );
1092
- if (activeA2A) {
1090
+ const a2aOutcome = await getA2AContinuationTaskOutcome(opts.taskId);
1091
+ if (a2aOutcome !== "terminal-delivered") {
1092
+ if (
1093
+ a2aOutcome === "terminal-without-delivery" &&
1094
+ (await reconcileTerminalA2AParentIfDisabled(opts.taskId))
1095
+ ) {
1096
+ await releaseApplicableIntegrationBudgets(
1097
+ budgetReservations.reservations,
1098
+ );
1099
+ return { status: "campaign-failed" };
1100
+ }
1101
+ if (a2aOutcome !== "active") {
1102
+ console.warn(
1103
+ `[integrations] Waiting campaign ${campaign.row.id} has A2A outcome ${a2aOutcome} without terminal delivery proof`,
1104
+ );
1105
+ }
1093
1106
  const waiting = await waitForA2AIntegrationCampaign(campaign.row.id, {
1094
1107
  runId: campaign.runId,
1095
1108
  leaseToken: campaign.leaseToken,
@@ -42,6 +42,8 @@ export interface RenderEmailArgs {
42
42
  paragraphs: string[];
43
43
  /** Primary call-to-action rendered as a real button. */
44
44
  cta?: EmailCta;
45
+ /** Optional second button rendered beside the primary CTA, dark-filled. */
46
+ secondaryCta?: EmailCta;
45
47
  /** A treated, copyable URL shown before or after the CTA. */
46
48
  linkBlock?: EmailLinkBlock;
47
49
  /**
@@ -50,6 +52,8 @@ export interface RenderEmailArgs {
50
52
  * by app/template code (never raw user input), and escape any dynamic values.
51
53
  */
52
54
  heroHtml?: string;
55
+ /** Body paragraphs rendered after the CTA and link block. Escaped-by-caller. */
56
+ closingParagraphs?: string[];
53
57
  /** Small muted text under the CTA (e.g. expiry note). */
54
58
  footer?: string;
55
59
  /** Optional app name shown beside the framework logo. */
@@ -128,12 +132,18 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
128
132
  // dynamic values they interpolate.
129
133
  const heroHtml = args.heroHtml ?? "";
130
134
 
131
- const paragraphsHtml = args.paragraphs
132
- .map(
133
- (p) =>
134
- `<p style="margin:0 0 16px 0; font-size:16px; line-height:1.6; color:#d4d4d8;">${p}</p>`,
135
- )
136
- .join("");
135
+ const renderParagraphs = (paragraphs: string[] | undefined): string =>
136
+ (paragraphs ?? [])
137
+ .map(
138
+ (p) =>
139
+ `<p style="margin:0 0 16px 0; font-size:16px; line-height:1.6; color:#d4d4d8;">${p}</p>`,
140
+ )
141
+ .join("");
142
+
143
+ const paragraphsHtml = renderParagraphs(args.paragraphs);
144
+ const closingParagraphsHtml = args.closingParagraphs?.length
145
+ ? `<div style="margin:28px 0 0 0;">${renderParagraphs(args.closingParagraphs)}</div>`
146
+ : "";
137
147
 
138
148
  const linkBlockHtml = args.linkBlock
139
149
  ? `
@@ -148,16 +158,27 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
148
158
  `
149
159
  : "";
150
160
 
161
+ const ctaButtonCell = (
162
+ cta: EmailCta,
163
+ background: string,
164
+ color: string,
165
+ border: string,
166
+ ): string => `
167
+ <td style="border-radius:10px; background:${background}; border:${border};">
168
+ <a href="${escapeAttr(cta.url)}"
169
+ style="display:inline-block; padding:14px 26px; font-family:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size:15px; font-weight:600; color:${color}; text-decoration:none; border-radius:10px;">
170
+ ${escapeHtml(cta.label)}
171
+ </a>
172
+ </td>`;
173
+
174
+ const secondaryCtaHtml = args.secondaryCta
175
+ ? `<td style="width:12px;">&nbsp;</td>${ctaButtonCell(args.secondaryCta, "#141417", "#fafafa", "1px solid #3f3f46")}`
176
+ : "";
177
+
151
178
  const ctaHtml = args.cta
152
179
  ? `
153
180
  <table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0 0 0;">
154
- <tr>
155
- <td style="border-radius:10px; background:${ctaBg};">
156
- <a href="${escapeAttr(args.cta.url)}"
157
- style="display:inline-block; padding:14px 26px; font-family:'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size:15px; font-weight:600; color:${ctaFg}; text-decoration:none; border-radius:10px;">
158
- ${escapeHtml(args.cta.label)}
159
- </a>
160
- </td>
181
+ <tr>${ctaButtonCell(args.cta, ctaBg, ctaFg, "0")}${secondaryCtaHtml}
161
182
  </tr>
162
183
  </table>
163
184
  `
@@ -211,6 +232,7 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
211
232
  ${args.linkBlock?.placement !== "after-cta" ? linkBlockHtml : ""}
212
233
  ${ctaHtml}
213
234
  ${args.linkBlock?.placement === "after-cta" ? linkBlockHtml : ""}
235
+ ${closingParagraphsHtml}
214
236
  ${footerHtml}
215
237
  </td>
216
238
  </tr>
@@ -237,11 +259,19 @@ export function renderEmail(args: RenderEmailArgs): RenderedEmail {
237
259
  textLines.push(`${args.cta.label}: ${args.cta.url}`);
238
260
  textLines.push("");
239
261
  }
262
+ if (args.secondaryCta) {
263
+ textLines.push(`${args.secondaryCta.label}: ${args.secondaryCta.url}`);
264
+ textLines.push("");
265
+ }
240
266
  if (args.linkBlock?.placement === "after-cta") {
241
267
  textLines.push(args.linkBlock.intro);
242
268
  textLines.push(args.linkBlock.url);
243
269
  textLines.push("");
244
270
  }
271
+ for (const p of args.closingParagraphs ?? []) {
272
+ textLines.push(stripTags(p));
273
+ textLines.push("");
274
+ }
245
275
  if (args.footer) {
246
276
  textLines.push(args.footer);
247
277
  }
@@ -4,13 +4,14 @@ import { z } from "zod";
4
4
  import { defineAction } from "../../action.js";
5
5
  import { getDbExec } from "../../db/client.js";
6
6
  import { getAppProductionUrl } from "../../server/app-url.js";
7
- import { renderEmail, emailStrong } from "../../server/email-template.js";
7
+ import { emailStrong, renderEmail } from "../../server/email-template.js";
8
8
  import { sendEmail, isEmailConfigured } from "../../server/email.js";
9
9
  import { invalidateCollabAccessCache } from "../../server/poll.js";
10
10
  import { getRequestUserEmail } from "../../server/request-context.js";
11
11
  import { getUserProfile } from "../../user-profile/store.js";
12
12
  import { assertAccess, ForbiddenError } from "../access.js";
13
13
  import { requireShareableResource } from "../registry.js";
14
+ import type { ShareEmailExtras } from "../registry.js";
14
15
  import {
15
16
  getExtensionShareChangeTargets,
16
17
  notifyExtensionShareChanged,
@@ -320,12 +321,14 @@ export default defineAction({
320
321
  );
321
322
  }
322
323
  }
324
+ const senderProfile = await getUserProfile(actor);
325
+ const senderDisplayName = senderProfile.name?.trim() || actor;
323
326
  let fromName: string | undefined;
324
327
  let replyTo: string | undefined;
325
328
  if (reg.getSender) {
326
329
  try {
327
330
  const sender = await reg.getSender(resource, {
328
- sender: await getUserProfile(actor),
331
+ sender: senderProfile,
329
332
  });
330
333
  fromName = sender?.fromName?.trim() || undefined;
331
334
  replyTo = sender?.replyTo?.trim() || undefined;
@@ -351,18 +354,37 @@ export default defineAction({
351
354
  );
352
355
  }
353
356
  }
357
+ let extras: ShareEmailExtras | undefined;
358
+ if (reg.getShareEmailExtras) {
359
+ try {
360
+ extras =
361
+ (await reg.getShareEmailExtras(resource, {
362
+ href: notificationUrl,
363
+ sender: senderProfile,
364
+ recipientEmail: principalId,
365
+ })) ?? undefined;
366
+ } catch (err) {
367
+ console.error(
368
+ "[share-resource] share email extras resolver failed; sending the plain notification:",
369
+ err,
370
+ );
371
+ }
372
+ }
354
373
  const subject = `${actor} shared "${resourceTitle}" with you on ${appName}`;
355
374
  const { html, text } = renderEmail({
356
375
  brandName,
357
376
  brandLogoUrl,
358
377
  preheader: subject,
359
- heading: "You've been given access",
360
- paragraphs: [
378
+ heading: `${senderDisplayName} shared "${resourceTitle}" with you`,
379
+ paragraphs: extras?.paragraphs ?? [
361
380
  `${emailStrong(actor)} has shared the ${reg.displayName} ${emailStrong(resourceTitle)} with you as a ${emailStrong(args.role)}.`,
362
381
  `Use the button below to open it. If prompted, sign in with ${emailStrong(principalId)}.`,
363
382
  ],
364
383
  heroHtml,
365
384
  cta: { label: `Open ${reg.displayName}`, url: notificationUrl },
385
+ secondaryCta: extras?.secondaryCta,
386
+ linkBlock: extras?.linkBlock,
387
+ closingParagraphs: extras?.closingParagraphs,
366
388
  footer: `You received this because ${actor} granted you ${args.role} access.`,
367
389
  });
368
390
  await sendEmail({
@@ -22,6 +22,7 @@ export {
22
22
  requireShareableResource,
23
23
  listShareableResources,
24
24
  type ShareableResourceRegistration,
25
+ type ShareEmailExtras,
25
26
  } from "./registry.js";
26
27
 
27
28
  export {
@@ -17,8 +17,20 @@
17
17
  * });
18
18
  */
19
19
 
20
+ import type { EmailCta, EmailLinkBlock } from "../server/email-template.js";
20
21
  import type { UserProfile } from "../user-profile/shared.js";
21
22
 
23
+ export interface ShareEmailExtras {
24
+ /**
25
+ * Replaces the default body paragraphs between the heading and the preview.
26
+ * Pass `[]` to send none. Omit to keep the default copy.
27
+ */
28
+ paragraphs?: string[];
29
+ secondaryCta?: EmailCta;
30
+ linkBlock?: EmailLinkBlock;
31
+ closingParagraphs?: string[];
32
+ }
33
+
22
34
  export interface ShareableResourceRegistration {
23
35
  /** Stable identifier used across actions, UI, and analytics. e.g. "document". */
24
36
  type: string;
@@ -85,6 +97,17 @@ export interface ShareableResourceRegistration {
85
97
  resource: any,
86
98
  ctx: { href: string; alt?: string },
87
99
  ) => string | undefined | Promise<string | undefined>;
100
+ /**
101
+ * Optional resolver for app-specific content in the share-notification
102
+ * email: a second button beside the primary CTA, a treated copyable link,
103
+ * and closing paragraphs beneath it. Closing paragraphs are injected
104
+ * verbatim (use `emailLink` / `emailStrong` for inline markup), so escape
105
+ * any dynamic values.
106
+ */
107
+ getShareEmailExtras?: (
108
+ resource: any,
109
+ ctx: { href: string; sender: UserProfile; recipientEmail: string },
110
+ ) => ShareEmailExtras | undefined | Promise<ShareEmailExtras | undefined>;
88
111
  /**
89
112
  * Drizzle DB accessor from the template's server/db/index.ts. Required —
90
113
  * the framework-level share actions and access helpers call this to reach
@@ -505,7 +505,9 @@ export function EditorToolbar({
505
505
  </DropdownMenuItem>
506
506
  ) : null}
507
507
  <DropdownMenuSeparator />
508
- <DropdownMenuItem onSelect={() => setClearOpen(true)}>
508
+ <DropdownMenuItem
509
+ onSelect={() => setTimeout(() => setClearOpen(true), 0)}
510
+ >
509
511
  <IconTrash className="mr-2 h-4 w-4" />
510
512
  {t("editorToolbar.clearAllEdits")}
511
513
  </DropdownMenuItem>
@@ -665,7 +665,7 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
665
665
  </div>
666
666
 
667
667
  <div className="shrink-0 space-y-2 px-3 py-2">
668
- <OrgSwitcher settingsPath="/settings/organization" />
668
+ <OrgSwitcher settingsPath="/settings#organization" />
669
669
  <DevDatabaseLink />
670
670
  </div>
671
671
  </>
@@ -10,7 +10,7 @@ interface DayHeaderProps {
10
10
 
11
11
  export function DayHeader({ label }: DayHeaderProps) {
12
12
  return (
13
- <div className="sticky top-0 z-10 -mx-1 px-1 backdrop-blur-md bg-background/85 supports-[backdrop-filter]:bg-background/70">
13
+ <div className="sticky top-0 z-10 -mx-1 bg-background/85 px-1">
14
14
  <div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground py-1.5">
15
15
  {label}
16
16
  </div>
@@ -29,8 +29,6 @@ export interface TranscriptSegment {
29
29
  interface TranscriptBubblesProps {
30
30
  segments: TranscriptSegment[];
31
31
  isLive: boolean;
32
- recordingId?: string | null;
33
- onSeek: (ms: number) => void;
34
32
  /**
35
33
  * Imperative ref hook: parent can scroll a particular segment into view.
36
34
  * Receives a function (segmentIndex) => void.
@@ -93,8 +91,6 @@ function groupConsecutive(segments: TranscriptSegment[]): BubbleGroup[] {
93
91
  export function TranscriptBubbles({
94
92
  segments,
95
93
  isLive,
96
- recordingId,
97
- onSeek,
98
94
  registerScrollTo,
99
95
  }: TranscriptBubblesProps) {
100
96
  const t = useT();
@@ -329,7 +325,6 @@ export function TranscriptBubbles({
329
325
  </div>
330
326
  <div className="space-y-0.5">
331
327
  {group.segments.map(({ seg, index }) => {
332
- const clickable = !!recordingId;
333
328
  return (
334
329
  <Tooltip key={index}>
335
330
  <TooltipTrigger asChild>
@@ -337,23 +332,7 @@ export function TranscriptBubbles({
337
332
  ref={(el) => {
338
333
  segmentRefs.current[index] = el;
339
334
  }}
340
- role={clickable ? "button" : undefined}
341
- tabIndex={clickable ? 0 : -1}
342
- onClick={() => clickable && onSeek(seg.startMs)}
343
- onKeyDown={(e) => {
344
- if (
345
- clickable &&
346
- (e.key === "Enter" || e.key === " ")
347
- ) {
348
- e.preventDefault();
349
- onSeek(seg.startMs);
350
- }
351
- }}
352
- className={cn(
353
- "group/segment -mx-1 rounded-md px-1 py-1 text-left text-sm leading-relaxed text-foreground transition-colors",
354
- clickable &&
355
- "cursor-pointer hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
356
- )}
335
+ className="group/segment -mx-1 rounded-md px-1 py-1 text-left text-sm leading-relaxed text-foreground transition-colors"
357
336
  >
358
337
  {seg.speaker && !isMe && (
359
338
  <span className="me-2 text-[11px] font-medium text-muted-foreground">
@@ -158,7 +158,7 @@ export function BrandingEditor({
158
158
 
159
159
  <div className="space-y-1.5">
160
160
  <Label>{t("brandingEditor.brandColor")}</Label>
161
- <div className="flex items-center gap-2">
161
+ <div className="flex flex-wrap items-center gap-2">
162
162
  <input
163
163
  type="color"
164
164
  value={brandColor}
@@ -191,6 +191,9 @@ export function BrandingEditor({
191
191
 
192
192
  <div className="space-y-1.5">
193
193
  <Label>{t("brandingEditor.logo")}</Label>
194
+ <p className="text-xs text-muted-foreground">
195
+ {t("brandingEditor.logoUsage")}
196
+ </p>
194
197
  <div
195
198
  className={`rounded-md border border-dashed p-4 flex items-center gap-4 ${
196
199
  dragging ? "bg-primary/5 border-primary" : ""
@@ -322,6 +325,28 @@ export function BrandingEditor({
322
325
  {name || t("brandingEditor.organizationFallback")}
323
326
  </div>
324
327
  </div>
328
+ <div className="text-xs uppercase tracking-wide text-muted-foreground mt-4 mb-2">
329
+ {t("brandingEditor.emailHeaderPreview")}
330
+ </div>
331
+ <div className="rounded-md border border-[#27272a] bg-[#0a0a0c] p-4 flex items-center justify-center gap-2">
332
+ {brandLogoUrl ? (
333
+ <img
334
+ src={brandLogoUrl}
335
+ alt=""
336
+ className="h-7 w-7 object-contain"
337
+ />
338
+ ) : (
339
+ <div
340
+ className="h-7 w-7 rounded bg-white/90 flex items-center justify-center font-semibold text-[12px]"
341
+ style={{ color: brandColor }}
342
+ >
343
+ {name.slice(0, 1).toUpperCase() || "C"}
344
+ </div>
345
+ )}
346
+ <span className="font-semibold text-[#fafafa] truncate">
347
+ {name || t("brandingEditor.organizationFallback")}
348
+ </span>
349
+ </div>
325
350
  </div>
326
351
 
327
352
  <div className="flex justify-end">