@agent-native/core 0.175.4 → 0.176.0-nightly-20260826210104

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 (81) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/ai-video-tools/SKILL.md +8 -7
  3. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +9 -8
  4. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -1
  5. package/corpus/templates/clips/actions/lib/loom-import-job.ts +52 -35
  6. package/corpus/templates/clips/actions/request-transcript.ts +6 -183
  7. package/corpus/templates/clips/app/components/clips-avatar.tsx +46 -0
  8. package/corpus/templates/clips/app/components/library/recording-card.tsx +8 -7
  9. package/corpus/templates/clips/app/components/library/space-card.tsx +8 -5
  10. package/corpus/templates/clips/app/components/meetings/attendee-stack.tsx +7 -8
  11. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +19 -12
  12. package/corpus/templates/clips/app/components/player/transcript-panel.tsx +128 -94
  13. package/corpus/templates/clips/app/components/workspace/members-list.tsx +8 -6
  14. package/corpus/templates/clips/app/components/workspace/notifications-list.tsx +8 -6
  15. package/corpus/templates/clips/app/components/workspace/top-creators-table.tsx +8 -6
  16. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +8 -7
  17. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +0 -26
  18. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -4
  19. package/corpus/templates/clips/changelog/2026-08-26-detected-calls-no-longer-duplicate-meetings-or-strand-the-prompt.md +6 -0
  20. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +39 -22
  21. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +1765 -219
  22. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
  23. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +45 -30
  24. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +91 -0
  25. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +16 -0
  26. package/corpus/templates/clips/shared/clips-ai-prefs.ts +0 -1
  27. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +44 -22
  28. package/corpus/templates/content/app/components/ui/avatar.tsx +1 -0
  29. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  30. package/corpus/templates/mail/actions/get-email.ts +68 -21
  31. package/corpus/templates/mail/actions/get-thread.ts +92 -51
  32. package/corpus/templates/mail/app/hooks/use-emails.ts +7 -4
  33. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +7 -4
  34. package/corpus/templates/plan/app/pages/PlansPage.tsx +23 -15
  35. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +18 -7
  36. package/corpus/templates/slides/app/components/ui/avatar.tsx +1 -0
  37. package/corpus/templates/tasks/DEVELOPING.md +8 -0
  38. package/corpus/templates/tasks/drizzle.config.ts +3 -1
  39. package/corpus/templates/tasks/drizzle.local.config.ts +6 -0
  40. package/corpus/templates/tasks/package.json +2 -1
  41. package/corpus/templates/tasks/server/db/migrations/meta/0000_snapshot.json +234 -0
  42. package/corpus/templates/tasks/server/db/migrations/meta/_journal.json +13 -0
  43. package/corpus/templates/tasks/server/db/migrations.ts +110 -0
  44. package/corpus/templates/tasks/server/plugins/db.ts +3 -87
  45. package/dist/client/review/ReviewThreadPanel.js +4 -2
  46. package/dist/client/use-action.d.ts +2 -0
  47. package/dist/client/use-action.js +4 -2
  48. package/dist/collab/client.js +20 -5
  49. package/dist/collab/presence.d.ts +3 -1
  50. package/dist/collab/presence.js +48 -45
  51. package/dist/db/drizzle-config.d.ts +7 -0
  52. package/dist/db/drizzle-config.js +35 -9
  53. package/dist/db/drizzle-migrations.d.ts +28 -0
  54. package/dist/db/drizzle-migrations.js +81 -0
  55. package/dist/db/index.d.ts +1 -1
  56. package/dist/db/migrations.d.ts +13 -1
  57. package/dist/db/migrations.js +60 -31
  58. package/dist/db/schema.js +4 -1
  59. package/dist/deploy/build.d.ts +6 -0
  60. package/dist/deploy/build.js +41 -0
  61. package/dist/observability/routes.d.ts +3 -3
  62. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  63. package/dist/provider-api/actions/provider-api.d.ts +9 -9
  64. package/dist/secrets/routes.d.ts +9 -9
  65. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  66. package/dist/server/auth.js +3 -2
  67. package/dist/server/builder-browser.d.ts +0 -4
  68. package/dist/server/builder-browser.js +23 -8
  69. package/dist/server/google-oauth.d.ts +18 -0
  70. package/dist/server/google-oauth.js +3 -3
  71. package/dist/server/short-lived-token.d.ts +1 -1
  72. package/dist/server/short-lived-token.js +1 -1
  73. package/dist/shared/google-profile-image.d.ts +2 -0
  74. package/dist/shared/google-profile-image.js +24 -0
  75. package/dist/shared/index.d.ts +1 -0
  76. package/dist/shared/index.js +1 -0
  77. package/docs/content/creating-templates.mdx +10 -1
  78. package/docs/content/server-database.mdx +46 -2
  79. package/docs/content/server-plugins.mdx +39 -3
  80. package/docs/content/webmcp.mdx +2 -0
  81. package/package.json +3 -2
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8623
34
+ - template files: 8631
@@ -141,18 +141,19 @@ removing overlapping duplicate speech and low-speech Whisper hallucinations. Kee
141
141
  system audio enabled when the meeting audio comes from another app.
