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

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 (77) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/.agents/skills/recording/SKILL.md +9 -8
  3. package/corpus/templates/clips/actions/import-loom-recording.ts +1 -1
  4. package/corpus/templates/clips/actions/lib/loom-import-job.ts +52 -35
  5. package/corpus/templates/clips/app/components/clips-avatar.tsx +46 -0
  6. package/corpus/templates/clips/app/components/library/recording-card.tsx +8 -7
  7. package/corpus/templates/clips/app/components/library/space-card.tsx +8 -5
  8. package/corpus/templates/clips/app/components/meetings/attendee-stack.tsx +7 -8
  9. package/corpus/templates/clips/app/components/meetings/transcript-bubbles.tsx +19 -12
  10. package/corpus/templates/clips/app/components/workspace/members-list.tsx +8 -6
  11. package/corpus/templates/clips/app/components/workspace/notifications-list.tsx +8 -6
  12. package/corpus/templates/clips/app/components/workspace/top-creators-table.tsx +8 -6
  13. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +8 -7
  14. package/corpus/templates/clips/changelog/2026-08-26-detected-calls-no-longer-duplicate-meetings-or-strand-the-prompt.md +6 -0
  15. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +39 -22
  16. package/corpus/templates/clips/desktop/src-tauri/src/adhoc_meetings_watcher.rs +1765 -219
  17. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +4 -0
  18. package/corpus/templates/clips/desktop/src-tauri/src/meetings_watcher.rs +45 -30
  19. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +91 -0
  20. package/corpus/templates/clips/server/routes/_agent-native/google/callback.get.ts +16 -0
  21. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +44 -22
  22. package/corpus/templates/content/app/components/ui/avatar.tsx +1 -0
  23. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +4 -1
  24. package/corpus/templates/mail/actions/get-email.ts +68 -21
  25. package/corpus/templates/mail/actions/get-thread.ts +92 -51
  26. package/corpus/templates/mail/app/hooks/use-emails.ts +7 -4
  27. package/corpus/templates/mail/server/lib/mail-connector-catalog.ts +7 -4
  28. package/corpus/templates/plan/app/pages/PlansPage.tsx +23 -15
  29. package/corpus/templates/slides/app/components/comments/SlideCommentsPanel.tsx +18 -7
  30. package/corpus/templates/slides/app/components/ui/avatar.tsx +1 -0
  31. package/corpus/templates/tasks/DEVELOPING.md +8 -0
  32. package/corpus/templates/tasks/drizzle.config.ts +3 -1
  33. package/corpus/templates/tasks/drizzle.local.config.ts +6 -0
  34. package/corpus/templates/tasks/package.json +2 -1
  35. package/corpus/templates/tasks/server/db/migrations/meta/0000_snapshot.json +234 -0
  36. package/corpus/templates/tasks/server/db/migrations/meta/_journal.json +13 -0
  37. package/corpus/templates/tasks/server/db/migrations.ts +110 -0
  38. package/corpus/templates/tasks/server/plugins/db.ts +3 -87
  39. package/dist/client/review/ReviewThreadPanel.js +4 -2
  40. package/dist/collab/client.js +20 -5
  41. package/dist/collab/presence.d.ts +3 -1
  42. package/dist/collab/presence.js +48 -45
  43. package/dist/collab/routes.d.ts +1 -1
  44. package/dist/collab/struct-routes.d.ts +1 -1
  45. package/dist/db/drizzle-config.d.ts +7 -0
  46. package/dist/db/drizzle-config.js +35 -9
  47. package/dist/db/drizzle-migrations.d.ts +28 -0
  48. package/dist/db/drizzle-migrations.js +81 -0
  49. package/dist/db/index.d.ts +1 -1
  50. package/dist/db/migrations.d.ts +13 -1
  51. package/dist/db/migrations.js +60 -31
  52. package/dist/db/schema.js +4 -1
  53. package/dist/deploy/build.d.ts +6 -0
  54. package/dist/deploy/build.js +41 -0
  55. package/dist/observability/routes.d.ts +5 -5
  56. package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
  57. package/dist/provider-api/actions/provider-api.d.ts +9 -9
  58. package/dist/secrets/routes.d.ts +9 -9
  59. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  60. package/dist/server/auth.js +3 -2
  61. package/dist/server/builder-browser.d.ts +0 -4
  62. package/dist/server/builder-browser.js +23 -8
  63. package/dist/server/google-oauth.d.ts +18 -0
  64. package/dist/server/google-oauth.js +3 -3
  65. package/dist/server/realtime-token.d.ts +1 -1
  66. package/dist/server/short-lived-token.d.ts +1 -1
  67. package/dist/server/short-lived-token.js +1 -1
  68. package/dist/server/transcribe-voice.d.ts +1 -1
  69. package/dist/shared/google-profile-image.d.ts +2 -0
  70. package/dist/shared/google-profile-image.js +24 -0
  71. package/dist/shared/index.d.ts +1 -0
  72. package/dist/shared/index.js +1 -0
  73. package/docs/content/creating-templates.mdx +10 -1
  74. package/docs/content/server-database.mdx +46 -2
  75. package/docs/content/server-plugins.mdx +39 -3
  76. package/docs/content/webmcp.mdx +2 -0
  77. 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
