@agent-native/core 0.196.0 → 0.196.1-nightly-20260928204657

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 (51) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +1 -1
  3. package/corpus/templates/clips/actions/create-recording.ts +1 -1
  4. package/corpus/templates/clips/actions/lib/auto-title-candidates.ts +42 -0
  5. package/corpus/templates/clips/actions/lib/create-recording-schema.ts +1 -1
  6. package/corpus/templates/clips/actions/lib/transcript-text.ts +16 -0
  7. package/corpus/templates/clips/actions/list-ai-requests.ts +49 -31
  8. package/corpus/templates/clips/actions/list-recordings.ts +2 -4
  9. package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +12 -6
  10. package/corpus/templates/clips/actions/regenerate-title.ts +4 -1
  11. package/corpus/templates/clips/actions/request-transcript.ts +1 -1
  12. package/corpus/templates/clips/actions/save-browser-transcript.ts +1 -1
  13. package/corpus/templates/clips/app/components/editable-recording-title.tsx +1 -1
  14. package/corpus/templates/clips/app/components/library/recording-card.tsx +1 -1
  15. package/corpus/templates/clips/app/hooks/use-auto-title.ts +194 -219
  16. package/corpus/templates/clips/app/routes/_app.r.$recordingId.tsx +2 -1
  17. package/corpus/templates/clips/app/routes/share.$shareId.tsx +1 -1
  18. package/corpus/templates/clips/changelog/2026-09-28-clip-share-previews-show-a-branded-image-while-the-recording.md +6 -0
  19. package/corpus/templates/clips/server/lib/slack-unfurls.ts +2 -0
  20. package/corpus/templates/clips/server/lib/transactional-email-store.ts +42 -2
  21. package/corpus/templates/clips/server/routes/api/thumbnail/[recordingId].get.ts +27 -15
  22. package/corpus/templates/clips/shared/ai-request-status.ts +6 -0
  23. package/corpus/templates/clips/shared/share-meta.ts +2 -27
  24. package/corpus/templates/clips/{actions/lib → shared}/title-source.ts +9 -2
  25. package/corpus/templates/slides/changelog/2026-09-28-typing-in-slides-agent-chat-keeps-every-character-in-order.md +6 -0
  26. package/corpus/templates/slides/scripts/edit-fidelity/README.md +11 -2
  27. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +135 -4
  28. package/dist/agent/production-agent.js +2 -0
  29. package/dist/agent/run-manager.d.ts +2 -0
  30. package/dist/agent/run-manager.js +9 -2
  31. package/dist/agent/thread-data-builder.d.ts +1 -0
  32. package/dist/agent/thread-data-builder.js +148 -0
  33. package/dist/chat-threads/store.d.ts +1 -0
  34. package/dist/chat-threads/store.js +244 -21
  35. package/dist/client/AgentKitAssistantChat.js +23 -12
  36. package/dist/client/chat/agentkit-agent-native.js +409 -8
  37. package/dist/client/chat/agentkit-protocol.js +3 -0
  38. package/dist/collab/awareness.d.ts +2 -2
  39. package/dist/collab/struct-routes.d.ts +1 -1
  40. package/dist/notifications/routes.d.ts +1 -1
  41. package/dist/observability/routes.d.ts +6 -6
  42. package/dist/org/handlers.d.ts +1 -1
  43. package/dist/resources/handlers.d.ts +1 -1
  44. package/dist/secrets/routes.d.ts +4 -4
  45. package/dist/server/agent-chat-plugin.d.ts +2 -0
  46. package/dist/server/agent-chat-plugin.js +12 -7
  47. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  48. package/dist/server/agent-engine-ollama-models-route.d.ts +2 -2
  49. package/dist/server/realtime-token.d.ts +1 -1
  50. package/dist/sharing/actions/list-resource-shares.d.ts +1 -1
  51. package/package.json +2 -2
