@agent-native/core 0.168.6 → 0.168.9

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 (87) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/mutate-dashboard.ts +8 -1
  3. package/corpus/templates/calendar/actions/delete-event.ts +34 -18
  4. package/corpus/templates/calendar/actions/delete-events.ts +13 -1
  5. package/corpus/templates/calendar/app/components/calendar/EventDetailPanel.tsx +22 -3
  6. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +19 -0
  7. package/corpus/templates/calendar/server/lib/google-api.ts +18 -1
  8. package/corpus/templates/clips/actions/finalize-meeting.ts +16 -0
  9. package/corpus/templates/clips/actions/list-meetings.ts +1 -0
  10. package/corpus/templates/clips/actions/list-recordings.ts +14 -0
  11. package/corpus/templates/clips/actions/sync-calendars.ts +10 -29
  12. package/corpus/templates/clips/app/components/library/recording-card.tsx +2 -2
  13. package/corpus/templates/clips/app/components/player/delete-recording-menu.tsx +20 -3
  14. package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
  15. package/corpus/templates/clips/app/routes/r.$recordingId.tsx +12 -5
  16. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +28 -12
  17. package/corpus/templates/clips/desktop/src/lib/meeting-stop.ts +16 -0
  18. package/corpus/templates/clips/server/lib/calendar-event-meetings.ts +19 -9
  19. package/corpus/templates/factory/.agents/skills/factory-graphs/SKILL.md +5 -0
  20. package/corpus/templates/factory/AGENTS.md +12 -22
  21. package/corpus/templates/factory/README.md +11 -8
  22. package/corpus/templates/factory/actions/approve-factory-item.ts +35 -8
  23. package/corpus/templates/factory/actions/babysit-agent-native-pull-request.ts +24 -9
  24. package/corpus/templates/factory/actions/create-factory.ts +211 -0
  25. package/corpus/templates/factory/actions/evaluate-triage-item.ts +29 -5
  26. package/corpus/templates/factory/actions/get-factory-graph.ts +1 -1
  27. package/corpus/templates/factory/actions/get-slack-feedback-context.ts +20 -11
  28. package/corpus/templates/factory/actions/get-triage-config.ts +13 -12
  29. package/corpus/templates/factory/actions/get-triage-item.ts +28 -6
  30. package/corpus/templates/factory/actions/govern-agent-native-pull-request.ts +33 -17
  31. package/corpus/templates/factory/actions/ingest-github-observation.ts +10 -4
  32. package/corpus/templates/factory/actions/list-factory-audit.ts +11 -4
  33. package/corpus/templates/factory/actions/list-factory-automations.ts +34 -12
  34. package/corpus/templates/factory/actions/list-triage-items.ts +11 -3
  35. package/corpus/templates/factory/actions/list-triage-rules.ts +11 -4
  36. package/corpus/templates/factory/actions/poll-github-sources.ts +24 -10
  37. package/corpus/templates/factory/actions/poll-sentry-errors.ts +26 -11
  38. package/corpus/templates/factory/actions/poll-slack-channel.ts +31 -13
  39. package/corpus/templates/factory/actions/reconcile-triage-run.ts +59 -9
  40. package/corpus/templates/factory/actions/record-triage-feedback.ts +14 -3
  41. package/corpus/templates/factory/actions/run-factory-automation.ts +12 -2
  42. package/corpus/templates/factory/actions/save-factory-automation.ts +82 -21
  43. package/corpus/templates/factory/actions/save-factory-graph.ts +10 -1
  44. package/corpus/templates/factory/actions/save-triage-config.ts +90 -36
  45. package/corpus/templates/factory/actions/save-triage-rule.ts +16 -3
  46. package/corpus/templates/factory/actions/start-builder-for-item.ts +28 -20
  47. package/corpus/templates/factory/actions/suggest-factory-rules.ts +11 -4
  48. package/corpus/templates/factory/actions/view-screen.ts +22 -30
  49. package/corpus/templates/factory/app/components/factory/FactorySettingsView.tsx +7 -2
  50. package/corpus/templates/factory/app/components/factory/FactoryWorkspaceActions.tsx +7 -22
  51. package/corpus/templates/factory/app/components/factory/NewFactoryForm.tsx +260 -0
  52. package/corpus/templates/factory/app/components/layout/Layout.tsx +1 -1
  53. package/corpus/templates/factory/app/components/layout/Sidebar.tsx +2 -1
  54. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +3 -3
  55. package/corpus/templates/factory/app/i18n/en-US.ts +18 -1
  56. package/corpus/templates/factory/app/routes/factory.tsx +78 -62
  57. package/corpus/templates/factory/app/routes/{factory-settings.tsx → new-factory.tsx} +12 -9
  58. package/corpus/templates/factory/server/db/schema.ts +18 -3
  59. package/corpus/templates/factory/server/factory-graph/contracts.ts +24 -0
  60. package/corpus/templates/factory/server/factory-graph/store.ts +16 -9
  61. package/corpus/templates/factory/server/lib/factory-automation-plan.ts +136 -0
  62. package/corpus/templates/factory/server/lib/factory-automation-repair.ts +31 -0
  63. package/corpus/templates/factory/server/lib/factory-scope.ts +416 -0
  64. package/corpus/templates/factory/server/lib/require-factory-automation.ts +11 -0
  65. package/corpus/templates/factory/server/plugins/agent-chat.ts +4 -2
  66. package/corpus/templates/factory/server/plugins/factory-migrations.ts +80 -0
  67. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +142 -43
  68. package/corpus/templates/factory/server/triage/audit.ts +4 -0
  69. package/corpus/templates/factory/server/triage/ids.ts +4 -0
  70. package/dist/cli/native-dependencies.js +4 -1
  71. package/dist/client/AgentPanel.d.ts +5 -1
  72. package/dist/client/AgentPanel.js +9 -4
  73. package/dist/client/agent-chat/index.d.ts +1 -1
  74. package/dist/client/agent-chat/index.js +1 -1
  75. package/dist/client/chat-first/apps-rail.js +6 -6
  76. package/dist/client/chat-first/primary-nav.js +14 -6
  77. package/dist/client/integrations/IntegrationsPanel.d.ts +15 -0
  78. package/dist/client/integrations/IntegrationsPanel.js +29 -21
  79. package/dist/client/integrations/index.d.ts +1 -1
  80. package/dist/client/integrations/index.js +1 -1
  81. package/dist/client/resources/McpIntegrationDialog.js +1 -1
  82. package/dist/collab/routes.d.ts +1 -1
  83. package/dist/notifications/routes.d.ts +1 -1
  84. package/dist/progress/routes.d.ts +1 -1
  85. package/dist/resources/handlers.d.ts +3 -3
  86. package/dist/server/ssr-handler.js +19 -1
  87. package/package.json +1 -1
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: 8496
34
+ - template files: 8502
@@ -49,6 +49,13 @@ const mutationTargetSchema = {
49
49
  .describe("Where in rowNumber to place the panel. Defaults to end."),
50
50
  };
