@agent-native/core 0.128.0 → 0.128.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 (132) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +27 -0
  3. package/corpus/core/package.json +3 -1
  4. package/corpus/core/src/server/builder-design-systems.ts +299 -82
  5. package/corpus/core/src/server/email-template.ts +33 -0
  6. package/corpus/core/src/server/index.ts +8 -0
  7. package/corpus/templates/analytics/_gitignore +2 -0
  8. package/corpus/templates/assets/_gitignore +2 -0
  9. package/corpus/templates/calendar/_gitignore +2 -0
  10. package/corpus/templates/chat/_gitignore +2 -0
  11. package/corpus/templates/clips/AGENTS.md +6 -1
  12. package/corpus/templates/clips/_gitignore +2 -0
  13. package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
  14. package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
  15. package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
  16. package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
  17. package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
  18. package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
  19. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
  20. package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
  21. package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
  22. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
  23. package/corpus/templates/clips/app/global.css +23 -0
  24. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
  25. package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
  26. package/corpus/templates/clips/app/routes/_app.tsx +2 -0
  27. package/corpus/templates/clips/app/routes/import.tsx +396 -0
  28. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
  29. package/corpus/templates/clips/app/routes/record.tsx +13 -71
  30. package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
  31. package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
  32. package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
  33. package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
  34. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
  35. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  36. package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
  37. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  38. package/corpus/templates/content/AGENTS.md +8 -0
  39. package/corpus/templates/content/_gitignore +2 -0
  40. package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
  41. package/corpus/templates/content/actions/_property-utils.ts +32 -2
  42. package/corpus/templates/content/actions/configure-document-property.ts +12 -2
  43. package/corpus/templates/content/actions/delete-document-property.ts +13 -3
  44. package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
  45. package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
  46. package/corpus/templates/content/actions/list-document-properties.ts +12 -3
  47. package/corpus/templates/content/actions/move-database-item.ts +74 -55
  48. package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
  49. package/corpus/templates/content/actions/set-document-property.ts +10 -1
  50. package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
  51. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
  52. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
  53. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
  54. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
  55. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
  56. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
  57. package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
  58. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
  59. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
  60. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
  61. package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
  62. package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
  63. package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
  64. package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
  65. package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
  66. package/corpus/templates/content/app/i18n-data.ts +261 -3
  67. package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
  68. package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
  69. package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
  70. package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
  71. package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
  72. package/corpus/templates/content/shared/api.ts +28 -0
  73. package/corpus/templates/design/_gitignore +2 -0
  74. package/corpus/templates/design/app/i18n-data.ts +10 -0
  75. package/corpus/templates/design/app/lib/builder-design-system-upload.ts +280 -0
  76. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +90 -54
  77. package/corpus/templates/design/changelog/2026-07-28-figma-fig-uploads-now-show-indexing-progress-and-an-open-in-.md +6 -0
  78. package/corpus/templates/design/server/handlers/design-system-decode-job-status.ts +49 -0
  79. package/corpus/templates/design/server/handlers/design-system-upload-start.ts +87 -0
  80. package/corpus/templates/design/server/handlers/index-design-system-sources.ts +82 -0
  81. package/corpus/templates/design/server/routes/api/design-system-decode-job-status.get.ts +1 -0
  82. package/corpus/templates/design/server/routes/api/design-system-upload-start.post.ts +1 -0
  83. package/corpus/templates/design/server/routes/api/index-design-system-sources.post.ts +1 -0
  84. package/corpus/templates/dispatch/_gitignore +2 -0
  85. package/corpus/templates/forms/_gitignore +2 -0
  86. package/corpus/templates/macros/_gitignore +2 -0
  87. package/corpus/templates/mail/_gitignore +2 -0
  88. package/corpus/templates/plan/_gitignore +2 -0
  89. package/corpus/templates/slides/_gitignore +2 -0
  90. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +126 -53
  91. package/corpus/templates/slides/app/components/design-system/builder-design-system-upload.ts +271 -0
  92. package/corpus/templates/slides/app/components/design-system/builder-index-response.ts +1 -1
  93. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  94. package/corpus/templates/slides/server/handlers/design-system-decode-job-status.ts +49 -0
  95. package/corpus/templates/slides/server/handlers/design-system-upload-start.ts +87 -0
  96. package/corpus/templates/slides/server/handlers/index-design-system-sources.ts +79 -0
  97. package/corpus/templates/slides/server/routes/api/design-system-decode-job-status.get.ts +1 -0
  98. package/corpus/templates/slides/server/routes/api/design-system-upload-start.post.ts +1 -0
  99. package/corpus/templates/slides/server/routes/api/index-design-system-sources.post.ts +1 -0
  100. package/corpus/templates/tasks/_gitignore +2 -0
  101. package/corpus/toolkit/CHANGELOG.md +6 -0
  102. package/corpus/toolkit/package.json +1 -1
  103. package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
  104. package/dist/collab/routes.d.ts +1 -1
  105. package/dist/collab/struct-routes.d.ts +1 -1
  106. package/dist/observability/routes.d.ts +3 -3
  107. package/dist/progress/routes.d.ts +1 -1
  108. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  109. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  110. package/dist/server/builder-design-systems.d.ts +49 -0
  111. package/dist/server/builder-design-systems.d.ts.map +1 -1
  112. package/dist/server/builder-design-systems.js +221 -69
  113. package/dist/server/builder-design-systems.js.map +1 -1
  114. package/dist/server/email-template.d.ts +7 -0
  115. package/dist/server/email-template.d.ts.map +1 -1
  116. package/dist/server/email-template.js +24 -0
  117. package/dist/server/email-template.js.map +1 -1
  118. package/dist/server/index.d.ts +1 -1
  119. package/dist/server/index.d.ts.map +1 -1
  120. package/dist/server/index.js +1 -1
  121. package/dist/server/index.js.map +1 -1
  122. package/dist/server/transcribe-voice.d.ts +1 -1
  123. package/dist/templates/chat/_gitignore +2 -0
  124. package/package.json +5 -3
  125. package/src/server/builder-design-systems.ts +299 -82
  126. package/src/server/email-template.ts +33 -0
  127. package/src/server/index.ts +8 -0
  128. package/src/templates/chat/_gitignore +2 -0
  129. package/corpus/templates/design/server/handlers/index-design-system-with-builder.ts +0 -151
  130. package/corpus/templates/design/server/routes/api/index-design-system-with-builder.post.ts +0 -1
  131. package/corpus/templates/slides/server/handlers/index-design-system-with-builder.ts +0 -119
  132. package/corpus/templates/slides/server/routes/api/index-design-system-with-builder.post.ts +0 -1