@@ -8,46 +8,36 @@ import {
8
8
  bumpChangeVersion,
9
9
  callAction,
10
10
  getChangeVersion,
11
- useChangeVersions,
11
+ useActionQuery,
12
+ useChangeVersion,
12
13
  } from "@agent-native/core/client/hooks";
13
14
  import {
14
15
  aiRequestTabId,
15
16
  parseAiRequestTabId,
17
+ type AutoTitleCandidate,
16
18
  type ClipsAiRequestKind,
17
19
  } from "@shared/ai-request-status";
18
20
  import { fullVideoAiModelSelection } from "@shared/clips-ai-prefs";
19
21
  import { useEffect, useRef } from "react";
20
22
 
21
- import { useRecordings, type RecordingSummary } from "./use-library";
22
-
23
- const DEFAULT_TITLE = "Untitled recording";
24
23
  const TWO_MINUTES_MS = 2 * 60 * 1000;
25
24
  export const WORKFLOW_ACTION_MAX_ATTEMPTS = 5;
26
25
  const WORKFLOW_ACTION_RETRY_DELAY_MS = 1000;
27
- const AI_REQUEST_SOURCE_PREFIX = "app-state:clips-ai-request-";
26
+ // `queueAiRequest` publishes `refresh-signal` after every queue, whoever the
27
+ // writer is. Sync drops this tab's own events, so local queues bump it too.
28
+ const AI_REQUEST_REFRESH_SOURCE = "app-state:refresh-signal";
28
29
  const AI_REQUEST_DELIVERY_TIMEOUT_MS = 10_000;
29
30
 