@@ -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() });
@@ -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>
@@ -9,7 +9,7 @@ import {
9
9
  import type { KeyboardEvent, ReactNode } from "react";
10
10
  import { useEffect, useMemo, useRef, useState } from "react";
11
11
 
12
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
12
+ import { ClipsAvatar } from "@/components/clips-avatar";
13
13
  import { Button } from "@/components/ui/button";
14
14
  import { Input } from "@/components/ui/input";
15
15
  import {
@@ -59,6 +59,7 @@ export interface SpeakerIdentity {
59
59
  key: string;
60
60
  label: string | null;
61
61
  initialsSource: AttendeeStackParticipant | string;
62
+ email?: string | null;
62
63
  isOwner: boolean;
63
64
  accentClass: string;
64
65
  /** The capture could not tell speakers apart, so this transcript names
@@ -291,6 +292,7 @@ export function resolveSpeaker(
291
292
  key,
292
293
  label,
293
294
  initialsSource: participant ?? label ?? (source === "mic" ? "Me" : "Them"),
295
+ email: participant?.email ?? (source === "mic" ? ownerEmail : null),
294
296
  isOwner: source === "mic",
295
297
  accentClass: accentForSpeaker(key, source === "mic"),
296
298
  };
@@ -641,21 +643,26 @@ export function TranscriptBubbles({
641
643
  >
642
644
  {!group.speaker.unattributed && (
643
645
  <div className="flex h-6 items-center gap-2">
644
- <Avatar
646
+ <ClipsAvatar
647
+ email={group.speaker.email}
648
+ alt={
649
+ group.speaker.label ||
650
+ (group.speaker.isOwner
651
+ ? t("transcriptBubbles.me")
652
+ : t("transcriptBubbles.them"))
653
+ }
654
+ fallback={attendeeInitials(
655
+ group.speaker.initialsSource,
656
+ )}
645
657
  className={cn(
646
658
  "size-6 shrink-0",
647
659
  group.speaker.accentClass,
648
660
  )}
649
- >
650
- <AvatarFallback
651
- className={cn(
652
- "text-[9px] font-semibold",
653
- group.speaker.accentClass,
654
- )}
655
- >
656
- {attendeeInitials(group.speaker.initialsSource)}
657
- </AvatarFallback>
658
- </Avatar>
661
+ fallbackClassName={cn(
662
+ "text-[9px] font-semibold",
663
+ group.speaker.accentClass,
664
+ )}
665
+ />
659
666
  <div className="flex min-h-6 items-center">
660
667
  <span
661
668
  className={cn(
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
5
5
  import { useState } from "react";
6
6
  import { toast } from "sonner";
7
7
 
8
+ import { ClipsAvatar } from "@/components/clips-avatar";
8
9
  import {
9
10
  AlertDialog,
10
11
  AlertDialogAction,
@@ -15,7 +16,6 @@ import {
15
16
  AlertDialogHeader,
16
17
  AlertDialogTitle,
17
18
  } from "@/components/ui/alert-dialog";
18
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
19
19
  import { Badge } from "@/components/ui/badge";
20
20
  import { Button } from "@/components/ui/button";
21
21
  import {
@@ -152,11 +152,13 @@ export function MembersList({
152
152
  <TableRow key={m.id}>
153
153
  <TableCell>
154
154
  <div className="flex items-center gap-2 min-w-0">
155
- <Avatar className="h-8 w-8 flex-shrink-0">
156
- <AvatarFallback className="text-xs bg-primary text-primary-foreground">
157
- {initials(m.email)}
158
- </AvatarFallback>
159
- </Avatar>
155
+ <ClipsAvatar
156
+ email={m.email}
157
+ alt={m.email}
158
+ fallback={initials(m.email)}
159
+ className="h-8 w-8 flex-shrink-0"
160
+ fallbackClassName="text-xs bg-primary text-primary-foreground"
161
+ />
160
162
  <div className="min-w-0">
161
163
  <div className="truncate font-medium flex items-center gap-1.5">
162
164
  {m.email}
@@ -9,7 +9,7 @@ import {
9
9
  } from "@tabler/icons-react";
10
10
  import { Link } from "react-router";
11
11
 
12
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
12
+ import { ClipsAvatar } from "@/components/clips-avatar";
13
13
 
14
14
  export type NotificationKind = "comment" | "reaction" | "mention" | "share";
15
15
 
@@ -62,11 +62,13 @@ export function NotificationsList({ items, onReply }: NotificationsListProps) {
62
62
  <ul className="divide-y">
63
63
  {items.map((item) => (
64
64
  <li key={item.id} className="py-3 flex items-start gap-3">
65
- <Avatar className="h-9 w-9 flex-shrink-0">
66
- <AvatarFallback className="text-xs bg-primary text-primary-foreground">
67
- {initials(item.authorEmail)}
68
- </AvatarFallback>
69
- </Avatar>
65
+ <ClipsAvatar
66
+ email={item.authorEmail}
67
+ alt={item.authorEmail ?? t("clipsFinalRaw.someone")}
68
+ fallback={initials(item.authorEmail)}
69
+ className="h-9 w-9 flex-shrink-0"
70
+ fallbackClassName="text-xs bg-primary text-primary-foreground"
71
+ />
70
72
  <div className="min-w-0 flex-1">
71
73
  <div className="flex items-center gap-2 text-sm">
72
74
  <KindIcon kind={item.kind} />
@@ -1,6 +1,6 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
2
 
3
- import { Avatar, AvatarFallback } from "@/components/ui/avatar";
3
+ import { ClipsAvatar } from "@/components/clips-avatar";
4
4
  import {
5
5
  Table,
6
6
  TableBody,
@@ -58,11 +58,13 @@ export function TopCreatorsTable({ rows }: TopCreatorsTableProps) {
58
58
  <TableRow key={row.email}>
59
59
  <TableCell>
60
60
  <div className="flex items-center gap-2 min-w-0">
61
- <Avatar className="h-7 w-7 flex-shrink-0">
62
- <AvatarFallback className="text-xs bg-primary text-primary-foreground">
63
- {initials(row.email)}
64
- </AvatarFallback>
65
- </Avatar>
61
+ <ClipsAvatar
62
+ email={row.email}
63
+ alt={row.email}
64
+ fallback={initials(row.email)}
65
+ className="h-7 w-7 flex-shrink-0"
66
+ fallbackClassName="text-xs bg-primary text-primary-foreground"
67
+ />
66
68
  <span className="truncate">{row.email}</span>
67
69
  </div>
68
70
  </TableCell>
@@ -28,6 +28,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
28
28
  import { NavLink, useNavigate, useParams } from "react-router";
29
29
  import { toast } from "sonner";
30
30
 
31
+ import { ClipsAvatar } from "@/components/clips-avatar";
31
32
  import { PageHeader } from "@/components/library/page-header";
32
33
  import {
33
34
  AttendeeStack,
@@ -52,7 +53,6 @@ import {
52
53
  AlertDialogHeader,
53
54
  AlertDialogTitle,
54
55
  } from "@/components/ui/alert-dialog";
55
- import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
56
56
  import { Badge } from "@/components/ui/badge";
57
57
  import { Button } from "@/components/ui/button";
58
58
  import {
@@ -258,12 +258,13 @@ function ActionItemsByPerson({
258
258
  {grouped.map(([who, list]) => (
259
259
  <div key={who} className="space-y-1.5">
260
260
  <div className="flex items-center gap-2">
261
- <Avatar className="h-5 w-5">
262
- <AvatarImage alt={who} />
263
- <AvatarFallback className="text-[9px]">
264
- {attendeeInitials(who || t("meetingDetail.unassigned"))}
265
- </AvatarFallback>
266
- </Avatar>
261
+ <ClipsAvatar
262
+ email={who || null}
263
+ alt={who || t("meetingDetail.unassigned")}
264
+ fallback={attendeeInitials(who || t("meetingDetail.unassigned"))}
265
+ className="h-5 w-5"
266
+ fallbackClassName="text-[9px]"
267
+ />
267
268
  <span className="text-xs font-medium">
268
269
  {who || t("meetingDetail.unassigned")}
269
270
  </span>
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-26
4
+ ---
5
+
6
+ Detected Zoom and Teams calls no longer create duplicate meetings or leave a prompt on screen after recording starts
@@ -237,11 +237,13 @@ export function MeetingNotification() {
237
237
  }).catch(() => {});
238
238
  };
239
239
 
240
- trackListen(
241
- listen<NotificationData>("meetings:show-notification", (ev) => {
240
+ const showListener = listen<NotificationData>(
241
+ "meetings:show-notification",
242
+ (ev) => {
242
243
  showNotification(ev.payload);
243
- }),
244
+ },
244
245
  );
246
+ trackListen(showListener);
245
247
 
246
248
  trackListen(
247
249
  listen<{ hovered: boolean }>("meetings:notification-hover", (ev) => {
@@ -249,33 +251,48 @@ export function MeetingNotification() {
249
251
  }),
250
252
  );
251
253
 
254
+ const hideListener = listen<TranscriptionStatusPayload>(
255
+ "meetings:hide-notification",
256
+ (ev) => {
257
+ if (ev.payload.meetingId !== dataRef.current?.meetingId) return;
258
+ hideNotification();
259
+ },
260
+ );
261
+ trackListen(hideListener);
262
+ const errorListener = listen<TranscriptionStatusPayload>(
263
+ "meetings:transcription-error",
264
+ (ev) => {
265
+ if (ev.payload.meetingId !== dataRef.current?.meetingId) return;
266
+ setPending(false);
267
+ setError(ev.payload.error || "Could not start notes.");
268
+ scheduleAutoHide(15_000);
269
+ },
270
+ );
271
+ trackListen(errorListener);
272
+
252
273
  // Cold overlay boot: hydrate any payload stored before this webview
253
274
  // mounted (calendar or adhoc).
254
- invoke<NotificationData | null>("take_pending_meeting_notification")
275
+ //
276
+ // Every listener has to be live first, because `take` is destructive and
277
+ // each event lost in the gap is lost permanently. A hide landing before the
278
+ // hide listener registers leaves a card nothing can take back; a show
279
+ // landing before the show listener registers is dropped while the payload it
280
+ // duplicated has already been consumed, so no card appears at all.
281
+ Promise.all([showListener, hideListener, errorListener])
282
+ .then(() =>
283
+ invoke<NotificationData | null>("take_pending_meeting_notification"),
284
+ )
255
285
  .then((pending) => {
256
286
  if (stopped || !pending) return;
287
+ // The take is asynchronous, so a live `meetings:show-notification` may
288
+ // have arrived while it was in flight. That payload is newer than this
289
+ // one by definition, and hydration is only a cold-boot fallback, so it
290
+ // must not replace what the live path already put on screen.
291
+ if (dataRef.current) return;
257
292
  showNotification(pending, { hydrated: true });
258
293
  })
259
294
  .catch(() => {});
260
295
 
261
- trackListen(
262
- listen<TranscriptionStatusPayload>("meetings:hide-notification", (ev) => {
263
- if (ev.payload.meetingId !== dataRef.current?.meetingId) return;
264
- hideNotification();
265
- }),
266
- );
267
- trackListen(
268
- listen<TranscriptionStatusPayload>(
269
- "meetings:transcription-error",
270
- (ev) => {
271
- if (ev.payload.meetingId !== dataRef.current?.meetingId) return;
272
- setPending(false);
273
- setError(ev.payload.error || "Could not start notes.");
274
- scheduleAutoHide(15_000);
275
- },
276
- ),
277
- );
278
-
279
296
  return () => {
280
297
  stopped = true;
281
298
  clearAutoHide();