@@ -0,0 +1,270 @@
1
+ import {
2
+ emailStrong,
3
+ getAppProductionUrl,
4
+ renderEmail,
5
+ sendEmail,
6
+ } from "@agent-native/core/server";
7
+
8
+ const CLIPS_BRAND_NAME = "Clips";
9
+ const EMAIL_SEND_TIMEOUT_MS = 60_000;
10
+ const FRIENDLY_REPLY_TO = "hello@agent-native.com";
11
+ const UNTITLED_CLIP = "Untitled Clip";
12
+
13
+ interface TransactionalEmailBase {
14
+ to: string;
15
+ }
16
+
17
+ export type ClipsTransactionalEmailInput =
18
+ | (TransactionalEmailBase & {
19
+ kind: "first-view";
20
+ recordingId: string;
21
+ title?: string | null;
22
+ viewerEmail?: string | null;
23
+ })
24
+ | (TransactionalEmailBase & {
25
+ kind: "unviewed-reminder";
26
+ recordingId: string;
27
+ title?: string | null;
28
+ senderEmail?: string | null;
29
+ senderName?: string | null;
30
+ brandLogoUrl?: string | null;
31
+ })
32
+ | (TransactionalEmailBase & {
33
+ kind: "first-import";
34
+ recordingId: string;
35
+ title?: string | null;
36
+ })
37
+ | (TransactionalEmailBase & {
38
+ kind: "two-clips";
39
+ generatedSummary?: string | null;
40
+ });
41
+
42
+ export interface ClipsTransactionalEmailRenderOptions {
43
+ appUrl: string;
44
+ appBasePath?: string;
45
+ }
46
+
47
+ export interface RenderedClipsTransactionalEmail {
48
+ subject: string;
49
+ html: string;
50
+ text: string;
51
+ }
52
+
53
+ function singleLine(value: string | null | undefined): string {
54
+ return (value ?? "").replace(/[\r\n]+/g, " ").trim();
55
+ }
56
+
57
+ function clipTitle(value: string | null | undefined): string {
58
+ return singleLine(value) || UNTITLED_CLIP;
59
+ }
60
+
61
+ export function normalizeEmailDisplayName(
62
+ value: string | null | undefined,
63
+ fallback: string,
64
+ ): string {
65
+ const email = singleLine(value);
66
+ const match = /^([A-Za-z]+(?:[._-][A-Za-z]+)*)@[^\s@]+$/.exec(email);
67
+ if (!match) return email || fallback;
68
+
69
+ return match[1]
70
+ .split(/[._-]/)
71
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
72
+ .join(" ");
73
+ }
74
+
75
+ function normalizeBasePath(value: string | undefined): string {
76
+ const normalized = singleLine(value).replace(/^\/+/, "").replace(/\/+$/, "");
77
+ return normalized ? `/${normalized}` : "";
78
+ }
79
+
80
+ function appUrlForPath(
81
+ path: string,
82
+ { appUrl, appBasePath }: ClipsTransactionalEmailRenderOptions,
83
+ ): string {
84
+ const url = new URL(appUrl);
85
+ let basePath = normalizeBasePath(url.pathname);
86
+ const configuredBasePath = normalizeBasePath(appBasePath);
87
+
88
+ if (
89
+ configuredBasePath &&
90
+ basePath !== configuredBasePath &&
91
+ !basePath.endsWith(configuredBasePath)
92
+ ) {
93
+ basePath = `${basePath}${configuredBasePath}`;
94
+ }
95
+
96
+ return new URL(`${basePath}${path}`, url.origin).toString();
97
+ }
98
+
99
+ function clipUrl(
100
+ recordingId: string,
101
+ options: ClipsTransactionalEmailRenderOptions,
102
+ ): string {
103
+ return appUrlForPath(`/r/${encodeURIComponent(recordingId)}`, options);
104
+ }
105
+
106
+ function recordUrl(options: ClipsTransactionalEmailRenderOptions): string {
107
+ return appUrlForPath("/record", options);
108
+ }
109
+
110
+ function resolveBrandLogoUrl(
111
+ value: string | null | undefined,
112
+ options: ClipsTransactionalEmailRenderOptions,
113
+ ): string | undefined {
114
+ const candidate = singleLine(value);
115
+ if (!candidate) return undefined;
116
+ try {
117
+ const url = new URL(candidate, options.appUrl);
118
+ return url.protocol === "https:" || url.protocol === "http:"
119
+ ? url.toString()
120
+ : undefined;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ function validReplyTo(value: string | null | undefined): string | undefined {
127
+ const candidate = singleLine(value).toLowerCase();
128
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidate) ? candidate : undefined;
129
+ }
130
+
131
+ export function renderClipsTransactionalEmail(
132
+ input: ClipsTransactionalEmailInput,
133
+ options: ClipsTransactionalEmailRenderOptions,
134
+ ): RenderedClipsTransactionalEmail {
135
+ const title = input.kind === "two-clips" ? undefined : clipTitle(input.title);
136
+
137
+ switch (input.kind) {
138
+ case "first-view": {
139
+ const viewer = normalizeEmailDisplayName(input.viewerEmail, "Someone");
140
+ const subject = `Your Clip “${title}” got its first view`;
141
+ const rendered = renderEmail({
142
+ brandName: CLIPS_BRAND_NAME,
143
+ preheader: subject,
144
+ heading: "Someone watched your Clip",
145
+ paragraphs: [
146
+ `${emailStrong(viewer)} registered the first view of ${emailStrong(title!)}.`,
147
+ "Clips tracks advanced analytics on your viewers' activity, and can even tell you whether your recipient took AI actions with your link. Come back to Clips to view analytics, or configure Clips AI to take agentic actions on your behalf.",
148
+ ],
149
+ cta: {
150
+ label: "See Clip activity",
151
+ url: clipUrl(input.recordingId, options),
152
+ },
153
+ footer:
154
+ "You received this one-time note because this Clip got its first registered view.",
155
+ });
156
+ return { subject, ...rendered };
157
+ }
158
+
159
+ case "unviewed-reminder": {
160
+ const sender =
161
+ singleLine(input.senderName) ||
162
+ normalizeEmailDisplayName(input.senderEmail, "Someone");
163
+ const subject = `Still need to watch “${title}”?`;
164
+ const url = clipUrl(input.recordingId, options);
165
+ const rendered = renderEmail({
166
+ brandName: CLIPS_BRAND_NAME,
167
+ brandLogoUrl: resolveBrandLogoUrl(input.brandLogoUrl, options),
168
+ preheader: subject,
169
+ heading: `${sender} shared a Clip with you`,
170
+ paragraphs: [
171
+ `${emailStrong(title!)} is waiting whenever you have a moment.`,
172
+ ],
173
+ linkBlock: {
174
+ intro:
175
+ "Don't have a moment to spare? Share the below link with your own AI agent and ask it for a summary:",
176
+ url,
177
+ },
178
+ cta: {
179
+ label: "Watch the Clip Manually",
180
+ url,
181
+ },
182
+ footer: `You received this reminder because ${sender} shared this Clip with you two days ago.`,
183
+ });
184
+ return { subject, ...rendered };
185
+ }
186
+
187
+ case "first-import": {
188
+ const subject = "Your first imported video is now Agent-Native";
189
+ const url = clipUrl(input.recordingId, options);
190
+ const rendered = renderEmail({
191
+ brandName: CLIPS_BRAND_NAME,
192
+ preheader: subject,
193
+ heading: "Your video is ready for more than playback",
194
+ paragraphs: [
195
+ `${emailStrong(title!)} is now an Agent-Native Clip.`,
196
+ "Its speech and on-screen visuals are available as agent-readable context for summaries, exact-moment lookup, tickets, emails, and follow-up work.",
197
+ ],
198
+ cta: {
199
+ label: "Open your Agent-Native Clip",
200
+ url,
201
+ },
202
+ linkBlock: {
203
+ intro: "Or just feed this link to your own AI agent:",
204
+ url,
205
+ placement: "after-cta",
206
+ },
207
+ footer:
208
+ "You received this one-time note because your first imported video is ready.",
209
+ });
210
+ return { subject, ...rendered };
211
+ }
212
+
213
+ case "two-clips": {
214
+ const summary =
215
+ singleLine(input.generatedSummary) ||
216
+ "Two people shared Clips with you, giving you a quick look at what Agent-Native video can do.";
217
+ const subject = "You've received two Clips. What would you create?";
218
+ const rendered = renderEmail({
219
+ brandName: CLIPS_BRAND_NAME,
220
+ preheader: subject,
221
+ heading: "You’ve received two Agent-Native Clips",
222
+ paragraphs: [
223
+ emailStrong(summary),
224
+ "Clips are screen recordings that are friendly for both human viewing and AI agent use. What would you create with yours?",
225
+ ],
226
+ cta: {
227
+ label: "Record an Agent-Native Clip",
228
+ url: recordUrl(options),
229
+ },
230
+ footer:
231
+ "This one-time note was sent after two Clips were shared with you.",
232
+ });
233
+ return { subject, ...rendered };
234
+ }
235
+ }
236
+ }
237
+
238
+ export async function sendClipsTransactionalEmail(
239
+ input: ClipsTransactionalEmailInput,
240
+ ): Promise<void> {
241
+ const rendered = renderClipsTransactionalEmail(input, {
242
+ appUrl: getAppProductionUrl(),
243
+ appBasePath: process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH,
244
+ });
245
+
246
+ const reminderSender =
247
+ input.kind === "unviewed-reminder"
248
+ ? singleLine(input.senderName) ||
249
+ normalizeEmailDisplayName(input.senderEmail, "Someone")
250
+ : undefined;
251
+
252
+ await sendEmail({
253
+ to: input.to,
254
+ subject: rendered.subject,
255
+ html: rendered.html,
256
+ text: rendered.text,
257
+ fromName: reminderSender
258
+ ? `${reminderSender} (via Agent-Native Clips)`
259
+ : undefined,
260
+ replyTo:
261
+ input.kind === "unviewed-reminder"
262
+ ? (validReplyTo(input.senderEmail) ?? FRIENDLY_REPLY_TO)
263
+ : FRIENDLY_REPLY_TO,
264
+ timeoutMs: EMAIL_SEND_TIMEOUT_MS,
265
+ });
266
+ }
267
+
268
+ export const renderTransactionalEmail = renderClipsTransactionalEmail;
269
+ export const sendTransactionalEmail = sendClipsTransactionalEmail;
270
+ export type TransactionalEmailInput = ClipsTransactionalEmailInput;
@@ -11,6 +11,7 @@ import registerMediaVerificationJob from "../jobs/media-verification.js";
11
11
  import registerMeetingRemindersJob from "../jobs/meeting-reminders.js";
12
12
  import registerPollCalendarsJob from "../jobs/poll-calendars.js";
13
13
  import registerStaleMeetingSweeperJob from "../jobs/stale-meeting-sweeper.js";
14
+ import registerTransactionalEmailsJob from "../jobs/transactional-emails.js";
14
15
 
15
16
  export default () => {
16
17
  // The reminder job registers the `meeting-reminder` event on every
@@ -22,4 +23,5 @@ export default () => {
22
23
  registerMediaVerificationJob();
23
24
  registerPollCalendarsJob();
24
25
  registerStaleMeetingSweeperJob();
26
+ registerTransactionalEmailsJob();
25
27
  };
@@ -33,11 +33,16 @@ import {
33
33
  runWithRequestContext,
34
34
  } from "@agent-native/core/server";
35
35
  import { resolveAccess } from "@agent-native/core/sharing";
36
- import { and, asc, eq, isNull, sql } from "drizzle-orm";
36
+ import { and, asc, eq, isNull, not, or, sql } from "drizzle-orm";
37
37
  import { defineEventHandler, getRequestIP, setResponseStatus } from "h3";
38
38
 
39
39
  import { getDb, schema } from "../../db/index.js";
40
- import { nanoid, shouldCountView } from "../../lib/recordings.js";
40
+ import {
41
+ nanoid,
42
+ ownerEmailMatches,
43
+ shouldCountView,
44
+ } from "../../lib/recordings.js";
45
+ import { transactionalEmailStore } from "../../lib/transactional-email-store.js";
41
46
 
42
47
  interface ViewEventBody {
43
48
  recordingId?: string;
@@ -408,6 +413,58 @@ export default defineEventHandler(async (event) => {
408
413
  return updated;
409
414
  });
410
415
 
416
+ const ownerEmail = rec.ownerEmail?.trim();
417
+ const transitionedToCountedView =
418
+ !existing.countedView && persisted.countedView;
419
+ const viewerIsOwner =
420
+ Boolean(viewerEmail && ownerEmail) &&
421
+ viewerEmail!.trim().toLowerCase() === ownerEmail!.toLowerCase();
422
+ if (transitionedToCountedView && ownerEmail && !viewerIsOwner) {
423
+ try {
424
+ const { enabledAt } = await transactionalEmailStore.ensureEnabledAt();
425
+ const [firstNonOwnerView] = await db
426
+ .select({
427
+ id: schema.recordingViews.id,
428
+ viewerKey: schema.recordingViews.viewerKey,
429
+ viewSessionId: schema.recordingViews.viewSessionId,
430
+ viewedAt: schema.recordingViews.viewedAt,
431
+ })
432
+ .from(schema.recordingViews)
433
+ .where(
434
+ and(
435
+ eq(schema.recordingViews.recordingId, recordingId),
436
+ or(
437
+ isNull(schema.recordingViews.viewerEmail),
438
+ not(
439
+ ownerEmailMatches(
440
+ schema.recordingViews.viewerEmail,
441
+ ownerEmail,
442
+ ),
443
+ ),
444
+ ),
445
+ ),
446
+ )
447
+ .orderBy(
448
+ asc(schema.recordingViews.viewedAt),
449
+ asc(schema.recordingViews.id),
450
+ )
451
+ .limit(1);
452
+ const isCurrentCountedView =
453
+ firstNonOwnerView?.viewerKey === viewerKey &&
454
+ firstNonOwnerView.viewSessionId === countedViewSessionId;
455
+ if (isCurrentCountedView && firstNonOwnerView.viewedAt >= enabledAt) {
456
+ await transactionalEmailStore.enqueue(`first-view:${recordingId}`, {
457
+ type: "first-view",
458
+ recipient: ownerEmail,
459
+ recordingIds: [recordingId],
460
+ requestedBy: ownerEmail,
461
+ });
462
+ }
463
+ } catch (err) {
464
+ console.warn("[view-event] first-view email enqueue failed:", err);
465
+ }
466
+ }
467
+
411
468
  // Only broadcast a refresh signal on "meaningful" events to avoid
412
469
  // spamming the polling clients every 2s with watch-progress
413
470
  // heartbeats. Skip for anonymous viewers — application_state writes
@@ -161,8 +161,14 @@ adding fields without returning to the table header. The New property picker
161
161
  supports searching property types by label or machine name.
162
162
 
163
163
  In unconstrained table views, row drag handles can reorder database item
164
- pages through `move-database-item`; clear search, sort, and filters before
165
- manual reordering. Creating a database row returns the created item IDs and
164
+ pages through `move-database-item`; pass both `databaseId` and `itemId` so the
165
+ membership target is exact, and clear search, sort, and filters before manual
166
+ reordering. Pinned and workspace-root sidebar rows also reorder exact
167
+ memberships, but they are references: moving one never reparents, transfers,
168
+ or changes access to the referenced page. Files sidebar Custom order is
169
+ different again: persist it per user and per database view with
170
+ `update-content-database-personal-view`, without changing the shared Files
171
+ membership order. Creating a database row returns the created item IDs and
166
172
  opens the new row page in the side preview. Duplicating a database row
167
173
  returns the duplicate item IDs and opens the copied row in the side preview
168
174
  so users can continue editing the new page immediately, including from
@@ -88,3 +88,11 @@ Every action carries its own schema, and the rest of the app-specific surface
88
88
  (comments, sharing, databases, Notion, local file sources such as
89
89
  `remove-local-file-source`) is registered too — use `tool-search` instead of
90
90
  scanning a table here.
91
+
92
+ Sidebar ordering has two deliberately different meanings. Reordering Pinned or
93
+ workspace roots moves the exact database membership identified by both
94
+ `databaseId` and `itemId`; it never reparents or moves the referenced document.
95
+ Files sidebar Custom order is a per-user database-view preference written with
96
+ `update-content-database-personal-view`, so it must not change shared Files
97
+ membership positions. Ordinary unconstrained database row reordering remains a
98
+ shared database mutation through `move-database-item`.
@@ -8,8 +8,10 @@ npm-debug.log*
8
8
  pnpm-debug.log*
9
9
 
10
10
  node_modules
11
+ build/
11
12
  dist
12
13
  dist-ssr
14
+ .output/
13
15
  *.local
14
16
 
15
17
  # Editor directories and files
@@ -42,6 +42,11 @@ export const filterSchema = z.object({
42
42
  parentFilterGroupId: z.string().optional(),
43
43
  });
44
44
 
45
+ export const sidebarOrderSchema = z.object({
46
+ mode: z.enum(["custom", "last_edited", "name", "created"]),
47
+ itemIds: z.array(z.string()),
48
+ });
49
+
45
50
  const personalViewOverridesFields = {
46
51
  activeViewId: z.string().optional(),
47
52
  views: z.array(
@@ -50,6 +55,7 @@ const personalViewOverridesFields = {
50
55
  sorts: z.array(sortSchema).default([]),
51
56
  filters: z.array(filterSchema).default([]),
52
57
  filterMode: z.enum(["and", "or"]).default("and"),
58
+ sidebarOrder: sidebarOrderSchema.optional(),
53
59
  }),
54
60
  ),
55
61
  };
@@ -59,6 +65,24 @@ export const personalViewOverridesSchema = z.object({
59
65
  ...personalViewOverridesFields,
60
66
  });
61
67
 
68
+ export function normalizePersonalDatabaseViewOverrides(
69
+ overrides: z.infer<typeof personalViewOverridesSchema>,
70
+ validItemIds?: ReadonlySet<string>,
71
+ ) {
72
+ return {
73
+ ...overrides,
74
+ views: overrides.views.map((view) => ({
75
+ ...view,
76
+ sidebarOrder: {
77
+ mode: view.sidebarOrder?.mode ?? "custom",
78
+ itemIds: [...new Set(view.sidebarOrder?.itemIds ?? [])].filter(
79
+ (itemId) => !validItemIds || validItemIds.has(itemId),
80
+ ),
81
+ },
82
+ })),
83
+ };
84
+ }
85
+
62
86
  const legacyPersonalViewOverridesSchema = z.object({
63
87
  version: z.literal(1),
64
88
  ...personalViewOverridesFields,
@@ -94,7 +118,8 @@ export async function readPersonalDatabaseViewOverrides(
94
118
  personalDatabaseViewSettingKey(databaseId),
95
119
  );
96
120
  const parsed = personalViewOverridesSchema.safeParse(stored);
97
- if (parsed.success) return parsed.data;
121
+ if (parsed.success)
122
+ return normalizePersonalDatabaseViewOverrides(parsed.data);
98
123
 
99
124
  const legacy = legacyPersonalViewOverridesSchema.safeParse(stored);
100
125
  if (!legacy.success) return null;
@@ -103,7 +128,7 @@ export async function readPersonalDatabaseViewOverrides(
103
128
  .from(schema.contentDatabases)
104
129
  .where(eq(schema.contentDatabases.id, databaseId));
105
130
  const legacyParentKey = filesParentPropertyId(databaseId);
106
- return {
131
+ return normalizePersonalDatabaseViewOverrides({
107
132
  ...legacy.data,
108
133
  version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
109
134
  views: legacy.data.views.map((view) => ({
@@ -120,5 +145,5 @@ export async function readPersonalDatabaseViewOverrides(
120
145
  )
121
146
  : view.filters,
122
147
  })),
123
- };
148
+ });
124
149
  }
@@ -128,7 +128,31 @@ export async function getDatabaseMembershipForDocument(
128
128
 
129
129
  export async function resolvePropertyDatabaseForDocument(
130
130
  document: DocumentRow,
131
+ databaseId?: string,
132
+ role: "viewer" | "editor" | "admin" = "viewer",
131
133
  ): Promise<ContentDatabaseRow | null> {
134
+ if (databaseId) {
135
+ const database = await getDatabaseById(databaseId);
136
+ if (!database) throw new Error(`Database "${databaseId}" not found`);
137
+ await assertAccess("document", database.documentId, role);
138
+ if (database.documentId === document.id) return database;
139
+
140
+ const db = getDb();
141
+ const [membership] = await db
142
+ .select({ id: schema.contentDatabaseItems.id })
143
+ .from(schema.contentDatabaseItems)
144
+ .where(
145
+ and(
146
+ eq(schema.contentDatabaseItems.databaseId, databaseId),
147
+ eq(schema.contentDatabaseItems.documentId, document.id),
148
+ ),
149
+ );
150
+ if (!membership) {
151
+ throw new Error("Document is not part of this database.");
152
+ }
153
+ return database;
154
+ }
155
+
132
156
  const ownedDatabase = await getDatabaseForDocument(document.id);
133
157
  if (ownedDatabase) return ownedDatabase;
134
158
  const membership = await getDatabaseMembershipForDocument(document.id);
@@ -456,8 +480,14 @@ function normalizeStringList(value: unknown) {
456
480
  : [];
457
481
  }
458
482
 
459
- export async function listPropertiesForDocument(document: DocumentRow) {
460
- const database = await resolvePropertyDatabaseForDocument(document);
483
+ export async function listPropertiesForDocument(
484
+ document: DocumentRow,
485
+ databaseId?: string,
486
+ ) {
487
+ const database = await resolvePropertyDatabaseForDocument(
488
+ document,
489
+ databaseId,
490
+ );
461
491
  if (!database) return [];
462
492
  // Read path: PURE read. Seeding the primary Blocks field happens at create
463
493
  // time and via the one-time startup repair (repairUnseededBlocksFields) —
@@ -35,6 +35,12 @@ export default defineAction({
35
35
  documentId: z
36
36
  .string()
37
37
  .describe("Document ID used to scope the property workspace"),
38
+ databaseId: z
39
+ .string()
40
+ .optional()
41
+ .describe(
42
+ "Database ID that owns the property; omit only for context-free entry points",
43
+ ),
38
44
  name: z.string().min(1).describe("Property name"),
39
45
  description: z
40
46
  .string()
@@ -96,7 +102,11 @@ export default defineAction({
96
102
  const name = args.name.trim();
97
103
  const type = args.type as DocumentPropertyType;
98
104
  let optionsJson = optionsForNewProperty(type, args.options as any);
99
- const database = await resolvePropertyDatabaseForDocument(document);
105
+ const database = await resolvePropertyDatabaseForDocument(
106
+ document,
107
+ args.databaseId,
108
+ "editor",
109
+ );
100
110
  if (!database) {
101
111
  throw new Error(
102
112
  "Properties belong to databases. Create or open a database before adding properties.",
@@ -226,7 +236,7 @@ export default defineAction({
226
236
  return {
227
237
  documentId: args.documentId,
228
238
  databaseId: database.id,
229
- properties: await listPropertiesForDocument(document),
239
+ properties: await listPropertiesForDocument(document, database.id),
230
240
  };
231
241
  },
232
242
  });
@@ -21,13 +21,23 @@ export default defineAction({
21
21
  "Delete a Notion-style property definition and its stored document values.",
22
22
  schema: z.object({
23
23
  documentId: z.string().describe("Document ID used to scope access"),
24
+ databaseId: z
25
+ .string()
26
+ .optional()
27
+ .describe(
28
+ "Database ID that owns the property; omit only for context-free entry points",
29
+ ),
24
30
  propertyId: z.string().describe("Property definition ID to delete"),
25
31
  }),
26
- run: async ({ documentId, propertyId }) => {
32
+ run: async ({ documentId, databaseId, propertyId }) => {
27
33
  const access = await assertAccess("document", documentId, "editor");
28
34
  const document = access.resource;
29
35
  const db = getDb();
30
- const database = await resolvePropertyDatabaseForDocument(document);
36
+ const database = await resolvePropertyDatabaseForDocument(
37
+ document,
38
+ databaseId,
39
+ "editor",
40
+ );
31
41
  if (!database) throw new Error("Document is not part of a database.");
32
42
 
33
43
  const [definition] = await db
@@ -129,7 +139,7 @@ export default defineAction({
129
139
  return {
130
140
  documentId,
131
141
  databaseId: database.id,
132
- properties: await listPropertiesForDocument(document),
142
+ properties: await listPropertiesForDocument(document, database.id),
133
143
  };
134
144
  },
135
145
  });
@@ -25,13 +25,23 @@ export default defineAction({
25
25
  "Duplicate a Notion-style property definition and copy its stored values.",
26
26
  schema: z.object({
27
27
  documentId: z.string().describe("Document ID used to scope access"),
28
+ databaseId: z
29
+ .string()
30
+ .optional()
31
+ .describe(
32
+ "Database ID that owns the property; omit only for context-free entry points",
33
+ ),
28
34
  propertyId: z.string().describe("Property definition ID to duplicate"),
29
35
  }),
30
- run: async ({ documentId, propertyId }) => {
36
+ run: async ({ documentId, databaseId, propertyId }) => {
31
37
  const access = await assertAccess("document", documentId, "editor");
32
38
  const document = access.resource;
33
39
  const db = getDb();
34
- const database = await resolvePropertyDatabaseForDocument(document);
40
+ const database = await resolvePropertyDatabaseForDocument(
41
+ document,
42
+ databaseId,
43
+ "editor",
44
+ );
35
45
  if (!database) throw new Error("Document is not part of a database.");
36
46
 
37
47
  const [definition] = await db
@@ -123,7 +133,7 @@ export default defineAction({
123
133
  return {
124
134
  documentId,
125
135
  databaseId: database.id,
126
- properties: await listPropertiesForDocument(document),
136
+ properties: await listPropertiesForDocument(document, database.id),
127
137
  };
128
138
  },
129
139
  });
@@ -1,6 +1,6 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { getRequestUserEmail } from "@agent-native/core/server/request-context";
3
- import { and, eq, inArray, isNull } from "drizzle-orm";
3
+ import { and, asc, eq, inArray, isNull } from "drizzle-orm";
4
4
  import { z } from "zod";
5
5
 
6
6
  import { getDb, schema } from "../server/db/index.js";
@@ -65,7 +65,8 @@ export default defineAction({
65
65
  ),
66
66
  isNull(schema.contentSpaces.archivedAt),
67
67
  ),
68
- );
68
+ )
69
+ .orderBy(asc(schema.contentDatabaseItems.position));
69
70
  const filesDatabaseIds = rows.map((row) => row.space.filesDatabaseId);
70
71
  const databaseIds = [...filesDatabaseIds, favoritesIds.databaseId];
71
72
  const filesDatabases = databaseIds.length
@@ -90,6 +91,7 @@ export default defineAction({
90
91
  role: string;
91
92
  catalogItemId: string;
92
93
  catalogDocumentId: string;
94
+ catalogPosition: number;
93
95
  }>;
94
96
  for (const row of rows) {
95
97
  if (
@@ -117,6 +119,7 @@ export default defineAction({
117
119
  role,
118
120
  catalogItemId: row.mapping.databaseItemId,
119
121
  catalogDocumentId: row.mapping.documentId,
122
+ catalogPosition: row.item.position,
120
123
  });
121
124
  }
122
125
  return {