142
142
 
143
143
  Native transcript first is a hard rule, not a preference: cloud transcription is
144
- fallback-only for Clips recordings. Cleanup and transcript-backed title/summary
145
- generation run in the durable post-finalize path, so never hide a usable native
146
- transcript behind failed metadata work, and keep heuristic titles replaceable
147
- until the agent refinement lands.
144
+ fallback-only for Clips recordings. Recording transcripts keep the native text as
145
+ captured; `request-transcript` does not run automatic cleanup. Never hide a
146
+ usable native transcript behind failed metadata work, and keep heuristic titles
147
+ replaceable until the agent refinement lands. The standalone `cleanup-transcript`
148
+ action remains available for explicit dictation, meeting, and agent workflows.
148
149
 
149
150
  Clips never routes recording/meeting audio to OpenAI for transcription. (Groq's endpoint is OpenAI-_compatible_ in request shape only — the audio goes to Groq, not OpenAI.)
150
151
 
151
152
  If no native transcript exists and no cloud fallback is available (no Builder connection and no Groq key), the action writes `status="failed"` so the UI can show a friendly prompt.
152
153
 
153
- When a transcript becomes ready, `request-transcript` must await native cleanup
154
- and the metadata handoff before its durable post-finalize worker returns.
155
- Do not leave title/summary work as an unawaited promise in that worker:
154
+ When a transcript becomes ready, `request-transcript` must await the metadata
155
+ handoff before its durable post-finalize worker returns. Do not leave
156
+ title/summary work as an unawaited promise in that worker:
156
157
  serverless runtimes may freeze it immediately. A local heuristic title uses
157
158
  `titleSource: "context"` so it remains a temporary UI fallback until the
158
159
  `generate-metadata` agent request replaces it with a transcript-backed title.
@@ -15,7 +15,7 @@ Reach for this skill any time you touch the recorder: the record button, the in-
15
15
 
16
16
  ## Data model touched
17
17
 
18
- - **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and create a `ready` row whose `videoUrl` is a Loom embed URL.
18
+ - **`recordings`** — the row gets created as soon as the user presses Record or imports a source. Native/file recordings transition `uploading` → `processing` → `ready` (or `failed`). `videoUrl`, `durationMs`, `videoSizeBytes`, `width`, `height`, `hasAudio`, `hasCamera` are populated as the upload streams in. Loom imports use `import-loom-recording` and become ready with either a Clips-hosted MP4 or a Loom embed when MP4 export is unavailable.
19
19
  - **`application_state.record-intent`** — the agent writes this when it wants to start a recording. The UI reads and clears it, then prompts for permission.
20
20
  - **`application_state.navigation`** — set to `{ view: "record" }` while the recorder is active.
21
21
 
@@ -122,13 +122,14 @@ segments; never store Loom's signed CDN URLs.
122
122
  When Loom exposes a downloadable public MP4, `import-loom-recording` downloads
123
123
  it, reuploads the bytes to Clips storage, and creates a ready, playable
124
124
  Clips-hosted recording, importing Loom's public transcript when the share page
125
- exposes one. If Loom does not expose a downloadable MP4, ask the user to download
126
- the original from Loom and use "Upload video".
127
-
128
- Loom imports are embed-backed, not Clips-owned video files. The player renders a
129
- Loom iframe and the native Clips editor is hidden for those recordings. If the
130
- user needs Clips-native trimming, exports, frame extraction, or upload-based
131
- transcription, ask them to upload the original video file instead.
125
+ exposes one. If Loom allows public playback but does not expose an MP4, the
126
+ action keeps a ready, embed-backed recording and still imports the transcript
127
+ when available.
128
+
129
+ Embed-backed Loom imports render a Loom iframe and hide the native Clips editor.
130
+ Reuploaded Loom imports support Clips-native trimming, exports, frame extraction,
131
+ and upload-based transcription; ask the user to upload the original video file
132
+ when those capabilities are needed for an embed-backed import.
132
133
 
133
134
  ## Browser diagnostics and recorder install options
134
135
 
@@ -145,7 +145,7 @@ async function fetchLoomOembed(shareUrl: string) {
145
145
 
146
146
  export default defineAction({
147
147
  description:
148
- "Import a public Loom share URL, or a direct link to a video file, into Clips as a playable recording. Loom links create the recording immediately and download/reupload Loom's public MP4 plus import Loom's public transcript in the background, since Loom's CDN plus a reupload can take longer than a single request should block on. Other direct video links (e.g. an MP4/WebM/MOV hosted by another screen recorder) are downloaded and reuploaded synchronously without transcript metadata — use request-transcript afterward. If storage is not connected, creates a waiting recording that can be retried after storage setup.",
148
+ "Import a public Loom share URL, or a direct link to a video file, into Clips as a playable recording. Loom links create the recording immediately and download/reupload Loom's public MP4 when available, or keep a playable Loom embed when Loom allows playback but not MP4 export; public transcripts are imported in the background. Other direct video links (e.g. an MP4/WebM/MOV hosted by another screen recorder) are downloaded and reuploaded synchronously without transcript metadata — use request-transcript afterward. If storage is not connected, creates a waiting recording that can be retried after storage setup.",
149
149
  schema: ImportLoomRecordingSchema,
150
150
  run: async (args, actionContext) => {
151
151
  const loomId = extractLoomVideoId(args.url);
@@ -8,13 +8,14 @@ import { ownerEmailMatches } from "../../server/lib/recordings.js";
8
8
  import { transactionalEmailStore } from "../../server/lib/transactional-email-store.js";
9
9
  import {
10
10
  extractLoomVideoId,
11
+ loomEmbedUrlForId,
11
12
  normalizeLoomShareUrl,
12
13
  } from "../../shared/loom.js";
13
14
  import {
14
15
  fetchLoomTranscript,
15
16
  loomTranscriptUnavailableMessage,
16
17
  } from "./loom-transcript.js";
17
- import { downloadLoomVideo } from "./loom-video.js";
18
+ import { downloadLoomVideo, LoomVideoUnavailableError } from "./loom-video.js";
18
19
 
19
20
  export type LoomImportJobResult = {
20
21
  status: "ready" | "failed";
@@ -147,7 +148,7 @@ export async function runLoomImportJob({
147
148
 
148
149
  console.log("[loom-import] job started", { recordingId, claimId });
149
150
 
150
- let media: Awaited<ReturnType<typeof downloadLoomVideo>>;
151
+ let media: Awaited<ReturnType<typeof downloadLoomVideo>> | null = null;
151
152
  try {
152
153
  media = await downloadLoomVideo({ loomId, shareUrl });
153
154
  console.log("[loom-import] download complete", {
@@ -156,40 +157,54 @@ export async function runLoomImportJob({
156
157
  mimeType: media.mimeType,
157
158
  });
158
159
  } catch (err) {
159
- return failLoomImport(
160
- recordingId,
161
- err instanceof Error ? err.message : String(err),
162
- claimId,
163
- );
160
+ // Loom's public player can work even when the viewer's role cannot export MP4.
161
+ if (err instanceof LoomVideoUnavailableError) {
162
+ console.warn("[loom-import] MP4 unavailable; keeping Loom embed", {
163
+ recordingId,
164
+ loomId,
165
+ });
166
+ } else {
167
+ return failLoomImport(
168
+ recordingId,
169
+ err instanceof Error ? err.message : String(err),
170
+ claimId,
171
+ );
172
+ }
164
173
  }
165
174
 
166
175
  try {
167
- const upload = await uploadFile({
168
- data: media.bytes,
169
- filename: `${recordingId}.mp4`,
170
- mimeType: media.mimeType,
171
- ownerEmail,
172
- stableUrl: true,
173
- recordAsset: false,
174
- });
175
- if (!upload?.url) {
176
+ const upload = media
177
+ ? await uploadFile({
178
+ data: media.bytes,
179
+ filename: `${recordingId}.mp4`,
180
+ mimeType: media.mimeType,
181
+ ownerEmail,
182
+ stableUrl: true,
183
+ recordAsset: false,
184
+ })
185
+ : null;
186
+ if (media && !upload?.url) {
176
187
  return failLoomImport(
177
188
  recordingId,
178
189
  "File upload returned no URL. Check your storage provider configuration.",
179
190
  claimId,
180
191
  );
181
192
  }
182
- console.log("[loom-import] reupload complete", {
183
- recordingId,
184
- videoUrl: upload.url,
185
- });
193
+
194
+ const videoUrl = upload?.url ?? loomEmbedUrlForId(loomId);
195
+ if (upload) {
196
+ console.log("[loom-import] reupload complete", {
197
+ recordingId,
198
+ videoUrl: upload.url,
199
+ });
200
+ }
186
201
 
187
202
  const now = new Date().toISOString();
188
203
  const [mediaReady] = await db
189
204
  .update(schema.recordings)
190
205
  .set({
191
- videoUrl: upload.url,
192
- videoSizeBytes: media.sizeBytes,
206
+ videoUrl,
207
+ videoSizeBytes: media?.sizeBytes ?? 0,
193
208
  status: "ready",
194
209
  failureReason: null,
195
210
  updatedAt: now,
@@ -213,20 +228,22 @@ export async function runLoomImportJob({
213
228
  }
214
229
  console.log("[loom-import] recording ready", { recordingId });
215
230
 
216
- void queueBuilderMediaCompression({
217
- recordingId,
218
- ownerEmail,
219
- videoUrl: upload.url,
220
- mimeType: media.mimeType,
221
- providerId: upload.provider,
222
- assetDbId: upload.id,
223
- sourceSizeBytes: media.sizeBytes,
224
- }).catch((err) => {
225
- console.warn("[clips] Loom media compression queue failed", {
231
+ if (media && upload) {
232
+ void queueBuilderMediaCompression({
226
233
  recordingId,
227
- error: err instanceof Error ? err.message : String(err),
234
+ ownerEmail,
235
+ videoUrl: upload.url,
236
+ mimeType: media.mimeType,
237
+ providerId: upload.provider,
238
+ assetDbId: upload.id,
239
+ sourceSizeBytes: media.sizeBytes,
240
+ }).catch((err) => {
241
+ console.warn("[clips] Loom media compression queue failed", {
242
+ recordingId,
243
+ error: err instanceof Error ? err.message : String(err),
244
+ });
228
245
  });
229
- });
246
+ }
230
247
 
231
248
  try {
232
249
  let transcript: Awaited<ReturnType<typeof fetchLoomTranscript>> = null;
@@ -291,7 +308,7 @@ export async function runLoomImportJob({
291
308
  recordingId,
292
309
  status: "ready",
293
310
  progress: 100,
294
- videoUrl: upload.url,
311
+ videoUrl,
295
312
  updatedAt: now,
296
313
  });
297
314
  await writeAppState("refresh-signal", { ts: Date.now() });
@@ -38,8 +38,6 @@ import {
38
38
  } from "@agent-native/core/application-state";
39
39
  import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
40
40
  import { resolveHasBuilderPrivateKey } from "@agent-native/core/server";
41
- import { getRequestUserEmail } from "@agent-native/core/server/request-context";
42
- import { getSetting, getUserSetting } from "@agent-native/core/settings";
43
41
  import { assertAccess } from "@agent-native/core/sharing";
44
42
  import { transcribeWithBuilder } from "@agent-native/core/transcription/builder";
45
43
  import { and, eq } from "drizzle-orm";
@@ -66,8 +64,6 @@ import {
66
64
  type TranscriptSegment,
67
65
  } from "../shared/transcript-segments.js";
68
66
  import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js";
69
- import cleanupTranscript from "./cleanup-transcript.js";
70
- import { loadAgentsMdContext } from "./lib/agents-md-context.js";
71
67
  import {
72
68
  AudioOnlyExtractionError,
73
69
  assertAudioHasAudibleSignal,
@@ -107,7 +103,6 @@ type RecordingMediaRow = {
107
103
  const BUILDER_GEMINI_TRANSCRIPTION_MODEL = "gemini-3-1-flash-lite";
108
104
  const SPEECH_ONLY_TRANSCRIPTION_INSTRUCTIONS =
109
105
  "Auto-detect the spoken language from the audio. Transcribe only words spoken in the audio, in the same language they were spoken. Do not translate. Do not infer language from screen text, filenames, account settings, browser locale, or these instructions. Do not describe screen activity, UI changes, silence, music, or non-speech sounds. Return an empty transcript when there are no spoken words.";
110
- const CLIPS_USER_PREFS_KEY = "clips-user-prefs";
111
106
  const RECENT_PENDING_TRANSCRIPT_MS = 2 * 60 * 1000;
112
107
  const BUILDER_TRANSCRIPTION_MIN_TIMEOUT_MS = 45_000;
113
108
  const BUILDER_TRANSCRIPTION_MAX_TIMEOUT_MS = 65_000;
@@ -856,27 +851,11 @@ async function failAudioOnlyPreparation({
856
851
  throw new Error(reason);
857
852
  }
858
853
 
859
- async function transcriptCleanupEnabled(): Promise<boolean> {
860
- const userEmail = getRequestUserEmail();
861
- if (userEmail) {
862
- const userSettings = await getUserSetting(
863
- userEmail,
864
- CLIPS_USER_PREFS_KEY,
865
- ).catch(() => null);
866
- if (userSettings && "transcriptCleanupEnabled" in userSettings) {
867
- return userSettings.transcriptCleanupEnabled !== false;
868
- }
869
- }
870
-
871
- const settings = await getSetting(CLIPS_USER_PREFS_KEY).catch(() => null);
872
- return settings?.transcriptCleanupEnabled !== false;
873
- }
874
-
875
854
  /**
876
855
  * Read the language already detected/stored on this recording's transcript row.
877
- * Cleanup and renormalization must preserve a detected non-English language
878
- * rather than clobbering it back to "en". Falls back to "en" only when no row
879
- * (or no language) exists yet.
856
+ * Renormalization must preserve a detected non-English language rather than
857
+ * clobbering it back to "en". Falls back to "en" only when no row (or no
858
+ * language) exists yet.
880
859
  */
881
860
  async function resolveStoredLanguage(
882
861
  db: ReturnType<typeof getDb>,
@@ -890,140 +869,6 @@ async function resolveStoredLanguage(
890
869
  return row?.language?.trim() || "en";
891
870
  }
892
871
 
893
- async function cleanupNativeTranscript({
894
- db,
895
- recordingId,
896
- ownerEmail,
897
- fullText,
898
- durationMs,
899
- segmentsJson,
900
- }: {
901
- db: ReturnType<typeof getDb>;
902
- recordingId: string;
903
- ownerEmail: string;
904
- fullText: string;
905
- durationMs: number | null | undefined;
906
- segmentsJson?: string | null;
907
- }): Promise<{ cleaned: boolean; provider?: string }> {
908
- const sourceText = fullText.trim();
909
- if (!sourceText) return { cleaned: false };
910
-
911
- if (!(await transcriptCleanupEnabled())) {
912
- await writeTranscriptCleanupState(recordingId, {
913
- status: "disabled",
914
- });
915
- return { cleaned: false };
916
- }
917
-
918
- await writeTranscriptCleanupState(recordingId, {
919
- status: "running",
920
- provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
921
- startedAt: new Date().toISOString(),
922
- });
923
-
924
- try {
925
- const agentsContext = await loadAgentsMdContext({
926
- ownerEmail,
927
- purpose: "cleanup",
928
- });
929
- const result = await cleanupTranscript.run({
930
- transcript: sourceText,
931
- task: "cleanup",
932
- context: agentsContext,
933
- });
934
- const cleanedText = result.cleanedText?.trim();
935
- if (!cleanedText || cleanedText === sourceText) {
936
- await writeTranscriptCleanupState(recordingId, {
937
- status: "unchanged",
938
- provider: result.provider,
939
- });
940
- return { cleaned: false, provider: result.provider };
941
- }
942
- if (!isSafeTranscriptCleanupReplacement(sourceText, cleanedText)) {
943
- await writeTranscriptCleanupState(recordingId, {
944
- status: "failed",
945
- provider: result.provider,
946
- failureReason:
947
- "Cleanup output was incomplete; the original transcript was kept.",
948
- sourceChars: sourceText.length,
949
- cleanedChars: cleanedText.length,
950
- });
951
- return { cleaned: false, provider: result.provider };
952
- }
953
-
954
- const cleanupSegmentsJson = resolveCleanupSegmentsJson(
955
- segmentsJson,
956
- cleanedText,
957
- durationMs,
958
- );
959
- if (!cleanupSegmentsJson) {
960
- await writeTranscriptCleanupState(recordingId, {
961
- status: "unchanged",
962
- provider: result.provider,
963
- failureReason:
964
- "Cleanup output could not be aligned with measured speaker cues; the original transcript was kept.",
965
- });
966
- return { cleaned: false, provider: result.provider };
967
- }
968
-
969
- const now = new Date().toISOString();
970
- const language = await resolveStoredLanguage(db, recordingId);
971
- const updated = await db
972
- .update(schema.recordingTranscripts)
973
- .set({
974
- ownerEmail,
975
- status: "ready",
976
- failureReason: null,
977
- language,
978
- segmentsJson: cleanupSegmentsJson,
979
- fullText: cleanedText,
980
- retryCount: 0,
981
- updatedAt: now,
982
- })
983
- .where(
984
- and(
985
- eq(schema.recordingTranscripts.recordingId, recordingId),
986
- eq(schema.recordingTranscripts.status, "ready"),
987
- eq(schema.recordingTranscripts.fullText, sourceText),
988
- ),
989
- )
990
- .returning({ recordingId: schema.recordingTranscripts.recordingId });
991
- if (!updated.length) {
992
- await writeTranscriptCleanupState(recordingId, {
993
- status: "failed",
994
- provider: result.provider,
995
- failureReason:
996
- "Transcript changed during cleanup; the newer transcript was kept.",
997
- });
998
- return { cleaned: false, provider: result.provider };
999
- }
1000
- await writeTranscriptCleanupState(recordingId, {
1001
- status: "ready",
1002
- provider: result.provider,
1003
- });
1004
-
1005
- return { cleaned: true, provider: result.provider };
1006
- } catch (err) {
1007
- const details = serializeError(err);
1008
- console.warn(
1009
- `[clips] native transcript cleanup skipped for ${recordingId}: ${summarizeError(err)}`,
1010
- );
1011
- if (verboseTranscriptErrors()) {
1012
- console.warn(
1013
- "[clips] native transcript cleanup error details",
1014
- serializeError(err, { includeStack: true }),
1015
- );
1016
- }
1017
- await writeTranscriptCleanupState(recordingId, {
1018
- status: "failed",
1019
- provider: BUILDER_GEMINI_TRANSCRIPTION_MODEL,
1020
- failureReason: (err as Error)?.message ?? String(err),
1021
- details,
1022
- });
1023
- return { cleaned: false };
1024
- }
1025
- }
1026
-
1027
872
  async function generateRecordingMetadata({
1028
873
  recordingId,
1029
874
  title,
@@ -1120,21 +965,6 @@ async function completeReadyTranscript({
1120
965
  }
1121
966
  }
1122
967
 
1123
- const cleanupPromise = cleanupNativeTranscript({
1124
- db,
1125
- recordingId,
1126
- ownerEmail,
1127
- fullText,
1128
- durationMs: recForTitle?.durationMs,
1129
- segmentsJson,
1130
- }).catch((err) => {
1131
- console.warn(
1132
- `[clips] native transcript cleanup failed for ${recordingId}:`,
1133
- (err as Error)?.message ?? String(err),
1134
- );
1135
- return { cleaned: false };
1136
- });
1137
-
1138
968
  const metadataPromise = recForTitle
1139
969
  ? generateRecordingMetadata({
1140
970
  recordingId,
@@ -1151,12 +981,7 @@ async function completeReadyTranscript({
1151
981
  })
1152
982
  : Promise.resolve({ titleQueued: false, summaryQueued: false });
1153
983
 
1154
- // Both calls are independent. Await them together so the durable worker stays
1155
- // alive without serially stacking two model-call timeouts.
1156
- const [cleanupResult, metadataResult] = await Promise.all([
1157
- cleanupPromise,
1158
- metadataPromise,
1159
- ]);
984
+ const metadataResult = await metadataPromise;
1160
985
 
1161
986
  if (!recForTitle) {
1162
987
  console.warn(
@@ -1175,9 +1000,7 @@ async function completeReadyTranscript({
1175
1000
  );
1176
1001
  }
1177
1002
 
1178
- // Wake the player polling so it picks up the queued cleanup state row
1179
- // (`transcript-cleanup-${recordingId}`) before its next 2s tick lands —
1180
- // otherwise the "Cleaning up…" badge can lag for one full poll interval.
1003
+ // Wake the player polling after transcript metadata generation completes.
1181
1004
  await writeAppState("refresh-signal", { ts: Date.now() });
1182
1005
  await finalizeEndedMeetingsForRecording(db, recordingId);
1183
1006
  await queueBrainExport(recordingId);
@@ -1185,7 +1008,7 @@ async function completeReadyTranscript({
1185
1008
  return {
1186
1009
  recordingId,
1187
1010
  status: "ready",
1188
- cleaned: cleanupResult.cleaned,
1011
+ cleaned: false,
1189
1012
  provider: segmentsJson && segmentsJson !== "[]" ? "existing" : "native",
1190
1013
  cleanupQueued: false,
1191
1014
  titleQueued: metadataResult.titleQueued,
@@ -0,0 +1,46 @@
1
+ import {
2
+ forwardRef,
3
+ useCallback,
4
+ type ComponentPropsWithoutRef,
5
+ type ReactNode,
6
+ } from "react";
7
+
8
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
9
+ import { useVisibleAvatarUrl } from "@/lib/use-visible-avatar-url";
10
+
11
+ interface ClipsAvatarProps extends Omit<
12
+ ComponentPropsWithoutRef<typeof Avatar>,
13
+ "children"
14
+ > {
15
+ email: string | null | undefined;
16
+ alt: string;
17
+ fallback: ReactNode;
18
+ fallbackClassName?: string;
19
+ }
20
+
21
+ export const ClipsAvatar = forwardRef<HTMLSpanElement, ClipsAvatarProps>(
22
+ function ClipsAvatar(
23
+ { email, alt, fallback, fallbackClassName, ...avatarProps },
24
+ forwardedRef,
25
+ ) {
26
+ const { avatarRef, avatarUrl } = useVisibleAvatarUrl(email);
27
+ const setAvatarRef = useCallback(
28
+ (element: HTMLSpanElement | null) => {
29
+ avatarRef.current = element;
30
+ if (typeof forwardedRef === "function") forwardedRef(element);
31
+ else if (forwardedRef) forwardedRef.current = element;
32
+ },
33
+ [avatarRef, forwardedRef],
34
+ );
35
+
36
+ return (
37
+ <Avatar {...avatarProps} ref={setAvatarRef}>
38
+ {avatarUrl ? <AvatarImage src={avatarUrl} alt={alt} /> : null}
39
+ <AvatarFallback className={fallbackClassName}>
40
+ {fallback}
41
+ </AvatarFallback>
42
+ </Avatar>
43
+ );
44
+ },
45
+ );
46
+ ClipsAvatar.displayName = "ClipsAvatar";
@@ -17,9 +17,9 @@ import {
17
17
  import { useCallback, useMemo, useRef, useState } from "react";
18
18
  import { Link } from "react-router";
19
19
 
20
+ import { ClipsAvatar } from "@/components/clips-avatar";
20
21
  import { AgentViewCount } from "@/components/player/recording-views-badge";
21
22
  import { ViewedByPopover } from "@/components/sharing/viewed-by-popover";
22
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
23
23
  import { Checkbox } from "@/components/ui/checkbox";
24
24
  import {
25
25
  DropdownMenu,
@@ -340,12 +340,13 @@ export function RecordingCard({
340
340
  </div>
341
341
  )}
342
342
  <div className="mt-1 flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
343
- <Avatar className="h-4 w-4 shrink-0">
344
- <AvatarImage src="" alt={recording.ownerEmail} />
345
- <AvatarFallback className="bg-primary/15 text-[8px] text-primary">
346
- {ownerInitials}
347
- </AvatarFallback>
348
- </Avatar>
343
+ <ClipsAvatar
344
+ email={recording.ownerEmail}
345
+ alt={recording.ownerEmail}
346
+ fallback={ownerInitials}
347
+ className="h-4 w-4 shrink-0"
348
+ fallbackClassName="bg-primary/15 text-[8px] text-primary"
349
+ />
349
350
  <span className="min-w-0 truncate">{recording.ownerEmail}</span>
350
351
  <span aria-hidden>•</span>
351
352
  <span className="shrink-0">{relative}</span>
@@ -9,6 +9,7 @@ import {
9
9
  import { useState } from "react";
10
10
  import { Link } from "react-router";
11
11
 
12
+ import { ClipsAvatar } from "@/components/clips-avatar";
12
13
  import {
13
14
  ContextMenu,
14
15
  ContextMenuContent,
@@ -104,13 +105,15 @@ export function SpaceCard({
104
105
  .slice(0, 2)
105
106
  .toUpperCase();
106
107
  return (
107
- <div
108
+ <ClipsAvatar
108
109
  key={email}
110
+ email={email}
111
+ alt={email}
112
+ fallback={initials}
113
+ className="h-5 w-5 border border-background"
114
+ fallbackClassName="bg-primary/15 text-[9px] font-medium text-primary"
109
115
  title={email}
110
- className="flex h-5 w-5 items-center justify-center rounded-full border border-background bg-primary/15 text-[9px] font-medium text-primary"
111
- >
112
- {initials}
113
- </div>
116
+ />
114
117
  );
115
118
  })}
116
119
  {members.length > 5 && (
@@ -5,7 +5,7 @@
5
5
  * gets a shadcn Tooltip showing name/email. Used on both list cards and
6
6
  * detail headers.
7
7
  */
8
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
8
+ import { ClipsAvatar } from "@/components/clips-avatar";
9
9
  import {
10
10
  Tooltip,
11
11
  TooltipContent,
@@ -58,14 +58,13 @@ export function AttendeeStack({
58
58
  {visible.map((p, i) => (
59
59
  <Tooltip key={`${p.email}-${i}`}>
60
60
  <TooltipTrigger asChild>
61
- <Avatar
61
+ <ClipsAvatar
62
+ email={p.email}
63
+ alt={p.name || p.email}
64
+ fallback={attendeeInitials(p)}
65
+ fallbackClassName="font-medium"
62
66
  className={`${sizeClass} ring-2 ring-background cursor-default`}
63
- >
64
- <AvatarImage alt={p.name || p.email} />
65
- <AvatarFallback className="font-medium">
66
- {attendeeInitials(p)}
67
- </AvatarFallback>
68
- </Avatar>
67
+ />
69
68
  </TooltipTrigger>
70
69
  <TooltipContent side="top" className="text-xs">
71
70
  <div className="font-medium">{p.name || p.email}</div>