30
- export function notifyAiRequestQueued(recordingId: string): void {
31
- if (!recordingId) return;
32
- const source = `${AI_REQUEST_SOURCE_PREFIX}${recordingId}`;
33
- bumpChangeVersion(source, Math.max(Date.now(), getChangeVersion(source) + 1));
34
- }
35
-
36
- export function isDefaultTitle(title: string | null | undefined): boolean {
37
- const trimmed = (title ?? "").trim();
38
- if (!trimmed) return true;
39
- return trimmed === DEFAULT_TITLE;
31
+ function bumpAiRequestRefresh(): void {
32
+ bumpChangeVersion(
33
+ AI_REQUEST_REFRESH_SOURCE,
34
+ Math.max(Date.now(), getChangeVersion(AI_REQUEST_REFRESH_SOURCE) + 1),
35
+ );
40
36
  }
41
37
 
42
- export function isAutoTitleReplaceable(
43
- title: string | null | undefined,
44
- titleSource: string | null | undefined,
45
- ): boolean {
46
- return (
47
- isDefaultTitle(title) ||
48
- titleSource === "default" ||
49
- titleSource === "context"
50
- );
38
+ export function notifyAiRequestQueued(recordingId: string): void {
39
+ if (!recordingId) return;
40
+ bumpAiRequestRefresh();
51
41
  }
52
42
 
53
43
  interface AiRequest {
@@ -70,6 +60,13 @@ interface AiRequest {
70
60
  deliveredTabId?: string;
71
61
  }
72
62
 
63
+ type QueuedAiRequest = AiRequest & { recordingId: string };
64
+
65
+ interface ListAiRequestsResult {
66
+ requests: QueuedAiRequest[];
67
+ titleCandidates: AutoTitleCandidate[];
68
+ }
69
+
73
70
  const DISPATCHABLE_REQUESTS = new Set([
74
71
  "generate-metadata",
75
72
  "regenerate-title",
@@ -80,23 +77,6 @@ const DISPATCHABLE_REQUESTS = new Set([
80
77
  "generate-workflow",
81
78
  ]);
82
79
 
83
- async function listRequests(): Promise<Map<string, AiRequest>> {
84
- try {
85
- const result = (await callAction("list-ai-requests", {} as any, {
86
- method: "GET",
87
- })) as { requests?: AiRequest[] } | null | undefined;
88
- return new Map(
89
- (result?.requests ?? [])
90
- .filter(
91
- (r): r is AiRequest & { recordingId: string } => !!r?.recordingId,
92
- )
93
- .map((r) => [r.recordingId, r]),
94
- );
95
- } catch {
96
- return new Map();
97
- }
98
- }
99
-
100
80
  async function clearRequest(recordingId: string): Promise<void> {
101
81
  const url = agentNativePath(
102
82
  `/_agent-native/application-state/${encodeURIComponent(
@@ -104,11 +84,15 @@ async function clearRequest(recordingId: string): Promise<void> {
104
84
  )}`,
105
85
  );
106
86
  await fetch(url, { method: "DELETE" }).catch(() => {});
87
+ bumpAiRequestRefresh();
107
88
  }
108
89
 
109
90
  export function useAutoTitleBridge(): void {
110
- const { data } = useRecordings({ view: "all", limit: 200 });
111
- const recordings: RecordingSummary[] = data?.recordings ?? [];
91
+ const { data, refetch } = useActionQuery<ListAiRequestsResult>(
92
+ "list-ai-requests",
93
+ {} as any,
94
+ );
95
+ const refreshVersion = useChangeVersion(AI_REQUEST_REFRESH_SOURCE);
112
96
  const dispatched = useRef<Set<string>>(new Set());
113
97
  const inflight = useRef<boolean>(false);
114
98
 
@@ -157,163 +141,158 @@ export function useAutoTitleBridge(): void {
157
141
  window.removeEventListener("agentNative.chatRunning", handleChatRunning);
158
142
  }, []);
159
143
 
160
- const readyRecordings = recordings.filter((r) => r.status === "ready");
161
- const readyRecordingsKey = readyRecordings
162
- .map(
163
- (r) =>
164
- `${r.id}:${r.titleSource ?? ""}:${r.title}:${r.updatedAt}:${r.transcriptStatus ?? ""}:${r.transcriptHasText ? "1" : "0"}`,
165
- )
166
- .join("|");
167
- const aiRequestVersion = useChangeVersions(
168
- readyRecordings.map(
169
- (recording) => `app-state:clips-ai-request-${recording.id}`,
170
- ),
171
- );
144
+ useEffect(() => {
145
+ if (refreshVersion) void refetch();
146
+ }, [refreshVersion, refetch]);
172
147
 
173
148
  useEffect(() => {
174
- if (readyRecordings.length === 0) return;
149
+ if (!data) return;
175
150
  let cancelled = false;
176
- let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
151
+ let timer: ReturnType<typeof setTimeout> | null = null;
152
+
153
+ function retrySoon() {
154
+ timer = setTimeout(() => {
155
+ void refetch().then((result) => {
156
+ if (!cancelled && result.data) void deliver(result.data);
157
+ });
158
+ }, 1000);
159
+ }
177
160
 
178
- async function tick() {
161
+ async function deliver(snapshot: ListAiRequestsResult) {
179
162
  if (cancelled) return;
180
163
  if (inflight.current) {
181
- fallbackTimer = setTimeout(() => void tick(), 50);
164
+ timer = setTimeout(() => void deliver(snapshot), 50);
182
165
  return;
183
166
  }
184
167
  inflight.current = true;
185
168
  try {
186
- const requestsById = await listRequests();
187
- if (cancelled) return;
188
-
189
- for (const rec of readyRecordings) {
169
+ const pending = new Set<string>();
170
+ for (const request of snapshot.requests) {
190
171
  if (cancelled) return;
172
+ if (!request.kind || !DISPATCHABLE_REQUESTS.has(request.kind)) {
173
+ continue;
174
+ }
175
+ pending.add(request.recordingId);
176
+ const dispatchKey = `${request.recordingId}:${request.kind}:${
177
+ request.requestId ?? request.requestedAt ?? "0"
178
+ }`;
179
+ if (dispatched.current.has(dispatchKey)) continue;
180
+ if (
181
+ request.kind === "generate-metadata" ||
182
+ request.kind === "regenerate-title"
183
+ ) {
184
+ dispatched.current.add(fallbackKey(request.recordingId));
185
+ }
191
186
 
192
- const request = requestsById.get(rec.id) ?? null;
193
-
194
- if (request?.kind && DISPATCHABLE_REQUESTS.has(request.kind)) {
195
- const dispatchKey = `${rec.id}:${request.kind}:${
196
- request.requestId ?? request.requestedAt ?? "0"
197
- }`;
198
- if (dispatched.current.has(dispatchKey)) continue;
199
- if (
200
- request.kind === "generate-metadata" ||
201
- request.kind === "regenerate-title"
202
- ) {
203
- dispatched.current.add(`${rec.id}:fallback`);
204
- }
205
-
206
- if (
207
- request.kind === "generate-workflow" &&
208
- typeof request.requestedAt === "string"
209
- ) {
210
- const workflowRequest = {
211
- recordingId: rec.id,
212
- requestedAt: request.requestedAt,
213
- ...(request.requestId ? { requestId: request.requestId } : {}),
214
- };
215
- if (request.deliveredTabId) {
216
- dispatched.current.add(dispatchKey);
217
- void consumeWorkflowRequest({
218
- ...workflowRequest,
219
- tabId: request.deliveredTabId,
220
- });
221
- continue;
222
- }
223
-
224
- const tabId = workflowTabId(
225
- rec.id,
226
- request.requestedAt,
227
- request.requestId,
228
- );
229
- try {
230
- const result = (await callAction(
231
- "reconcile-workflow-generation" as any,
232
- {
233
- operation: "track",
234
- ...workflowRequest,
235
- tabId,
236
- } as any,
237
- )) as { tracked?: boolean };
238
- if (result.tracked !== true) {
239
- fallbackTimer = setTimeout(() => void tick(), 1000);
240
- continue;
241
- }
242
- } catch {
243
- fallbackTimer = setTimeout(() => void tick(), 1000);
244
- continue;
245
- }
246
- const delivery = await sendToAgentChatAndConfirm({
247
- ...buildAiRequestChatOptions(rec, request),
248
- tabId,
249
- chatTarget: "local",
250
- });
251
- if (!delivery.delivered) {
252
- await retryWorkflowAction(
253
- {
254
- operation: "release",
255
- ...workflowRequest,
256
- tabId,
257
- },
258
- "released",
259
- );
260
- fallbackTimer = setTimeout(() => void tick(), 1000);
261
- continue;
262
- }
187
+ if (
188
+ request.kind === "generate-workflow" &&
189
+ typeof request.requestedAt === "string"
190
+ ) {
191
+ const workflowRequest = {
192
+ recordingId: request.recordingId,
193
+ requestedAt: request.requestedAt,
194
+ ...(request.requestId ? { requestId: request.requestId } : {}),
195
+ };
196
+ if (request.deliveredTabId) {
263
197
  dispatched.current.add(dispatchKey);
264
- void persistAndConsumeWorkflowRequest({
198
+ void consumeWorkflowRequest({
265
199
  ...workflowRequest,
266
- tabId,
200
+ tabId: request.deliveredTabId,
267
201
  });
268
202
  continue;
269
203
  }
270
- if (
271
- typeof request.requestedAt !== "string" ||
272
- !request.requestedAt.trim()
273
- ) {
274
- console.warn("[clips] queued AI request is missing requestedAt", {
275
- recordingId: rec.id,
276
- kind: request.kind,
277
- });
278
- fallbackTimer = setTimeout(() => void tick(), 1000);
204
+
205
+ const tabId = workflowTabId(
206
+ request.recordingId,
207
+ request.requestedAt,
208
+ request.requestId,
209
+ );
210
+ try {
211
+ const result = (await callAction(
212
+ "reconcile-workflow-generation" as any,
213
+ {
214
+ operation: "track",
215
+ ...workflowRequest,
216
+ tabId,
217
+ } as any,
218
+ )) as { tracked?: boolean };
219
+ if (result.tracked !== true) {
220
+ retrySoon();
221
+ continue;
222
+ }
223
+ } catch {
224
+ retrySoon();
279
225
  continue;
280
226
  }
281
- const delivery = await dispatchAiRequest(
282
- rec,
283
- request,
284
- aiRequestTabId(
285
- rec.id,
286
- request.kind as ClipsAiRequestKind,
287
- request.requestedAt,
288
- ),
289
- );
227
+ const delivery = await sendToAgentChatAndConfirm({
228
+ ...buildAiRequestChatOptions(request),
229
+ tabId,
230
+ chatTarget: "local",
231
+ });
290
232
  if (!delivery.delivered) {
291
- dispatched.current.delete(dispatchKey);
292
- fallbackTimer = setTimeout(() => void tick(), 1000);
233
+ await retryWorkflowAction(
234
+ {
235
+ operation: "release",
236
+ ...workflowRequest,
237
+ tabId,
238
+ },
239
+ "released",
240
+ );
241
+ retrySoon();
293
242
  continue;
294
243
  }
295
244
  dispatched.current.add(dispatchKey);
296
- void clearRequest(rec.id);
297
- } else if (isAutoTitleReplaceable(rec.title, rec.titleSource)) {
298
- if (
299
- rec.transcriptStatus !== "ready" ||
300
- rec.transcriptHasText !== true
301
- ) {
302
- continue;
303
- }
304
-
305
- if (Date.now() - new Date(rec.createdAt).getTime() < TWO_MINUTES_MS)
306
- continue;
307
-
308
- const fallbackKey = `${rec.id}:fallback`;
309
- if (dispatched.current.has(fallbackKey)) continue;
310
- dispatched.current.add(fallbackKey);
245
+ void persistAndConsumeWorkflowRequest({
246
+ ...workflowRequest,
247
+ tabId,
248
+ });
249
+ continue;
250
+ }
251
+ if (
252
+ typeof request.requestedAt !== "string" ||
253
+ !request.requestedAt.trim()
254
+ ) {
255
+ console.warn("[clips] queued AI request is missing requestedAt", {
256
+ recordingId: request.recordingId,
257
+ kind: request.kind,
258
+ });
259
+ retrySoon();
260
+ continue;
261
+ }
262
+ const delivery = await dispatchAiRequest(
263
+ request,
264
+ aiRequestTabId(
265
+ request.recordingId,
266
+ request.kind as ClipsAiRequestKind,
267
+ request.requestedAt,
268
+ ),
269
+ );
270
+ if (!delivery.delivered) {
271
+ dispatched.current.delete(dispatchKey);
272
+ retrySoon();
273
+ continue;
274
+ }
275
+ dispatched.current.add(dispatchKey);
276
+ void clearRequest(request.recordingId);
277
+ }
311
278
 
312
- callAction(
313
- "regenerate-title" as any,
314
- { recordingId: rec.id } as any,
315
- ).catch(() => {});
279
+ for (const candidate of snapshot.titleCandidates) {
280
+ if (cancelled) return;
281
+ if (pending.has(candidate.id)) continue;
282
+ if (autoTitleFallbackDelay(candidate, dispatched.current) !== 0) {
283
+ continue;
316
284
  }
285
+ dispatched.current.add(fallbackKey(candidate.id));
286
+ callAction(
287
+ "regenerate-title" as any,
288
+ { recordingId: candidate.id } as any,
289
+ )
290
+ .then((result) => {
291
+ if ((result as { queued?: boolean } | null)?.queued === true) {
292
+ bumpAiRequestRefresh();
293
+ }
294
+ })
295
+ .catch(() => {});
317
296
  }
318
297
  } finally {
319
298
  inflight.current = false;
@@ -321,66 +300,67 @@ export function useAutoTitleBridge(): void {
321
300
  }
322
301
 
323
302
  function scheduleNextFallback() {
324
- if (cancelled) return;
303
+ if (cancelled || !data) return;
325
304
  const delay = nextAutoTitleFallbackDelay(
326
- readyRecordings,
305
+ data.titleCandidates,
327
306
  dispatched.current,
328
307
  );
329
308
  if (delay === null) return;
330
- fallbackTimer = setTimeout(
309
+ timer = setTimeout(
331
310
  () => {
332
- fallbackTimer = null;
333
- void tick().finally(scheduleNextFallback);
311
+ timer = null;
312
+ void deliver(data).finally(scheduleNextFallback);
334
313
  },
335
314
  Math.max(delay, 50),
336
315
  );
337
316
  }
338
317
 
339
- void tick().finally(scheduleNextFallback);
318
+ void deliver(data).finally(scheduleNextFallback);
340
319
  return () => {
341
320
  cancelled = true;
342
- if (fallbackTimer) clearTimeout(fallbackTimer);
321
+ if (timer) clearTimeout(timer);
343
322
  };
344
- // readyRecordingsKey is the stable snapshot consumed by tick; depending on
345
- // the array itself would restart the effect on every query result object.
346
- // eslint-disable-next-line react-hooks/exhaustive-deps
347
- }, [aiRequestVersion, readyRecordingsKey]);
323
+ }, [data, refetch]);
324
+ }
325
+
326
+ function fallbackKey(recordingId: string): string {
327
+ return `${recordingId}:fallback`;
328
+ }
329
+
330
+ /**
331
+ * Milliseconds until the auto-title fallback may run for this candidate, or
332
+ * `null` once it has been dispatched. Eligibility is decided by the server.
333
+ */
334
+ function autoTitleFallbackDelay(
335
+ candidate: AutoTitleCandidate,
336
+ dispatched: ReadonlySet<string>,
337
+ now = Date.now(),
338
+ ): number | null {
339
+ if (dispatched.has(fallbackKey(candidate.id))) return null;
340
+ const createdAt = new Date(candidate.createdAt).getTime();
341
+ return Number.isFinite(createdAt)
342
+ ? Math.max(0, TWO_MINUTES_MS - (now - createdAt))
343
+ : 0;
348
344
  }
349
345
 
350
346
  export function nextAutoTitleFallbackDelay(
351
- recordings: readonly RecordingSummary[],
347
+ candidates: readonly AutoTitleCandidate[],
352
348
  dispatched: ReadonlySet<string>,
353
349
  now = Date.now(),
354
350
  ): number | null {
355
351
  let nextDelay: number | null = null;
356
-
357
- for (const recording of recordings) {
358
- if (recording.status !== "ready") continue;
359
- if (!isAutoTitleReplaceable(recording.title, recording.titleSource)) {
360
- continue;
361
- }
362
- if (
363
- recording.transcriptStatus !== "ready" ||
364
- recording.transcriptHasText !== true ||
365
- dispatched.has(`${recording.id}:fallback`)
366
- ) {
367
- continue;
368
- }
369
-
370
- const createdAt = new Date(recording.createdAt).getTime();
371
- const delay = Number.isFinite(createdAt)
372
- ? Math.max(0, TWO_MINUTES_MS - (now - createdAt))
373
- : 0;
352
+ for (const candidate of candidates) {
353
+ const delay = autoTitleFallbackDelay(candidate, dispatched, now);
354
+ if (delay === null) continue;
374
355
  nextDelay = nextDelay === null ? delay : Math.min(nextDelay, delay);
375
356
  }
376
-
377
357
  return nextDelay;
378
358
  }
379
359
 
380
- function buildRequestContext(rec: RecordingSummary, request: AiRequest) {
360
+ function buildRequestContext(request: QueuedAiRequest) {
381
361
  return {
382
- recordingId: rec.id,
383
- currentTitle: request.currentTitle ?? rec.title,
362
+ recordingId: request.recordingId,
363
+ currentTitle: request.currentTitle ?? "",
384
364
  currentDescription: request.currentDescription ?? "",
385
365
  transcript: request.transcriptText ?? "",
386
366
  agentsContext: request.agentsContext ?? "",
@@ -393,8 +373,7 @@ function buildRequestContext(rec: RecordingSummary, request: AiRequest) {
393
373
  }
394
374
 
395
375
  export function buildAiRequestChatOptions(
396
- rec: RecordingSummary,
397
- request: AiRequest,
376
+ request: QueuedAiRequest,
398
377
  ): AgentChatMessage {
399
378
  const includeFullVideo = request.includeFullVideoInAi === true;
400
379
  const gemini = includeFullVideo ? fullVideoAiModelSelection() : null;
@@ -402,8 +381,8 @@ export function buildAiRequestChatOptions(
402
381
  return {
403
382
  message:
404
383
  request.message ??
405
- `Handle queued ${request.kind} work for recording ${rec.id}.`,
406
- context: JSON.stringify(buildRequestContext(rec, request)),
384
+ `Handle queued ${request.kind} work for recording ${request.recordingId}.`,
385
+ context: JSON.stringify(buildRequestContext(request)),
407
386
  submit: true,
408
387
  openSidebar: openInChat ? true : false,
409
388
  newTab: true,
@@ -491,14 +470,10 @@ function requestIdFromTab(tabId: string) {
491
470
  return match ? decodeURIComponent(match[1]) : undefined;
492
471
  }
493
472
 
494
- function dispatchAiRequest(
495
- rec: RecordingSummary,
496
- request: AiRequest,
497
- tabId: string,
498
- ) {
473
+ function dispatchAiRequest(request: QueuedAiRequest, tabId: string) {
499
474
  return sendToAgentChatAndConfirm(
500
475
  {
501
- ...buildAiRequestChatOptions(rec, request),
476
+ ...buildAiRequestChatOptions(request),
502
477
  chatTarget: "local",
503
478
  tabId,
504
479
  },
@@ -43,6 +43,7 @@ import {
43
43
  buildShareContinuationQuery,
44
44
  CLIP_SHARE_REF,
45
45
  } from "@shared/share-attribution";
46
+ import { isDefaultTitle } from "@shared/title-source";
46
47
  import type { WorkflowKind } from "@shared/workflow";
47
48
  import {
48
49
  IconCalendar,
@@ -141,7 +142,7 @@ import {
141
142
  TooltipContent,
142
143
  TooltipTrigger,
143
144
  } from "@/components/ui/tooltip";
144
- import { isDefaultTitle, notifyAiRequestQueued } from "@/hooks/use-auto-title";
145
+ import { notifyAiRequestQueued } from "@/hooks/use-auto-title";
145
146
  import { useCompletionAudioCue } from "@/hooks/use-completion-audio-cue";
146
147
  import { useFolders, useSpaces } from "@/hooks/use-library";
147
148
  import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts";
@@ -18,6 +18,7 @@ import {
18
18
  EnvironmentBadge,
19
19
  } from "@agent-native/core/client/ui";
20
20
  import { usePersistentSidebarCollapsed } from "@agent-native/toolkit/app-shell";
21
+ import { isDefaultTitle } from "@shared/title-source";
21
22
  import {
22
23
  IconAlertTriangle,
23
24
  IconDeviceDesktop,
@@ -108,7 +109,6 @@ import {
108
109
  TooltipContent,
109
110
  TooltipTrigger,
110
111
  } from "@/components/ui/tooltip";
111
- import { isDefaultTitle } from "@/hooks/use-auto-title";
112
112
  import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts";
113
113
  import { useSonnerLifecycleToast } from "@/hooks/use-sonner-lifecycle-toast";
114
114
  import { useViewTracking } from "@/hooks/use-view-tracking";
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-09-28
4
+ ---
5
+
6
+ Clip share previews show a branded image while the recording thumbnail is unavailable.
@@ -40,6 +40,7 @@ type SlackUnfurlRecording = {
40
40
  expiresAt: string | null;
41
41
  videoUrl: string | null;
42
42
  sourceAppName: string | null;
43
+ updatedAt: string | null;
43
44
  };
44
45
 
45
46
  export type SlackVideoBlock = {
@@ -289,6 +290,7 @@ export async function loadSlackVideoBlockForUrl(
289
290
  expiresAt: schema.recordings.expiresAt,
290
291
  videoUrl: schema.recordings.videoUrl,
291
292
  sourceAppName: schema.recordings.sourceAppName,
293
+ updatedAt: schema.recordings.updatedAt,
292
294
  })
293
295
  .from(schema.recordings)
294
296
  .where(eq(schema.recordings.id, share.id))
@@ -1,6 +1,16 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { and, asc, eq, inArray, isNull, lte, or, type SQL } from "drizzle-orm";
3
+ import {
4
+ and,
5
+ asc,
6
+ eq,
7
+ inArray,
8
+ isNull,
9
+ lte,
10
+ or,
11
+ sql,
12
+ type SQL,
13
+ } from "drizzle-orm";
4
14
  import { z } from "zod";
5
15
 
6
16
  import { getDb, schema } from "../db/index.js";
@@ -195,8 +205,12 @@ export type TransactionalEmailConfig = z.infer<
195
205
  export type TransactionalEmailJob = z.infer<typeof transactionalEmailJobSchema>;
196
206
  export type RecapCopy = z.infer<typeof recapCopySchema>;
197
207
 
208
+ const AI_BACKED_TYPES = [
209
+ "two-clips",
210
+ ] as const satisfies readonly TransactionalEmailJob["type"][];
211
+
198
212
  export function isAiBackedType(type: TransactionalEmailJob["type"]): boolean {
199
- return type === "two-clips";
213
+ return (AI_BACKED_TYPES as readonly string[]).includes(type);
200
214
  }
201
215
 
202
216
  export type TransactionalEmailStoreOptions = {
@@ -354,6 +368,31 @@ export function createTransactionalEmailStore(
354
368
  ).map(databaseJobToJob);
355
369
  }
356
370
 
371
+ async function listAiClaimCandidates(
372
+ claimantEmail: string,
373
+ ): Promise<TransactionalEmailJob[]> {
374
+ const claimant = claimantEmail.trim().toLowerCase();
375
+ return (
376
+ await getDb()
377
+ .select()
378
+ .from(schema.transactionalEmailJobs)
379
+ .where(
380
+ and(
381
+ inArray(schema.transactionalEmailJobs.state, [
382
+ "awaiting_ai",
383
+ "ai_dispatched",
384
+ ]),
385
+ inArray(schema.transactionalEmailJobs.type, AI_BACKED_TYPES),
386
+ or(
387
+ sql`lower(trim(${schema.transactionalEmailJobs.recipient})) = ${claimant}`,
388
+ sql`lower(trim(${schema.transactionalEmailJobs.requestedBy})) = ${claimant}`,
389
+ ),
390
+ ),
391
+ )
392
+ .orderBy(asc(schema.transactionalEmailJobs.createdAt))
393
+ ).map(databaseJobToJob);
394
+ }
395
+
357
396
  async function replaceJob(
358
397
  job: TransactionalEmailJob,
359
398
  condition: SQL | undefined,
@@ -768,6 +807,7 @@ export function createTransactionalEmailStore(
768
807
  enqueueOrConvergeFirstImport,
769
808
  readJob,
770
809
  listJobs,
810
+ listAiClaimCandidates,
771
811
  transition,
772
812
  claimAwaitingAi,
773
813
  reclaimStaleAiDispatch,