@agent-native/core 0.128.1 → 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 (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +2 -1
  4. package/corpus/core/src/server/email-template.ts +33 -0
  5. package/corpus/templates/chat/_gitignore +2 -0
  6. package/corpus/templates/clips/AGENTS.md +6 -1
  7. package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
  8. package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
  9. package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
  10. package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
  11. package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
  12. package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
  13. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
  14. package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
  15. package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
  16. package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
  17. package/corpus/templates/clips/app/global.css +23 -0
  18. package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
  19. package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
  20. package/corpus/templates/clips/app/routes/_app.tsx +2 -0
  21. package/corpus/templates/clips/app/routes/import.tsx +396 -0
  22. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
  23. package/corpus/templates/clips/app/routes/record.tsx +13 -71
  24. package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
  25. package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
  26. package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
  27. package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
  28. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
  29. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  30. package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
  31. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
  32. package/corpus/templates/content/AGENTS.md +8 -0
  33. package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
  34. package/corpus/templates/content/actions/_property-utils.ts +32 -2
  35. package/corpus/templates/content/actions/configure-document-property.ts +12 -2
  36. package/corpus/templates/content/actions/delete-document-property.ts +13 -3
  37. package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
  38. package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
  39. package/corpus/templates/content/actions/list-document-properties.ts +12 -3
  40. package/corpus/templates/content/actions/move-database-item.ts +74 -55
  41. package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
  42. package/corpus/templates/content/actions/set-document-property.ts +10 -1
  43. package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
  44. package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
  45. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
  46. package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
  47. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
  48. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
  49. package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
  50. package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
  51. package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
  52. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
  53. package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
  54. package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
  55. package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
  56. package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
  57. package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
  58. package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
  59. package/corpus/templates/content/app/i18n-data.ts +261 -3
  60. package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
  61. package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
  62. package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
  63. package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
  64. package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
  65. package/corpus/templates/content/shared/api.ts +28 -0
  66. package/corpus/toolkit/CHANGELOG.md +6 -0
  67. package/corpus/toolkit/package.json +1 -1
  68. package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
  69. package/dist/collab/struct-routes.d.ts +1 -1
  70. package/dist/observability/routes.d.ts +3 -3
  71. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  72. package/dist/server/email-template.d.ts +7 -0
  73. package/dist/server/email-template.d.ts.map +1 -1
  74. package/dist/server/email-template.js +24 -0
  75. package/dist/server/email-template.js.map +1 -1
  76. package/dist/server/transcribe-voice.d.ts +1 -1
  77. package/dist/templates/chat/_gitignore +2 -0
  78. package/package.json +4 -3
  79. package/src/server/email-template.ts +33 -0
  80. package/src/templates/chat/_gitignore +2 -0
@@ -1,9 +1,11 @@
1
1
  import { writeAppState } from "@agent-native/core/application-state";
2
2
  import { uploadFile } from "@agent-native/core/file-upload";
3
- import { and, eq } from "drizzle-orm";
3
+ import { and, asc, eq, gte, inArray } from "drizzle-orm";
4
4
 
5
5
  import { getDb, schema } from "../../server/db/index.js";
6
6
  import { queueBuilderMediaCompression } from "../../server/lib/builder-media-compression.js";
7
+ import { ownerEmailMatches } from "../../server/lib/recordings.js";
8
+ import { transactionalEmailStore } from "../../server/lib/transactional-email-store.js";
7
9
  import {
8
10
  extractLoomVideoId,
9
11
  normalizeLoomShareUrl,
@@ -19,6 +21,35 @@ export type LoomImportJobResult = {
19
21
  failureReason?: string;
20
22
  };
21
23
 
24
+ export async function enqueueFirstImportEmailIfEligible(
25
+ input: { recordingId: string; ownerEmail: string; createdAt: string },
26
+ db: ReturnType<typeof getDb> = getDb(),
27
+ ): Promise<void> {
28
+ const { enabledAt } = await transactionalEmailStore.ensureEnabledAt();
29
+ if (input.createdAt < enabledAt) return;
30
+
31
+ const [firstReadyImport] = await db
32
+ .select({ id: schema.recordings.id })
33
+ .from(schema.recordings)
34
+ .where(
35
+ and(
36
+ ownerEmailMatches(schema.recordings.ownerEmail, input.ownerEmail),
37
+ eq(schema.recordings.status, "ready"),
38
+ inArray(schema.recordings.sourceAppName, ["Loom", "Video link"]),
39
+ gte(schema.recordings.createdAt, enabledAt),
40
+ ),
41
+ )
42
+ .orderBy(asc(schema.recordings.createdAt), asc(schema.recordings.id))
43
+ .limit(1);
44
+ if (firstReadyImport?.id !== input.recordingId) return;
45
+
46
+ await transactionalEmailStore.enqueueOrConvergeFirstImport(
47
+ input.ownerEmail,
48
+ input.recordingId,
49
+ input.ownerEmail,
50
+ );
51
+ }
52
+
22
53
  export async function failLoomImport(
23
54
  recordingId: string,
24
55
  failureReason: string,
@@ -84,9 +115,15 @@ export async function runLoomImportJob({
84
115
  durationMs: schema.recordings.durationMs,
85
116
  sourceWindowTitle: schema.recordings.sourceWindowTitle,
86
117
  loomImportClaimId: schema.recordings.loomImportClaimId,
118
+ createdAt: schema.recordings.createdAt,
87
119
  })
88
120
  .from(schema.recordings)
89
- .where(eq(schema.recordings.id, recordingId));
121
+ .where(
122
+ and(
123
+ eq(schema.recordings.id, recordingId),
124
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
125
+ ),
126
+ );
90
127
 
91
128
  const shareUrl = normalizeLoomShareUrl(recording?.sourceWindowTitle ?? "");
92
129
  const loomId = shareUrl ? extractLoomVideoId(shareUrl) : null;
@@ -144,6 +181,7 @@ export async function runLoomImportJob({
144
181
  .where(
145
182
  and(
146
183
  eq(schema.recordings.id, recordingId),
184
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
147
185
  eq(schema.recordings.loomImportClaimId, claimId),
148
186
  ),
149
187
  )
@@ -216,6 +254,18 @@ export async function runLoomImportJob({
216
254
  });
217
255
  }
218
256
 
257
+ try {
258
+ await enqueueFirstImportEmailIfEligible(
259
+ { recordingId, ownerEmail, createdAt: recording.createdAt },
260
+ db,
261
+ );
262
+ } catch (err) {
263
+ console.warn("[clips] First-import email enqueue failed", {
264
+ recordingId,
265
+ error: err instanceof Error ? err.message : String(err),
266
+ });
267
+ }
268
+
219
269
  try {
220
270
  await writeAppState(`recording-upload-${recordingId}`, {
221
271
  recordingId,
@@ -239,6 +289,7 @@ export async function runLoomImportJob({
239
289
  .where(
240
290
  and(
241
291
  eq(schema.recordings.id, recordingId),
292
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
242
293
  eq(schema.recordings.loomImportClaimId, claimId),
243
294
  ),
244
295
  );
@@ -1,7 +1,8 @@
1
1
  import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
2
- import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
3
2
  import { z } from "zod";
4
3
 
4
+ import { readResponseBytesWithLimit } from "./video-download-limits.js";
5
+
5
6
  const LOOM_DOWNLOAD_TIMEOUT_MS = 120_000;
6
7
  const LOOM_VIDEO_USER_AGENT =
7
8
  "Mozilla/5.0 (compatible; AgentNativeClips/1.0; +https://agent-native.com)";
@@ -29,29 +30,6 @@ export class LoomVideoUnavailableError extends Error {
29
30
  }
30
31
  }
31
32
 
32
- function formatBytes(bytes: number): string {
33
- if (bytes >= 1024 * 1024) {
34
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
35
- }
36
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
37
- return `${bytes} bytes`;
38
- }
39
-
40
- function assertUploadSize(sizeBytes: number | null | undefined): void {
41
- if (!Number.isFinite(sizeBytes ?? NaN) || (sizeBytes ?? 0) <= 0) return;
42
- if ((sizeBytes ?? 0) > MAX_UPLOAD_BYTES) {
43
- throw new Error(
44
- `Loom video is too large to import (${formatBytes(sizeBytes ?? 0)}, max ${formatBytes(MAX_UPLOAD_BYTES)}). Download a shorter or compressed copy and upload it directly.`,
45
- );
46
- }
47
- }
48
-
49
- function parseContentLength(value: string | null): number | null {
50
- if (!value) return null;
51
- const parsed = Number(value);
52
- return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
53
- }
54
-
55
33
  function safeLoomDownloadUrl(value: string): string | null {
56
34
  try {
57
35
  const parsed = new URL(value);
@@ -65,44 +43,6 @@ function safeLoomDownloadUrl(value: string): string | null {
65
43
  }
66
44
  }
67
45
 
68
- async function readResponseBytesWithLimit(
69
- response: Response,
70
- ): Promise<Uint8Array> {
71
- const contentLength = parseContentLength(
72
- response.headers.get("content-length"),
73
- );
74
- assertUploadSize(contentLength);
75
-
76
- if (!response.body) {
77
- const arrayBuffer = await response.arrayBuffer();
78
- assertUploadSize(arrayBuffer.byteLength);
79
- return new Uint8Array(arrayBuffer);
80
- }
81
-
82
- const reader = response.body.getReader();
83
- const chunks: Buffer[] = [];
84
- let totalBytes = 0;
85
-
86
- try {
87
- while (true) {
88
- const { done, value } = await reader.read();
89
- if (done) break;
90
- if (!value) continue;
91
-
92
- totalBytes += value.byteLength;
93
- assertUploadSize(totalBytes);
94
- chunks.push(
95
- Buffer.from(value.buffer, value.byteOffset, value.byteLength),
96
- );
97
- }
98
- } finally {
99
- reader.releaseLock();
100
- }
101
-
102
- const buffer = Buffer.concat(chunks, totalBytes);
103
- return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
104
- }
105
-
106
46
  function normalizeVideoMimeType(value: string | null): string {
107
47
  const mimeType = (value ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
108
48
  if (!mimeType || mimeType === "application/octet-stream") return "video/mp4";
@@ -0,0 +1,62 @@
1
+ import { MAX_UPLOAD_BYTES } from "@shared/upload-limits.js";
2
+
3
+ export function formatBytes(bytes: number): string {
4
+ if (bytes >= 1024 * 1024) {
5
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
6
+ }
7
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
8
+ return `${bytes} bytes`;
9
+ }
10
+
11
+ export function assertUploadSize(sizeBytes: number | null | undefined): void {
12
+ if (!Number.isFinite(sizeBytes ?? NaN) || (sizeBytes ?? 0) <= 0) return;
13
+ if ((sizeBytes ?? 0) > MAX_UPLOAD_BYTES) {
14
+ throw new Error(
15
+ `Video is too large to import (${formatBytes(sizeBytes ?? 0)}, max ${formatBytes(MAX_UPLOAD_BYTES)}). Download a shorter or compressed copy and upload it directly.`,
16
+ );
17
+ }
18
+ }
19
+
20
+ function parseContentLength(value: string | null): number | null {
21
+ if (!value) return null;
22
+ const parsed = Number(value);
23
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null;
24
+ }
25
+
26
+ export async function readResponseBytesWithLimit(
27
+ response: Response,
28
+ ): Promise<Uint8Array> {
29
+ const contentLength = parseContentLength(
30
+ response.headers.get("content-length"),
31
+ );
32
+ assertUploadSize(contentLength);
33
+
34
+ if (!response.body) {
35
+ const arrayBuffer = await response.arrayBuffer();
36
+ assertUploadSize(arrayBuffer.byteLength);
37
+ return new Uint8Array(arrayBuffer);
38
+ }
39
+
40
+ const reader = response.body.getReader();
41
+ const chunks: Buffer[] = [];
42
+ let totalBytes = 0;
43
+
44
+ try {
45
+ while (true) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ if (!value) continue;
49
+
50
+ totalBytes += value.byteLength;
51
+ assertUploadSize(totalBytes);
52
+ chunks.push(
53
+ Buffer.from(value.buffer, value.byteOffset, value.byteLength),
54
+ );
55
+ }
56
+ } finally {
57
+ reader.releaseLock();
58
+ }
59
+
60
+ const buffer = Buffer.concat(chunks, totalBytes);
61
+ return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
62
+ }
@@ -0,0 +1,249 @@
1
+ import { defineAction } from "@agent-native/core/action";
2
+ import { accessFilter, resolveAccess } from "@agent-native/core/sharing";
3
+ import { and, eq, gte, inArray } from "drizzle-orm";
4
+ import { z } from "zod";
5
+
6
+ import { getDb, schema } from "../server/db/index.js";
7
+ import {
8
+ getCurrentOwnerEmail,
9
+ ownerEmailMatches,
10
+ } from "../server/lib/recordings.js";
11
+ import {
12
+ AI_DISPATCH_STALE_MS,
13
+ transactionalEmailStore,
14
+ type TransactionalEmailJob,
15
+ } from "../server/lib/transactional-email-store.js";
16
+
17
+ const MAX_CLAIMS = 10;
18
+ const MAX_TITLE_LENGTH = 200;
19
+ const MAX_DESCRIPTION_LENGTH = 600;
20
+ export const MAX_TRANSCRIPT_EXCERPT_LENGTH = 1_200;
21
+
22
+ export type TransactionalEmailContextPacket = {
23
+ recordingId: string;
24
+ title: string;
25
+ description: string;
26
+ senderEmail: string;
27
+ transcriptExcerpt: string;
28
+ };
29
+
30
+ export type ClaimedTransactionalEmailAiRequest = {
31
+ jobId: string;
32
+ logicalKey: string;
33
+ contextPackets: [
34
+ TransactionalEmailContextPacket,
35
+ TransactionalEmailContextPacket,
36
+ ];
37
+ };
38
+
39
+ function normalizeEmail(value: string | null | undefined): string {
40
+ return value?.trim().toLowerCase() ?? "";
41
+ }
42
+
43
+ function boundedText(value: string | null | undefined, limit: number): string {
44
+ return (value ?? "").replace(/\s+/g, " ").trim().slice(0, limit);
45
+ }
46
+
47
+ async function claimantMayClaim(
48
+ job: TransactionalEmailJob,
49
+ claimantEmail: string,
50
+ ): Promise<boolean> {
51
+ if (normalizeEmail(job.recipient) === claimantEmail) return true;
52
+ if (normalizeEmail(job.requestedBy) !== claimantEmail) return false;
53
+
54
+ const access = await Promise.all(
55
+ job.recordingIds.map((recordingId) =>
56
+ resolveAccess("recording", recordingId),
57
+ ),
58
+ );
59
+ if (!access.every(Boolean)) return false;
60
+
61
+ const db = getDb();
62
+ const [directShares, countedViews] = await Promise.all([
63
+ db
64
+ .select({ recordingId: schema.recordingShares.resourceId })
65
+ .from(schema.recordingShares)
66
+ .where(
67
+ and(
68
+ eq(schema.recordingShares.principalType, "user"),
69
+ ownerEmailMatches(schema.recordingShares.principalId, claimantEmail),
70
+ inArray(schema.recordingShares.resourceId, job.recordingIds),
71
+ ),
72
+ ),
73
+ db
74
+ .select({ recordingId: schema.recordingViewers.recordingId })
75
+ .from(schema.recordingViewers)
76
+ .where(
77
+ and(
78
+ ownerEmailMatches(schema.recordingViewers.viewerEmail, claimantEmail),
79
+ eq(schema.recordingViewers.countedView, true),
80
+ inArray(schema.recordingViewers.recordingId, job.recordingIds),
81
+ ),
82
+ ),
83
+ ]);
84
+ const directlyRelatedIds = new Set([
85
+ ...access.flatMap((entry, index) =>
86
+ entry?.role === "owner" ? [job.recordingIds[index]] : [],
87
+ ),
88
+ ...directShares.map((share) => share.recordingId),
89
+ ...countedViews.map((view) => view.recordingId),
90
+ ]);
91
+ return job.recordingIds.every((recordingId) =>
92
+ directlyRelatedIds.has(recordingId),
93
+ );
94
+ }
95
+
96
+ async function loadContextPackets(
97
+ job: TransactionalEmailJob,
98
+ enabledAt: string,
99
+ ): Promise<
100
+ [TransactionalEmailContextPacket, TransactionalEmailContextPacket] | null
101
+ > {
102
+ if (job.type !== "two-clips" || job.recordingIds.length !== 2) return null;
103
+
104
+ const db = getDb();
105
+ const [recordings, transcripts, shares] = await Promise.all([
106
+ db
107
+ .select({
108
+ id: schema.recordings.id,
109
+ title: schema.recordings.title,
110
+ description: schema.recordings.description,
111
+ })
112
+ .from(schema.recordings)
113
+ .where(
114
+ and(
115
+ inArray(schema.recordings.id, job.recordingIds),
116
+ accessFilter(schema.recordings, schema.recordingShares),
117
+ ),
118
+ ),
119
+ db
120
+ .select({
121
+ recordingId: schema.recordingTranscripts.recordingId,
122
+ fullText: schema.recordingTranscripts.fullText,
123
+ })
124
+ .from(schema.recordingTranscripts)
125
+ .where(
126
+ inArray(schema.recordingTranscripts.recordingId, job.recordingIds),
127
+ ),
128
+ db
129
+ .select({
130
+ id: schema.recordingShares.id,
131
+ recordingId: schema.recordingShares.resourceId,
132
+ principalId: schema.recordingShares.principalId,
133
+ createdBy: schema.recordingShares.createdBy,
134
+ createdAt: schema.recordingShares.createdAt,
135
+ })
136
+ .from(schema.recordingShares)
137
+ .where(
138
+ and(
139
+ eq(schema.recordingShares.principalType, "user"),
140
+ inArray(schema.recordingShares.resourceId, job.recordingIds),
141
+ gte(schema.recordingShares.createdAt, enabledAt),
142
+ ),
143
+ ),
144
+ ]);
145
+
146
+ const recordingById = new Map(recordings.map((row) => [row.id, row]));
147
+ const transcriptById = new Map(
148
+ transcripts.map((row) => [row.recordingId, row.fullText]),
149
+ );
150
+ const recipient = normalizeEmail(job.recipient);
151
+ const senderByRecordingId = new Map<string, string>();
152
+ for (const share of shares
153
+ .filter(
154
+ (row) =>
155
+ normalizeEmail(row.principalId) === recipient &&
156
+ row.createdAt >= enabledAt,
157
+ )
158
+ .sort(
159
+ (left, right) =>
160
+ left.createdAt.localeCompare(right.createdAt) ||
161
+ left.id.localeCompare(right.id),
162
+ )) {
163
+ if (!senderByRecordingId.has(share.recordingId)) {
164
+ senderByRecordingId.set(
165
+ share.recordingId,
166
+ normalizeEmail(share.createdBy),
167
+ );
168
+ }
169
+ }
170
+
171
+ const packets = job.recordingIds.map((recordingId) => {
172
+ const recording = recordingById.get(recordingId);
173
+ const senderEmail = senderByRecordingId.get(recordingId);
174
+ if (!recording || !senderEmail) return null;
175
+ return {
176
+ recordingId,
177
+ title: boundedText(recording.title, MAX_TITLE_LENGTH),
178
+ description: boundedText(recording.description, MAX_DESCRIPTION_LENGTH),
179
+ senderEmail,
180
+ transcriptExcerpt: boundedText(
181
+ transcriptById.get(recordingId),
182
+ MAX_TRANSCRIPT_EXCERPT_LENGTH,
183
+ ),
184
+ };
185
+ });
186
+
187
+ return packets.length === 2 && packets[0] && packets[1]
188
+ ? [packets[0], packets[1]]
189
+ : null;
190
+ }
191
+
192
+ export async function claimTransactionalEmailAiRequests(
193
+ claimantEmail: string,
194
+ limit = MAX_CLAIMS,
195
+ ): Promise<{ requests: ClaimedTransactionalEmailAiRequest[] }> {
196
+ const claimant = normalizeEmail(claimantEmail);
197
+ const claimLimit = Math.min(Math.max(limit, 1), MAX_CLAIMS);
198
+ const config = await transactionalEmailStore.readConfig();
199
+ if (!config) return { requests: [] };
200
+ const staleBefore = new Date(Date.now() - AI_DISPATCH_STALE_MS);
201
+ const candidates = (await transactionalEmailStore.listJobs()).filter(
202
+ (job) =>
203
+ job.type === "two-clips" &&
204
+ (job.state === "awaiting_ai" ||
205
+ (job.state === "ai_dispatched" &&
206
+ Date.parse(job.aiDispatchedAt ?? job.updatedAt) <=
207
+ staleBefore.getTime())) &&
208
+ job.recordingIds.length === 2,
209
+ );
210
+ const requests: ClaimedTransactionalEmailAiRequest[] = [];
211
+
212
+ for (const candidate of candidates) {
213
+ if (requests.length >= claimLimit) break;
214
+ if (!(await claimantMayClaim(candidate, claimant))) continue;
215
+ const contextPackets = await loadContextPackets(
216
+ candidate,
217
+ config.enabledAt,
218
+ );
219
+ if (!contextPackets) continue;
220
+ const claimed =
221
+ candidate.state === "awaiting_ai"
222
+ ? await transactionalEmailStore.claimAwaitingAi(
223
+ candidate.logicalKey,
224
+ claimant,
225
+ )
226
+ : await transactionalEmailStore.reclaimStaleAiDispatch(
227
+ candidate.logicalKey,
228
+ claimant,
229
+ staleBefore,
230
+ );
231
+ if (!claimed) continue;
232
+ requests.push({
233
+ jobId: claimed.logicalKey,
234
+ logicalKey: claimed.logicalKey,
235
+ contextPackets,
236
+ });
237
+ }
238
+
239
+ return { requests };
240
+ }
241
+
242
+ export default defineAction({
243
+ description:
244
+ "Claim bounded two-Clip transactional email summary work for the signed-in Clips UI.",
245
+ schema: z.object({}),
246
+ http: { method: "GET" },
247
+ agentTool: false,
248
+ run: async () => claimTransactionalEmailAiRequests(getCurrentOwnerEmail()),
249
+ });
@@ -7,8 +7,12 @@ import {
7
7
  IconAlertTriangle,
8
8
  IconChevronLeft,
9
9
  IconChevronRight,
10
+ IconPlayerRecord,
11
+ IconUpload,
12
+ IconLink,
10
13
  } from "@tabler/icons-react";
11
14
  import { useCallback, useEffect, useMemo, useState } from "react";
15
+ import { NavLink } from "react-router";
12
16
  import { toast } from "sonner";
13
17
 
14
18
  import { CreateFolderDialog } from "@/components/library/create-folder-dialog";
@@ -59,6 +63,64 @@ function Skeleton() {
59
63
  );
60
64
  }
61
65
 
66
+ function NewRecordingTile({
67
+ spaceId,
68
+ folderId,
69
+ }: {
70
+ spaceId?: string | null;
71
+ folderId?: string | null;
72
+ }) {
73
+ const t = useT();
74
+ const recordHref = useMemo(() => {
75
+ const params = new URLSearchParams();
76
+ if (spaceId) params.set("spaceId", spaceId);
77
+ if (folderId) params.set("folderId", folderId);
78
+ const qs = params.toString();
79
+ return qs ? `/record?${qs}` : "/record";
80
+ }, [spaceId, folderId]);
81
+ const uploadHref = useMemo(() => {
82
+ const params = new URLSearchParams();
83
+ if (spaceId) params.set("spaceId", spaceId);
84
+ if (folderId) params.set("folderId", folderId);
85
+ params.set("autoUpload", "1");
86
+ return `/record?${params.toString()}`;
87
+ }, [spaceId, folderId]);
88
+ const importHref = useMemo(() => {
89
+ const params = new URLSearchParams();
90
+ if (spaceId) params.set("spaceId", spaceId);
91
+ if (folderId) params.set("folderId", folderId);
92
+ const qs = params.toString();
93
+ return qs ? `/import?${qs}` : "/import";
94
+ }, [spaceId, folderId]);
95
+
96
+ return (
97
+ <div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-muted/20 p-6 text-center transition-colors hover:border-primary/40 hover:bg-muted/40">
98
+ <Button className="w-full max-w-[180px] gap-1.5" size="sm" asChild>
99
+ <NavLink to={recordHref}>
100
+ <IconPlayerRecord className="h-4 w-4" />
101
+ {t("navigation.newRecording")}
102
+ </NavLink>
103
+ </Button>
104
+ <div className="flex items-center gap-3">
105
+ <NavLink
106
+ to={uploadHref}
107
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
108
+ >
109
+ <IconUpload className="h-3.5 w-3.5" />
110
+ {t("preRecord.uploadVideo")}
111
+ </NavLink>
112
+ <NavLink
113
+ to={importHref}
114
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
115
+ >
116
+ <IconLink className="h-3.5 w-3.5" />
117
+ {t("preRecord.importLoom")}
118
+ </NavLink>
119
+ </div>
120
+ </div>
121
+ );
122
+ }
123
+
62
124
  const PAGE_SIZE = 100;
63
125
 
64
126
  interface FolderTargetRow {
@@ -491,6 +553,9 @@ export function LibraryGrid({
491
553
  readOnly={!canManageRecordings}
492
554
  />
493
555
  ))}
556
+ {view === "library" && page === totalPages && (
557
+ <NewRecordingTile spaceId={spaceId} folderId={folderId} />
558
+ )}
494
559
  </div>
495
560
  )}
496
561
  </div>
@@ -32,6 +32,8 @@ import {
32
32
  IconShare,
33
33
  IconSettings,
34
34
  IconSearch,
35
+ IconUpload,
36
+ IconLink,
35
37
  } from "@tabler/icons-react";
36
38
  import { ReactNode, useEffect, useMemo, useState } from "react";
37
39
  import { NavLink, useLocation, useParams } from "react-router";
@@ -384,6 +386,37 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
384
386
  </Tooltip>
385
387
  </div>
386
388
 
389
+ <div className="flex flex-col items-center gap-1 px-2">
390
+ <Tooltip>
391
+ <TooltipTrigger asChild>
392
+ <NavLink
393
+ to="/record?autoUpload=1"
394
+ aria-label={t("preRecord.uploadVideo")}
395
+ className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/60 hover:text-foreground"
396
+ >
397
+ <IconUpload className="h-4 w-4" />
398
+ </NavLink>
399
+ </TooltipTrigger>
400
+ <TooltipContent side="right">
401
+ {t("preRecord.uploadVideo")}
402
+ </TooltipContent>
403
+ </Tooltip>
404
+ <Tooltip>
405
+ <TooltipTrigger asChild>
406
+ <NavLink
407
+ to="/import"
408
+ aria-label={t("preRecord.importLoom")}
409
+ className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/60 hover:text-foreground"
410
+ >
411
+ <IconLink className="h-4 w-4" />
412
+ </NavLink>
413
+ </TooltipTrigger>
414
+ <TooltipContent side="right">
415
+ {t("preRecord.importLoom")}
416
+ </TooltipContent>
417
+ </Tooltip>
418
+ </div>
419
+
387
420
  <nav className="mt-3 flex flex-col items-center gap-1 px-2">
388
421
  {navItems.map(({ to, label, icon: Icon, match }) => {
389
422
  const active = match(location.pathname);
@@ -417,6 +450,28 @@ export function LibraryLayout({ children }: LibraryLayoutProps) {
417
450
  {t("navigation.newRecording")}
418
451
  </NavLink>
419
452
  </Button>
453
+ <Button
454
+ className="mt-1.5 w-full gap-1.5 text-muted-foreground"
455
+ size="sm"
456
+ variant="ghost"
457
+ asChild
458
+ >
459
+ <NavLink to="/record?autoUpload=1">
460
+ <IconUpload className="h-4 w-4" />
461
+ {t("preRecord.uploadVideo")}
462
+ </NavLink>
463
+ </Button>
464
+ <Button
465
+ className="mt-1.5 w-full gap-1.5 text-muted-foreground"
466
+ size="sm"
467
+ variant="ghost"
468
+ asChild
469
+ >
470
+ <NavLink to="/import">
471
+ <IconLink className="h-4 w-4" />
472
+ {t("preRecord.importLoom")}
473
+ </NavLink>
474
+ </Button>
420
475
  </div>
421
476
 
422
477
  <nav className="mt-3 space-y-0.5 px-2">