@agent-native/core 0.114.4 → 0.114.5

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 (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +8 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/templates/clips/.agents/skills/meetings/SKILL.md +11 -1
  5. package/corpus/templates/clips/AGENTS.md +14 -4
  6. package/corpus/templates/clips/actions/update-meeting.ts +13 -3
  7. package/corpus/templates/clips/actions/view-screen.ts +1 -0
  8. package/corpus/templates/clips/app/components/meetings/meeting-card.tsx +2 -1
  9. package/corpus/templates/clips/app/components/meetings/share-meeting-dialog.tsx +96 -4
  10. package/corpus/templates/clips/app/i18n/en-US.ts +11 -0
  11. package/corpus/templates/clips/app/lib/public-meeting.ts +85 -0
  12. package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +11 -1
  13. package/corpus/templates/clips/app/routes/share.$shareId.tsx +6 -7
  14. package/corpus/templates/clips/app/routes/share.meeting.$meetingId.tsx +241 -135
  15. package/corpus/templates/clips/changelog/2026-07-20-meeting-share-links-can-include-the-full-transcript-with-an-.md +6 -0
  16. package/corpus/templates/clips/changelog/2026-07-20-recording-retries-the-default-mac-microphone-when-a-saved-in.md +6 -0
  17. package/corpus/templates/clips/changelog/2026-07-20-shared-clips-now-tell-agents-to-wait-while-uploads-and-trans.md +6 -0
  18. package/corpus/templates/clips/desktop/src/lib/meeting-join-url.ts +1 -33
  19. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +65 -2
  20. package/corpus/templates/clips/server/db/schema.ts +3 -0
  21. package/corpus/templates/clips/server/lib/public-agent-context.ts +31 -21
  22. package/corpus/templates/clips/server/plugins/auth.ts +1 -0
  23. package/corpus/templates/clips/server/plugins/db.ts +6 -0
  24. package/corpus/templates/clips/server/routes/api/agent-transcript.json.get.ts +13 -3
  25. package/corpus/templates/clips/server/routes/api/public-meeting.get.ts +152 -0
  26. package/corpus/templates/clips/shared/agent-context.ts +60 -0
  27. package/corpus/templates/clips/shared/meeting-join-url.ts +36 -0
  28. package/dist/collab/routes.d.ts +1 -1
  29. package/dist/collab/struct-routes.d.ts +1 -1
  30. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  31. package/dist/notifications/routes.d.ts +2 -2
  32. package/dist/progress/routes.d.ts +1 -1
  33. package/package.json +2 -2
@@ -1,91 +1,71 @@
1
1
  import { appPath } from "@agent-native/core/client/api-path";
2
+ import { useSession } from "@agent-native/core/client/hooks";
2
3
  import { useT } from "@agent-native/core/client/i18n";
3
4
  import { PoweredByBadge } from "@agent-native/core/client/ui";
4
- import { getRequestUserEmail } from "@agent-native/core/server";
5
- import { resolveAccess } from "@agent-native/core/sharing";
6
5
  import {
7
6
  IconCalendar,
7
+ IconCheck,
8
+ IconCopy,
8
9
  IconExternalLink,
9
10
  IconListCheck,
11
+ IconNotes,
10
12
  IconUsers,
11
13
  IconWand,
12
14
  } from "@tabler/icons-react";
15
+ import { useQuery } from "@tanstack/react-query";
13
16
  import { and, eq, isNull } from "drizzle-orm";
14
- import { useEffect, useRef } from "react";
17
+ import { useEffect, useRef, useState } from "react";
15
18
  import type { LoaderFunctionArgs, MetaFunction } from "react-router";
16
- import { useLoaderData, useRevalidator } from "react-router";
19
+ import { useLoaderData, useParams } from "react-router";
20
+ import { toast } from "sonner";
17
21
 
18
22
  import {
19
23
  AttendeeStack,
20
24
  type AttendeeStackParticipant,
21
25
  } from "@/components/meetings/attendee-stack";
26
+ import { TranscriptBubbles } from "@/components/meetings/transcript-bubbles";
22
27
  import { Button } from "@/components/ui/button";
23
28
  import { Spinner } from "@/components/ui/spinner";
24
29
  import enMessages from "@/i18n/en-US";
30
+ import {
31
+ fetchPublicMeeting,
32
+ type PublicMeeting,
33
+ type PublicMeetingResult,
34
+ type PublicMeetingTranscript,
35
+ } from "@/lib/public-meeting";
25
36
 
26
37
  import { getDb, schema } from "../../server/db";
27
38
 
28
- interface ActionItem {
29
- id: string;
30
- text: string;
31
- assigneeEmail: string | null;
32
- completedAt: string | null;
33
- }
34
-
35
- interface Participant {
36
- email: string;
37
- name: string | null;
38
- isOrganizer: boolean;
39
- }
40
-
41
- interface Bullet {
42
- text: string;
43
- }
44
-
45
- interface ShareMeetingData {
46
- id: string;
47
- title: string;
48
- scheduledStart: string | null;
49
- summaryMd: string;
50
- bullets: Bullet[];
51
- participants: Participant[];
52
- actionItems: ActionItem[];
53
- actualStart: string | null;
54
- actualEnd: string | null;
55
- transcriptStatus: string | null;
56
- }
57
-
58
- type LoaderData = { meeting: ShareMeetingData } | { meeting: null };
39
+ type LoaderData = { meeting: PublicMeeting | null };
59
40
 
60
41
  export async function loader({
61
42
  params,
62
43
  }: LoaderFunctionArgs): Promise<LoaderData> {
63
- const id = params.meetingId;
64
- if (!id) return { meeting: null };
44
+ const meetingId = params.meetingId;
45
+ if (!meetingId) return { meeting: null };
65
46
 
66
- const [row] = await getDb()
47
+ const [meeting] = await getDb()
67
48
  .select({
68
49
  id: schema.meetings.id,
69
50
  title: schema.meetings.title,
70
51
  scheduledStart: schema.meetings.scheduledStart,
71
- visibility: schema.meetings.visibility,
72
52
  summaryMd: schema.meetings.summaryMd,
73
53
  bulletsJson: schema.meetings.bulletsJson,
74
54
  actualStart: schema.meetings.actualStart,
75
55
  actualEnd: schema.meetings.actualEnd,
76
56
  transcriptStatus: schema.meetings.transcriptStatus,
57
+ visibility: schema.meetings.visibility,
77
58
  })
78
59
  .from(schema.meetings)
79
- .where(and(eq(schema.meetings.id, id), isNull(schema.meetings.trashedAt)))
60
+ .where(
61
+ and(
62
+ eq(schema.meetings.id, meetingId),
63
+ eq(schema.meetings.visibility, "public"),
64
+ isNull(schema.meetings.trashedAt),
65
+ ),
66
+ )
80
67
  .limit(1);
81
-
82
- if (!row) return { meeting: null };
83
-
84
- if (row.visibility !== "public") {
85
- const userEmail = getRequestUserEmail();
86
- const access = userEmail ? await resolveAccess("meeting", id) : null;
87
- if (!access) return { meeting: null };
88
- }
68
+ if (!meeting) return { meeting: null };
89
69
 
90
70
  const [participants, actionItems] = await Promise.all([
91
71
  getDb()
@@ -95,7 +75,7 @@ export async function loader({
95
75
  isOrganizer: schema.meetingParticipants.isOrganizer,
96
76
  })
97
77
  .from(schema.meetingParticipants)
98
- .where(eq(schema.meetingParticipants.meetingId, id)),
78
+ .where(eq(schema.meetingParticipants.meetingId, meetingId)),
99
79
  getDb()
100
80
  .select({
101
81
  id: schema.meetingActionItems.id,
@@ -104,38 +84,45 @@ export async function loader({
104
84
  completedAt: schema.meetingActionItems.completedAt,
105
85
  })
106
86
  .from(schema.meetingActionItems)
107
- .where(eq(schema.meetingActionItems.meetingId, id)),
87
+ .where(eq(schema.meetingActionItems.meetingId, meetingId)),
108
88
  ]);
109
89
 
110
- let bullets: Bullet[] = [];
90
+ let bullets: PublicMeeting["bullets"] = [];
111
91
  try {
112
- const parsed = JSON.parse(row.bulletsJson);
113
- if (Array.isArray(parsed)) bullets = parsed as Bullet[];
92
+ const parsed = JSON.parse(meeting.bulletsJson);
93
+ if (Array.isArray(parsed)) {
94
+ bullets = parsed.filter(
95
+ (bullet): bullet is { text: string } =>
96
+ typeof bullet === "object" &&
97
+ bullet !== null &&
98
+ typeof bullet.text === "string",
99
+ );
100
+ }
114
101
  } catch {}
115
102
 
116
103
  return {
117
104
  meeting: {
118
- id: row.id,
119
- title: row.title,
120
- scheduledStart: row.scheduledStart,
121
- summaryMd: row.summaryMd,
105
+ id: meeting.id,
106
+ title: meeting.title,
107
+ scheduledStart: meeting.scheduledStart,
108
+ summaryMd: meeting.summaryMd,
122
109
  bullets,
123
110
  participants,
124
111
  actionItems,
125
- actualStart: row.actualStart,
126
- actualEnd: row.actualEnd,
127
- transcriptStatus: row.transcriptStatus,
112
+ actualStart: meeting.actualStart,
113
+ actualEnd: meeting.actualEnd,
114
+ transcriptStatus: meeting.transcriptStatus,
128
115
  },
129
116
  };
130
117
  }
131
118
 
132
119
  export const meta: MetaFunction<typeof loader> = ({ loaderData }) => {
133
- const m = loaderData?.meeting;
134
- const title = m?.title
135
- ? `${m.title} · Clips`
120
+ const meeting = loaderData?.meeting;
121
+ const title = meeting?.title
122
+ ? `${meeting.title} · Clips`
136
123
  : enMessages.shareMeeting.pageTitle;
137
- const description = m?.title
138
- ? `AI meeting notes for "${m.title}"`
124
+ const description = meeting?.title
125
+ ? `AI meeting notes for "${meeting.title}"`
139
126
  : enMessages.shareMeeting.description;
140
127
  return [
141
128
  { title },
@@ -147,8 +134,8 @@ export const meta: MetaFunction<typeof loader> = ({ loaderData }) => {
147
134
 
148
135
  export function HydrateFallback() {
149
136
  return (
150
- <div className="flex items-center justify-center h-screen w-full bg-background">
151
- <Spinner className="h-8 w-8 text-muted-foreground" />
137
+ <div className="flex h-screen w-full items-center justify-center bg-background">
138
+ <Spinner className="size-8 text-muted-foreground" />
152
139
  </div>
153
140
  );
154
141
  }
@@ -168,60 +155,103 @@ function formatDateTime(iso?: string | null): string {
168
155
  }
169
156
  }
170
157
 
171
- const REVALIDATE_INTERVAL_MS = 5_000;
172
- const REVALIDATE_MAX_DURATION_MS = 30 * 60 * 1000;
173
-
174
- /**
175
- * Silent client-side revalidation while a meeting is still live or its notes
176
- * haven't landed yet — the loader is SSR-only otherwise (M9), so a share link
177
- * opened mid-meeting would never update without a manual reload. Polls the
178
- * same access-checked loader (via useRevalidator, not a new endpoint) every
179
- * 5s and stops once notes arrive or after 30 minutes.
180
- */
181
- function useMeetingShareRevalidation(meeting: ShareMeetingData | null) {
182
- const revalidator = useRevalidator();
183
- const startedAtRef = useRef<number | null>(null);
184
-
185
- const isLive = !!meeting && !!meeting.actualStart && !meeting.actualEnd;
158
+ function shouldPollMeeting(meeting: PublicMeeting): boolean {
159
+ const isLive = !!meeting.actualStart && !meeting.actualEnd;
186
160
  const transcriptPending =
187
- meeting?.transcriptStatus === "in_progress" ||
188
- meeting?.transcriptStatus === "pending";
161
+ meeting.transcriptStatus === "in_progress" ||
162
+ meeting.transcriptStatus === "pending" ||
163
+ meeting.transcript?.status === "pending";
189
164
  const notesAbsentWhileReady =
190
- !!meeting &&
191
165
  meeting.transcriptStatus === "ready" &&
192
166
  !meeting.summaryMd &&
193
167
  meeting.bullets.length === 0 &&
194
168
  meeting.actionItems.length === 0;
195
- const shouldPoll = isLive || transcriptPending || notesAbsentWhileReady;
169
+ return isLive || transcriptPending || notesAbsentWhileReady;
170
+ }
196
171
 
197
- useEffect(() => {
198
- if (!shouldPoll) {
199
- startedAtRef.current = null;
200
- return;
201
- }
202
- if (startedAtRef.current == null) startedAtRef.current = Date.now();
203
- const interval = window.setInterval(() => {
204
- if (
205
- Date.now() - (startedAtRef.current ?? Date.now()) >
206
- REVALIDATE_MAX_DURATION_MS
207
- ) {
208
- window.clearInterval(interval);
209
- return;
210
- }
211
- if (revalidator.state === "idle") revalidator.revalidate();
212
- }, REVALIDATE_INTERVAL_MS);
213
- return () => window.clearInterval(interval);
214
- }, [shouldPoll, revalidator]);
172
+ function transcriptCopyText(
173
+ transcript: PublicMeetingTranscript,
174
+ meLabel: string,
175
+ themLabel: string,
176
+ ): string {
177
+ if (transcript.segments.length > 0) {
178
+ return transcript.segments
179
+ .map((segment) => {
180
+ const speaker =
181
+ segment.speaker || (segment.source === "mic" ? meLabel : themLabel);
182
+ return `${speaker}: ${segment.text}`;
183
+ })
184
+ .join("\n");
185
+ }
186
+ return transcript.fullText?.trim() ?? "";
215
187
  }
216
188
 
189
+ const REVALIDATE_INTERVAL_MS = 5_000;
190
+ const REVALIDATE_MAX_DURATION_MS = 30 * 60 * 1000;
191
+
217
192
  export default function ShareMeetingRoute() {
218
193
  const t = useT();
219
- const data = useLoaderData<LoaderData>();
220
- useMeetingShareRevalidation(data.meeting);
194
+ const loaderData = useLoaderData<LoaderData>();
195
+ const { meetingId } = useParams<{ meetingId: string }>();
196
+ const { session, isLoading: sessionLoading } = useSession();
197
+ const pollingStartedAtRef = useRef<number | null>(null);
198
+ const [transcriptCopied, setTranscriptCopied] = useState(false);
199
+ const initialMeetingResult: PublicMeetingResult | undefined =
200
+ loaderData.meeting
201
+ ? {
202
+ ok: true,
203
+ status: 200,
204
+ data: { meeting: loaderData.meeting, viewer: null },
205
+ }
206
+ : undefined;
221
207
 
222
- if (!data.meeting) {
208
+ const meetingQuery = useQuery({
209
+ queryKey: [
210
+ "public-meeting",
211
+ meetingId,
212
+ session?.email ?? null,
213
+ session?.orgId ?? null,
214
+ ],
215
+ queryFn: ({ signal }) => fetchPublicMeeting(meetingId ?? "", { signal }),
216
+ enabled: !!meetingId && !sessionLoading,
217
+ initialData: initialMeetingResult,
218
+ refetchInterval: (query) => {
219
+ const result = query.state.data;
220
+ const payload = result?.data;
221
+ const meeting =
222
+ result?.ok && payload && "meeting" in payload ? payload.meeting : null;
223
+ if (!meeting || !shouldPollMeeting(meeting)) {
224
+ pollingStartedAtRef.current = null;
225
+ return false;
226
+ }
227
+ if (pollingStartedAtRef.current == null) {
228
+ pollingStartedAtRef.current = Date.now();
229
+ }
230
+ return Date.now() - pollingStartedAtRef.current <
231
+ REVALIDATE_MAX_DURATION_MS
232
+ ? REVALIDATE_INTERVAL_MS
233
+ : false;
234
+ },
235
+ refetchIntervalInBackground: false,
236
+ });
237
+
238
+ const payload = meetingQuery.data?.data;
239
+ const meeting =
240
+ meetingQuery.data?.ok && payload && "meeting" in payload
241
+ ? payload.meeting
242
+ : null;
243
+
244
+ useEffect(() => {
245
+ if (meeting?.title) document.title = `${meeting.title} · Clips`;
246
+ }, [meeting?.title]);
247
+
248
+ if (!meeting && (sessionLoading || meetingQuery.isLoading)) {
249
+ return <HydrateFallback />;
250
+ }
251
+
252
+ if (!meeting) {
223
253
  return (
224
- <div className="flex flex-col items-center justify-center h-screen w-full gap-3 text-center px-4 bg-background">
254
+ <div className="flex h-screen w-full flex-col items-center justify-center gap-3 bg-background px-4 text-center">
225
255
  <p className="text-sm text-muted-foreground">
226
256
  {t("shareMeeting.unavailable")}
227
257
  </p>
@@ -230,41 +260,64 @@ export default function ShareMeetingRoute() {
230
260
  );
231
261
  }
232
262
 
233
- const { meeting } = data;
234
- const { bullets } = meeting;
235
263
  const hasNotes =
236
- !!meeting.summaryMd || bullets.length > 0 || meeting.actionItems.length > 0;
237
-
264
+ !!meeting.summaryMd ||
265
+ meeting.bullets.length > 0 ||
266
+ meeting.actionItems.length > 0;
238
267
  const attendees: AttendeeStackParticipant[] = meeting.participants.map(
239
- (p) => ({ email: p.email, name: p.name ?? undefined }),
268
+ (participant) => ({
269
+ email: participant.email,
270
+ name: participant.name ?? undefined,
271
+ }),
240
272
  );
273
+ const transcript = meeting.transcript;
274
+ const copyText = transcript
275
+ ? transcriptCopyText(
276
+ transcript,
277
+ t("transcriptBubbles.me"),
278
+ t("transcriptBubbles.them"),
279
+ )
280
+ : "";
281
+
282
+ const handleCopyTranscript = async () => {
283
+ if (!copyText) return;
284
+ try {
285
+ await navigator.clipboard.writeText(copyText);
286
+ setTranscriptCopied(true);
287
+ toast.success(t("shareMeeting.transcriptCopied"));
288
+ window.setTimeout(() => setTranscriptCopied(false), 1_500);
289
+ } catch {
290
+ toast.error(t("shareMeeting.copyTranscriptFailed"));
291
+ }
292
+ };
241
293
 
242
294
  return (
243
295
  <div className="min-h-screen bg-background">
244
296
  <header className="border-b border-border">
245
- <div className="max-w-2xl mx-auto flex items-center gap-3 px-4 py-3">
297
+ <div className="mx-auto flex max-w-2xl items-center gap-3 px-4 py-3">
246
298
  <h1 className="min-w-0 flex-1 truncate text-sm font-medium">
247
299
  {meeting.title || t("meetingDetail.untitledMeeting")}
248
300
  </h1>
249
301
  <Button variant="ghost" size="sm" asChild className="shrink-0">
250
302
  <a href={appPath("/")} className="gap-1.5">
251
303
  {t("shareMeeting.tryClips")}
252
- <IconExternalLink className="h-3.5 w-3.5" />
304
+ <IconExternalLink className="size-3.5" />
253
305
  </a>
254
306
  </Button>
255
307
  </div>
256
308
  </header>
257
- <div className="max-w-2xl mx-auto px-4 py-8">
258
- <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground mb-8">
309
+
310
+ <main className="mx-auto max-w-2xl px-4 py-8">
311
+ <div className="mb-8 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
259
312
  {meeting.scheduledStart && (
260
313
  <span className="inline-flex items-center gap-1">
261
- <IconCalendar className="h-3.5 w-3.5" />
314
+ <IconCalendar className="size-3.5" />
262
315
  {formatDateTime(meeting.scheduledStart)}
263
316
  </span>
264
317
  )}
265
318
  {attendees.length > 0 && (
266
319
  <span className="inline-flex items-center gap-1.5">
267
- <IconUsers className="h-3.5 w-3.5" />
320
+ <IconUsers className="size-3.5" />
268
321
  <AttendeeStack participants={attendees} max={5} size="xs" />
269
322
  <span>
270
323
  {t("shareMeeting.attendees", { count: attendees.length })}
@@ -274,37 +327,37 @@ export default function ShareMeetingRoute() {
274
327
  </div>
275
328
 
276
329
  {!hasNotes ? (
277
- <p className="text-sm text-muted-foreground italic">
330
+ <p className="text-sm italic text-muted-foreground">
278
331
  {t("shareMeeting.noAiNotes")}
279
332
  </p>
280
333
  ) : (
281
334
  <div className="space-y-8">
282
335
  {meeting.summaryMd && (
283
336
  <section>
284
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
285
- <IconWand className="h-3.5 w-3.5" />
337
+ <div className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
338
+ <IconWand className="size-3.5" />
286
339
  {t("shareMeeting.summary")}
287
340
  </div>
288
- <div className="text-sm leading-relaxed whitespace-pre-wrap">
341
+ <div className="whitespace-pre-wrap text-sm leading-relaxed">
289
342
  {meeting.summaryMd}
290
343
  </div>
291
344
  </section>
292
345
  )}
293
346
 
294
- {bullets.length > 0 && (
347
+ {meeting.bullets.length > 0 && (
295
348
  <section>
296
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
297
- <IconWand className="h-3.5 w-3.5" />
349
+ <div className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
350
+ <IconWand className="size-3.5" />
298
351
  {t("shareMeeting.keyPoints")}
299
352
  </div>
300
353
  <ul className="space-y-2">
301
- {bullets.map((b, i) => (
354
+ {meeting.bullets.map((bullet, index) => (
302
355
  <li
303
- key={i}
356
+ key={index}
304
357
  className="flex gap-2 text-sm leading-relaxed text-muted-foreground"
305
358
  >
306
359
  <span>•</span>
307
- <span className="flex-1">{b.text}</span>
360
+ <span className="flex-1">{bullet.text}</span>
308
361
  </li>
309
362
  ))}
310
363
  </ul>
@@ -313,17 +366,20 @@ export default function ShareMeetingRoute() {
313
366
 
314
367
  {meeting.actionItems.length > 0 && (
315
368
  <section>
316
- <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">
317
- <IconListCheck className="h-3.5 w-3.5" />
369
+ <div className="mb-3 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
370
+ <IconListCheck className="size-3.5" />
318
371
  {t("shareMeeting.actionItems")}
319
372
  </div>
320
373
  <ul className="space-y-2">
321
- {meeting.actionItems.map((item, i) => (
322
- <li key={i} className="flex gap-2 text-sm leading-relaxed">
374
+ {meeting.actionItems.map((item) => (
375
+ <li
376
+ key={item.id}
377
+ className="flex gap-2 text-sm leading-relaxed"
378
+ >
323
379
  <span
324
380
  className={
325
381
  item.completedAt
326
- ? "line-through text-muted-foreground"
382
+ ? "text-muted-foreground line-through"
327
383
  : ""
328
384
  }
329
385
  >
@@ -342,10 +398,60 @@ export default function ShareMeetingRoute() {
342
398
  </div>
343
399
  )}
344
400
 
401
+ {transcript && (
402
+ <section className="mt-10">
403
+ <div className="mb-3 flex items-center justify-between gap-3">
404
+ <div className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
405
+ <IconNotes className="size-3.5" />
406
+ {t("shareMeeting.transcript")}
407
+ </div>
408
+ {copyText && (
409
+ <Button
410
+ type="button"
411
+ variant="ghost"
412
+ size="sm"
413
+ className="h-7 gap-1.5"
414
+ onClick={handleCopyTranscript}
415
+ >
416
+ {transcriptCopied ? (
417
+ <IconCheck className="size-3.5" />
418
+ ) : (
419
+ <IconCopy className="size-3.5" />
420
+ )}
421
+ {t("shareMeeting.copyTranscript")}
422
+ </Button>
423
+ )}
424
+ </div>
425
+ {transcript.segments.length > 0 ? (
426
+ <div className="flex h-[36rem] flex-col overflow-hidden rounded-lg border border-border">
427
+ <TranscriptBubbles
428
+ segments={transcript.segments}
429
+ isLive={false}
430
+ recordingId={null}
431
+ onSeek={() => {}}
432
+ />
433
+ </div>
434
+ ) : transcript.fullText ? (
435
+ <div className="whitespace-pre-wrap rounded-lg border border-border p-4 text-sm leading-relaxed">
436
+ {transcript.fullText}
437
+ </div>
438
+ ) : (
439
+ <div className="flex min-h-40 flex-col overflow-hidden rounded-lg border border-border">
440
+ <TranscriptBubbles
441
+ segments={[]}
442
+ isLive={false}
443
+ recordingId={null}
444
+ onSeek={() => {}}
445
+ />
446
+ </div>
447
+ )}
448
+ </section>
449
+ )}
450
+
345
451
  <div className="mt-12">
346
452
  <PoweredByBadge />
347
453
  </div>
348
- </div>
454
+ </main>
349
455
  </div>
350
456
  );
351
457
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: added
3
+ date: 2026-07-20
4
+ ---
5
+
6
+ Meeting share links can include the full transcript with an explicit privacy control.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-20
4
+ ---
5
+
6
+ Recording retries the default Mac microphone when a saved input is no longer available.
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-07-20
4
+ ---
5
+
6
+ Shared Clips now tell agents to wait while uploads and transcription finish.
@@ -1,33 +1 @@
1
- const ZOOM_JOIN_PATH = /^\/j\/(\d+)\/?$/;
2
-
3
- function isZoomMeetingHost(hostname: string): boolean {
4
- return hostname === "zoom.us" || hostname.endsWith(".zoom.us");
5
- }
6
-
7
- /**
8
- * Resolve a calendar join URL to the native desktop-app URI when the provider
9
- * supports one. Unknown providers and unsupported Zoom URL shapes stay on
10
- * their original HTTPS URL.
11
- */
12
- export function resolveDesktopMeetingJoinUrl(joinUrl: string): string {
13
- try {
14
- const url = new URL(joinUrl);
15
- if (url.protocol !== "https:" || !isZoomMeetingHost(url.hostname)) {
16
- return joinUrl;
17
- }
18
-
19
- const meetingNumber = ZOOM_JOIN_PATH.exec(url.pathname)?.[1];
20
- if (!meetingNumber) return joinUrl;
21
-
22
- const params = new URLSearchParams({
23
- action: "join",
24
- confno: meetingNumber,
25
- });
26
- const passcode = url.searchParams.get("pwd");
27
- if (passcode) params.set("pwd", passcode);
28
-
29
- return `zoommtg://${url.hostname}/join?${params.toString()}`;
30
- } catch {
31
- return joinUrl;
32
- }
33
- }
1
+ export { resolveNativeMeetingJoinUrl as resolveDesktopMeetingJoinUrl } from "../../../shared/meeting-join-url";