51
51
 
52
+ const insertPanelSchema = z
53
+ .record(z.string(), z.unknown())
54
+ .refine(
55
+ (panel) => typeof panel.id === "string" && panel.id.trim().length > 0,
56
+ { message: "panel.id must be a non-empty string" },
57
+ );
58
+
52
59
  const mutationOperationSchema = z.discriminatedUnion("op", [
53
60
  z.object({
54
61
  op: z.literal("movePanels"),
@@ -72,7 +79,7 @@ const mutationOperationSchema = z.discriminatedUnion("op", [
72
79
  }),
73
80
  z.object({
74
81
  op: z.literal("insertPanel"),
75
- panel: z.record(z.string(), z.unknown()),
82
+ panel: insertPanelSchema,
76
83
  ...mutationTargetSchema,
77
84
  }),
78
85
  z.object({
@@ -5,6 +5,7 @@ import {
5
5
  normalizeGuestNotificationMessage,
6
6
  sendEventGuestNotificationNote,
7
7
  } from "../server/lib/event-guest-notifications.js";
8
+ import { isGoogleNotFoundError } from "../server/lib/google-api.js";
8
9
  import * as googleCalendar from "../server/lib/google-calendar.js";
9
10
  import {
10
11
  cliBoolean,
@@ -71,25 +72,39 @@ export default defineAction({
71
72
  ? "none"
72
73
  : (args.sendUpdates ?? (shouldNotifyGuests ? "all" : "none")),
73
74
  };
74
- const eventForNotification = shouldNotifyGuests
75
- ? await googleCalendar.getEvent(googleEventId, {
76
- ownerEmail,
77
- accountEmail,
78
- })
79
- : undefined;
75
+ let eventForNotification;
76
+ try {
77
+ eventForNotification = shouldNotifyGuests
78
+ ? await googleCalendar.getEvent(googleEventId, {
79
+ ownerEmail,
80
+ accountEmail,
81
+ })
82
+ : undefined;
80
83
 
81
- if (args.removeOnly) {
82
- await googleCalendar.removeEventFromCalendar(
83
- googleEventId,
84
- { ownerEmail, accountEmail },
85
- options,
86
- );
87
- } else {
88
- await googleCalendar.deleteEvent(
89
- googleEventId,
90
- { ownerEmail, accountEmail },
91
- options,
92
- );
84
+ if (args.removeOnly) {
85
+ await googleCalendar.removeEventFromCalendar(
86
+ googleEventId,
87
+ { ownerEmail, accountEmail },
88
+ options,
89
+ );
90
+ } else {
91
+ await googleCalendar.deleteEvent(
92
+ googleEventId,
93
+ { ownerEmail, accountEmail },
94
+ options,
95
+ );
96
+ }
97
+ } catch (error) {
98
+ if (!isGoogleNotFoundError(error)) throw error;
99
+
100
+ return {
101
+ success: true,
102
+ alreadyAbsent: true,
103
+ id: `google-${googleEventId}`,
104
+ accountEmail,
105
+ scope: args.scope,
106
+ removedOnly: args.removeOnly ?? false,
107
+ };
93
108
  }
94
109
 
95
110
  const guestNotification =
@@ -105,6 +120,7 @@ export default defineAction({
105
120
 
106
121
  return {
107
122
  success: true,
123
+ alreadyAbsent: false,
108
124
  id: `google-${googleEventId}`,
109
125
  accountEmail,
110
126
  scope: args.scope,
@@ -10,6 +10,7 @@ import {
10
10
  type WeekdayName,
11
11
  } from "../server/lib/event-weekday.js";
12
12
  import { zonedDateTimeToUtcIso } from "../server/lib/find-time.js";
13
+ import { isGoogleNotFoundError } from "../server/lib/google-api.js";
13
14
  import * as googleCalendar from "../server/lib/google-calendar.js";
14
15
  import type { CalendarEvent } from "../shared/api.js";
15
16
  import {
@@ -36,7 +37,7 @@ const MAX_MATCHED_EVENTS = 200;
36
37
  * firing every delete at once and turning a clean batch into retries. */
37
38
  const DELETE_CONCURRENCY = 4;
38
39
 
39
- type Outcome = "deleted" | "matched" | "skipped" | "failed";
40
+ type Outcome = "deleted" | "already_absent" | "matched" | "skipped" | "failed";
40
41
 
41
42
  interface EventResult {
42
43
  id: string;
@@ -449,6 +450,7 @@ export default defineAction({
449
450
  ...summaryBase,
450
451
  dryRun: true,
451
452
  deleted: 0,
453
+ alreadyAbsent: 0,
452
454
  failed: 0,
453
455
  skipped: results.length,
454
456
  events: [...targets.map((target) => target.display), ...results],
@@ -483,6 +485,13 @@ export default defineAction({
483
485
  }
484
486
  return { ...target.display, outcome: "deleted" };
485
487
  } catch (error) {
488
+ if (isGoogleNotFoundError(error)) {
489
+ return {
490
+ ...target.display,
491
+ outcome: "already_absent",
492
+ reason: "Already absent from Google Calendar",
493
+ };
494
+ }
486
495
  return {
487
496
  ...target.display,
488
497
  outcome: "failed",
@@ -498,6 +507,9 @@ export default defineAction({
498
507
  dryRun: false,
499
508
  deleted: deleteResults.filter((entry) => entry.outcome === "deleted")
500
509
  .length,
510
+ alreadyAbsent: deleteResults.filter(
511
+ (entry) => entry.outcome === "already_absent",
512
+ ).length,
501
513
  failed: deleteResults.filter((entry) => entry.outcome === "failed")
502
514
  .length,
503
515
  skipped: results.length,
@@ -35,6 +35,7 @@ import {
35
35
  } from "@/components/ui/tooltip";
36
36
  import { useUpdateEvent } from "@/hooks/use-events";
37
37
  import { useViewPreferences } from "@/hooks/use-view-preferences";
38
+ import { getDisplayDateInTimezone } from "@/lib/calendar-timezone";
38
39
  import { getEditableEventTitle } from "@/lib/event-form-utils";
39
40
  import { isOutOfOfficeEvent } from "@/lib/out-of-office";
40
41
  import { cn } from "@/lib/utils";
@@ -447,15 +448,33 @@ export function EventDetailPanel({
447
448
  ) : (
448
449
  <>
449
450
  <span className="text-foreground">
450
- {format(parseISO(event.start), "h:mm a")}
451
+ {format(
452
+ getDisplayDateInTimezone(
453
+ event.start,
454
+ event.startTimeZone ?? event.endTimeZone,
455
+ ),
456
+ "h:mm a",
457
+ )}
451
458
  {" → "}
452
- {format(parseISO(event.end), "h:mm a")}
459
+ {format(
460
+ getDisplayDateInTimezone(
461
+ event.end,
462
+ event.endTimeZone ?? event.startTimeZone,
463
+ ),
464
+ "h:mm a",
465
+ )}
453
466
  </span>
454
467
  <span className="ml-2 text-muted-foreground/70">
455
468
  {formatDuration(event.start, event.end)}
456
469
  </span>
457
470
  <div className="mt-0.5 text-muted-foreground">
458
- {format(parseISO(event.start), "EEE MMM d")}
471
+ {format(
472
+ getDisplayDateInTimezone(
473
+ event.start,
474
+ event.startTimeZone ?? event.endTimeZone,
475
+ ),
476
+ "EEE MMM d",
477
+ )}
459
478
  </div>
460
479
  </>
461
480
  )}
@@ -118,6 +118,25 @@ export function getDateTimePartsInTimezone(
118
118
  return dateTimeParts(value, normalizeTimezone(timezone));
119
119
  }
120
120
 
121
+ /**
122
+ * Build a local Date carrying an event's wall-clock fields so date-fns can
123
+ * format those fields without converting them through the browser timezone.
124
+ * The result is for display only; it must not be used for date arithmetic.
125
+ */
126
+ export function getDisplayDateInTimezone(
127
+ value: Date | string,
128
+ timezone?: string | null,
129
+ ): Date {
130
+ const parsed = value instanceof Date ? value : new Date(value);
131
+ if (!timezone) return parsed;
132
+
133
+ const parts = getDateTimePartsInTimezone(value, timezone);
134
+ if (!parts) return parsed;
135
+
136
+ const [year, month, day] = parts.date.split("-").map(Number);
137
+ return new Date(year, month - 1, day, parts.hour, parts.minute, parts.second);
138
+ }
139
+
121
140
  export function getDateKeyInTimezone(
122
141
  value: Date | string,
123
142
  timezone: string,
@@ -113,6 +113,23 @@ export function createOAuth2Client(
113
113
  // Authenticated fetch helper
114
114
  // ---------------------------------------------------------------------------
115
115
 
116
+ export class GoogleApiError extends Error {
117
+ readonly status: number;
118
+
119
+ constructor(status: number, message: string) {
120
+ super(`Google API error (${status}): ${message}`);
121
+ this.name = "GoogleApiError";
122
+ this.status = status;
123
+ }
124
+ }
125
+
126
+ export function isGoogleNotFoundError(error: unknown): boolean {
127
+ return (
128
+ (error instanceof GoogleApiError && error.status === 404) ||
129
+ (error instanceof Error && /^Google API error \(404\):/.test(error.message))
130
+ );
131
+ }
132
+
116
133
  export async function googleFetch(
117
134
  url: string,
118
135
  accessToken: string,
@@ -133,7 +150,7 @@ export async function googleFetch(
133
150
  (data as any)?.error?.message ||
134
151
  (data as any)?.error_description ||
135
152
  res.statusText;
136
- throw new Error(`Google API error (${res.status}): ${msg}`);
153
+ throw new GoogleApiError(res.status, msg);
137
154
  }
138
155
 
139
156
  return data;
@@ -27,6 +27,17 @@ import { loadAgentsMdContext } from "./lib/agents-md-context.js";
27
27
  // handles crashed processes without stealing an active text-model run.
28
28
  const PENDING_STALE_MS = 2 * 60 * 1000;
29
29
 
30
+ export function skippedFinalizeResult(meetingId: string) {
31
+ return {
32
+ meetingId,
33
+ summaryMd: "",
34
+ bullets: [],
35
+ actionItems: [],
36
+ provider: null,
37
+ skipped: "no-transcript" as const,
38
+ };
39
+ }
40
+
30
41
  function trimContextValue(value: string, maxChars: number): string {
31
42
  const trimmed = value.replace(/\0/g, "").trim();
32
43
  if (trimmed.length <= maxChars) return trimmed;
@@ -80,6 +91,11 @@ export default defineAction({
80
91
  updatedAt: nowIso,
81
92
  })
82
93
  .where(eq(schema.meetings.id, args.meetingId));
94
+ // The desktop stop path can finish before its final transcript flush
95
+ // lands. That is a completed recording without notes, not an action
96
+ // failure worth returning as HTTP 500; the explicit regenerate flow
97
+ // still receives the actionable error below.
98
+ if (!args.force) return skippedFinalizeResult(args.meetingId);
83
99
  throw new Error(
84
100
  `Cannot finalize meeting ${args.meetingId} — no transcript text available yet.`,
85
101
  );
@@ -372,6 +372,7 @@ export default defineAction({
372
372
  await recordCalendarFetchError(
373
373
  account,
374
374
  new Error("Token refresh failed"),
375
+ { needsReauth: true },
375
376
  ),
376
377
  );
377
378
  continue;
@@ -14,6 +14,7 @@ import {
14
14
  } from "drizzle-orm";
15
15
  import { z } from "zod";
16
16
 
17
+ import { effectiveDuration, parseEdits } from "../app/lib/timestamp-mapping.js";
17
18
  import { getDb, schema } from "../server/db/index.js";
18
19
  import {
19
20
  agentRecordingAccessFilter,
@@ -303,6 +304,9 @@ export default defineAction({
303
304
  thumbnailUrl: schema.recordings.thumbnailUrl,
304
305
  animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl,
305
306
  durationMs: schema.recordings.durationMs,
307
+ // Needed to derive the edited-length badge below; dropped before the
308
+ // response is built so the raw edits blob never reaches the client.
309
+ editsJson: schema.recordings.editsJson,
306
310
  status: schema.recordings.status,
307
311
  uploadProgress: schema.recordings.uploadProgress,
308
312
  failureReason: schema.recordings.failureReason,
@@ -417,7 +421,17 @@ export default defineAction({
417
421
  description: r.description,
418
422
  thumbnailUrl: r.thumbnailUrl,
419
423
  animatedThumbnailUrl: r.animatedThumbnailUrl,
424
+ // Raw source length. StitchManager sums this across queued
425
+ // recordings to size the concatenated export, which always
426
+ // includes each source's full untrimmed media — trims are applied
427
+ // at export/playback time, not by dropping bytes from the source.
420
428
  durationMs: r.durationMs,
429
+ // Edited length, not the original recorded length — matches what
430
+ // the clip page itself shows once trims/cuts are applied.
431
+ effectiveDurationMs: effectiveDuration(
432
+ r.durationMs,
433
+ parseEdits(r.editsJson),
434
+ ),
421
435
  status: r.status,
422
436
  uploadProgress: r.uploadProgress,
423
437
  failureReason: r.failureReason,
@@ -23,6 +23,8 @@ import { z } from "zod";
23
23
 
24
24
  import { getDb, schema } from "../server/db/index.js";
25
25
  import {
26
+ recordCalendarFetchError,
27
+ recordCalendarFetchSuccess,
26
28
  resolveCalendarAccessToken,
27
29
  shouldMarkNeedsReauth,
28
30
  } from "../server/lib/calendar-event-meetings.js";
@@ -98,24 +100,11 @@ export default defineAction({
98
100
  // sync error via shouldMarkNeedsReauth without flipping status.
99
101
  const accessToken = await resolveCalendarAccessToken(account);
100
102
  if (!accessToken) {
101
- // Fix 10: isolate this account's needs-reauth write in its own
102
- // try/catch so a downstream throw on a different account doesn't
103
- // leave the flag unpersisted.
104
- try {
105
- await db
106
- .update(schema.calendarAccounts)
107
- .set({
108
- status: "needs-reauth",
109
- lastSyncError: "Token refresh failed — reconnect required.",
110
- updatedAt: new Date().toISOString(),
111
- })
112
- .where(eq(schema.calendarAccounts.id, account.id));
113
- } catch (writeErr: any) {
114
- console.warn(
115
- `[sync-calendars] failed to flag account ${account.id} as needs-reauth:`,
116
- writeErr?.message ?? writeErr,
117
- );
118
- }
103
+ await recordCalendarFetchError(
104
+ account,
105
+ new Error("Token refresh failed"),
106
+ { needsReauth: true },
107
+ );
119
108
  errors.push({
120
109
  accountId: account.id,
121
110
  error: "needs-reauth",
@@ -206,18 +195,10 @@ export default defineAction({
206
195
  perAccountEvents += 1;
207
196
  }
208
197
 
209
- // Fix 10: isolate the success-path status write so other accounts'
210
- // needs-reauth flags can't be wiped by a later account-level throw.
198
+ // Keep the success write isolated and guarded so an in-flight sweep
199
+ // cannot overwrite a concurrent needs-reauth decision.
211
200
  try {
212
- await db
213
- .update(schema.calendarAccounts)
214
- .set({
215
- lastSyncedAt: new Date().toISOString(),
216
- lastSyncError: null,
217
- status: "connected",
218
- updatedAt: new Date().toISOString(),
219
- })
220
- .where(eq(schema.calendarAccounts.id, account.id));
201
+ await recordCalendarFetchSuccess(account);
221
202
  } catch (writeErr: any) {
222
203
  console.warn(
223
204
  `[sync-calendars] failed to update account ${account.id} after success:`,
@@ -98,8 +98,8 @@ export function RecordingCard({
98
98
  const pendingTrashRef = useRef(false);
99
99
 
100
100
  const duration = useMemo(
101
- () => formatDuration(recording.durationMs),
102
- [recording.durationMs],
101
+ () => formatDuration(recording.effectiveDurationMs),
102
+ [recording.effectiveDurationMs],
103
103
  );
104
104
  const relative = useMemo(() => {
105
105
  const date = new Date(recording.createdAt);
@@ -51,6 +51,7 @@ export function RecordingOptionsMenu({
51
51
  const [open, setOpen] = useState(false);
52
52
  const [menuOpen, setMenuOpen] = useState(false);
53
53
  const deletedWhileOpenRef = useRef(false);
54
+ const pendingDeleteConfirmRef = useRef(false);
54
55
  const showDownload = canDownload && Boolean(onDownload);
55
56
  const showDelete = canDelete;
56
57
  const trashRecording = useActionMutation<any, { id: string }>(
@@ -99,7 +100,22 @@ export function RecordingOptionsMenu({
99
100
  <IconDotsVertical className="h-4 w-4" />
100
101
  </Button>
101
102
  </DropdownMenuTrigger>
102
- <DropdownMenuContent align="end" className="w-44">
103
+ <DropdownMenuContent
104
+ align="end"
105
+ className="w-44"
106
+ onCloseAutoFocus={(event) => {
107
+ // Opening the AlertDialog while this menu is still tearing down
108
+ // leaves `pointer-events: none` stuck on <body>: two dismissable
109
+ // layers overlap and the survivor never restores the style. Wait
110
+ // for the menu to finish closing, and keep focus off the trigger
111
+ // so the dialog owns it.
112
+ if (pendingDeleteConfirmRef.current) {
113
+ event.preventDefault();
114
+ pendingDeleteConfirmRef.current = false;
115
+ setOpen(true);
116
+ }
117
+ }}
118
+ >
103
119
  {showDownload ? (
104
120
  <DropdownMenuItem
105
121
  onSelect={handleDownload}
@@ -116,7 +132,8 @@ export function RecordingOptionsMenu({
116
132
  <DropdownMenuItem
117
133
  onSelect={(event) => {
118
134
  event.preventDefault();
119
- setOpen(true);
135
+ pendingDeleteConfirmRef.current = true;
136
+ setMenuOpen(false);
120
137
  }}
121
138
  className="text-destructive focus:text-destructive"
122
139
  >
@@ -132,7 +149,7 @@ export function RecordingOptionsMenu({
132
149
  if (!deletedWhileOpenRef.current) return;
133
150
  deletedWhileOpenRef.current = false;
134
151
  event.preventDefault();
135
- setTimeout(() => onDeleted?.(), 0);
152
+ onDeleted?.();
136
153
  }}
137
154
  >
138
155
  <AlertDialogHeader>
@@ -15,6 +15,7 @@ export interface RecordingSummary {
15
15
  thumbnailUrl: string | null;
16
16
  animatedThumbnailUrl: string | null;
17
17
  durationMs: number;
18
+ effectiveDurationMs: number;
18
19
  status: "uploading" | "processing" | "ready" | "failed";
19
20
  uploadProgress?: number;
20
21
  failureReason?: string | null;
@@ -247,9 +247,8 @@ function nativeSaveFailureMessage(reason: string | null | undefined): string {
247
247
  return "The desktop recorder finished and saved a local copy, but Clips could not upload it. You can retry from the Clips menu without recording again.";
248
248
  }
249
249
 
250
- export function BackToLibraryButton() {
250
+ export function BackButton({ onBack }: { onBack: () => void }) {
251
251
  const t = useT();
252
- const navigate = useNavigate();
253
252
 
254
253
  return (
255
254
  <Tooltip>
@@ -258,7 +257,7 @@ export function BackToLibraryButton() {
258
257
  variant="ghost"
259
258
  size="icon"
260
259
  className="shrink-0"
261
- onClick={() => navigate("/library", { replace: true })}
260
+ onClick={onBack}
262
261
  aria-label={t("recordingPage.backToLibrary")}
263
262
  >
264
263
  <IconArrowLeft className="h-4 w-4 rtl:-scale-x-100" />
@@ -1053,7 +1052,9 @@ export default function RecordingPage() {
1053
1052
  return (
1054
1053
  <div className="flex min-h-screen w-full flex-col bg-background">
1055
1054
  <header className="flex min-w-0 shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:px-4 sm:py-3">
1056
- <BackToLibraryButton />
1055
+ <BackButton
1056
+ onBack={() => navigate("/library", { replace: true })}
1057
+ />
1057
1058
  <div className="min-w-0 flex-1">
1058
1059
  <p className="truncate text-sm font-medium">{visibleTitle}</p>
1059
1060
  <p className="truncate text-xs text-muted-foreground">
@@ -1369,7 +1370,13 @@ export default function RecordingPage() {
1369
1370
  {/* Main video column */}
1370
1371
  <div className="flex w-full min-w-0 flex-col xl:flex-1">
1371
1372
  <header className="flex min-w-0 shrink-0 items-center gap-2 border-b border-border px-3 py-2 sm:px-4 sm:py-3">
1372
- <BackToLibraryButton />
1373
+ <BackButton
1374
+ onBack={
1375
+ editing
1376
+ ? () => setEditing(false)
1377
+ : () => navigate("/library", { replace: true })
1378
+ }
1379
+ />
1373
1380
  <div className="flex-1 min-w-0">
1374
1381
  <EditableRecordingTitle
1375
1382
  recordingId={recording.id}
@@ -4,6 +4,7 @@ import { open as openExternal } from "@tauri-apps/plugin-shell";
4
4
  import { useCallback, useEffect, useMemo, useRef } from "react";
5
5
 
6
6
  import { callAppBundleIdsForJoinUrl } from "../lib/meeting-call-app";
7
+ import { stopMeetingBeforeTranscriptFlush } from "../lib/meeting-stop";
7
8
  import {
8
9
  appendFinalTranscript,
9
10
  onFinalTranscript,
@@ -195,18 +196,33 @@ export function useMeetingTranscription({
195
196
  }
196
197
  });
197
198
  await invoke("silence_detector_stop").catch(() => {});
198
- if (reason !== "app-quit") {
199
- await session.historyInFlight?.catch(() => {});
200
- }
201
- // Final flush waits for any in-flight flush first (flushTranscript's
202
- // single-flight coalescing) then sends the definitive snapshot.
203
- await flushTranscript().catch((err) => {
204
- console.warn("[clips-popover] meeting transcript save failed:", err);
205
- });
206
- await callClipsAction("stop-meeting-recording", {
207
- meetingId: session.meetingId,
208
- }).catch((err) => {
209
- console.warn("[clips-popover] stop meeting action failed:", err);
199
+ await stopMeetingBeforeTranscriptFlush({
200
+ // Stamp actualEnd as soon as capture is torn down. Transcript
201
+ // history and network flushes may be slow or unavailable, but they
202
+ // must not leave the meeting looking live in the meantime.
203
+ stopRecording: async () => {
204
+ await callClipsAction("stop-meeting-recording", {
205
+ meetingId: session.meetingId,
206
+ }).catch((err) => {
207
+ console.warn("[clips-popover] stop meeting action failed:", err);
208
+ });
209
+ },
210
+ waitForHistory: async () => {
211
+ if (reason !== "app-quit") {
212
+ await session.historyInFlight?.catch(() => {});
213
+ }
214
+ },
215
+ // Final flush waits for any in-flight flush first
216
+ // (flushTranscript's single-flight coalescing) and sends the
217
+ // definitive snapshot.
218
+ flushTranscript: async () => {
219
+ await flushTranscript().catch((err) => {
220
+ console.warn(
221
+ "[clips-popover] meeting transcript save failed:",
222
+ err,
223
+ );
224
+ });
225
+ },
210
226
  });
211
227
  if (session.lines.length) {
212
228
  const finalizePromise = callClipsAction("finalize-meeting", {
@@ -0,0 +1,16 @@
1
+ interface StopMeetingBeforeTranscriptFlushOptions {
2
+ stopRecording: () => Promise<void>;
3
+ waitForHistory: () => Promise<void>;
4
+ flushTranscript: () => Promise<void>;
5
+ }
6
+
7
+ /** Persist the terminal meeting state before waiting on transcript work. */
8
+ export async function stopMeetingBeforeTranscriptFlush({
9
+ stopRecording,
10
+ waitForHistory,
11
+ flushTranscript,
12
+ }: StopMeetingBeforeTranscriptFlushOptions): Promise<void> {
13
+ await stopRecording();
14
+ await waitForHistory();
15
+ await flushTranscript();
16
+ }