@agent-native/core 0.161.12 → 0.161.16

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 (47) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/actions/clear-selected-dashboard-object.ts +71 -0
  3. package/corpus/templates/analytics/actions/view-screen.ts +68 -4
  4. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -66
  5. package/corpus/templates/analytics/app/lib/selected-object.ts +53 -0
  6. package/corpus/templates/analytics/app/pages/Ask.tsx +21 -0
  7. package/corpus/templates/analytics/changelog/2026-08-18-ask-no-stale-dashboard-context.md +6 -0
  8. package/corpus/templates/calendar/app/root.tsx +12 -4
  9. package/corpus/templates/calendar/changelog/2026-08-18-calendar-stays-responsive-in-the-desktop-app-without-focus-t.md +6 -0
  10. package/corpus/templates/clips/app/components/library/recording-card.tsx +19 -3
  11. package/corpus/templates/clips/app/hooks/use-recording-leave-guard.ts +70 -0
  12. package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
  13. package/corpus/templates/clips/app/routes/record.tsx +42 -0
  14. package/corpus/templates/clips/changelog/2026-08-18-deleting-a-recording-no-longer-freezes-clips.md +6 -0
  15. package/corpus/templates/clips/desktop/src/app.tsx +9 -10
  16. package/corpus/templates/clips/desktop/src/lib/recorder.ts +4 -3
  17. package/corpus/templates/clips/desktop/src/lib/recording-start-guard.ts +26 -0
  18. package/corpus/templates/dispatch/app/chat-first-pages.ts +40 -40
  19. package/corpus/templates/dispatch/app/i18n-data.ts +5 -5
  20. package/corpus/templates/factory/AGENTS.md +30 -29
  21. package/corpus/templates/slides/app/root.tsx +21 -0
  22. package/corpus/templates/slides/server/handlers/decks.ts +7 -3
  23. package/corpus/templates/slides/server/handlers/request-auth-context.ts +58 -1
  24. package/corpus/templates/slides/server/handlers/share.ts +7 -2
  25. package/corpus/templates/slides/server/handlers/uploads.ts +7 -2
  26. package/dist/action.js +49 -0
  27. package/dist/agent/engine/translate-anthropic.js +19 -1
  28. package/dist/agent/engine/types.d.ts +7 -0
  29. package/dist/agent/production-agent.js +32 -0
  30. package/dist/agent/thread-data-builder.d.ts +16 -4
  31. package/dist/agent/thread-data-builder.js +159 -5
  32. package/dist/client/AssistantChat.js +1 -0
  33. package/dist/client/agent-chat-adapter.js +13 -1
  34. package/dist/client/chat-first/app-open-actions.js +1 -1
  35. package/dist/client/chat-first/copy.js +2 -2
  36. package/dist/client/mcp-apps/McpAppRenderer.js +7 -2
  37. package/dist/collab/struct-routes.d.ts +1 -1
  38. package/dist/mcp/embed-app.js +71 -2
  39. package/dist/notifications/routes.d.ts +1 -1
  40. package/dist/observability/routes.d.ts +2 -2
  41. package/dist/secrets/routes.d.ts +6 -6
  42. package/dist/server/agent-teams.js +3 -1
  43. package/dist/server/cors-origins.js +24 -1
  44. package/dist/server/realtime-token.d.ts +1 -1
  45. package/dist/styles/agent-conversation.css +25 -4
  46. package/dist/transcription/builder-transcription.js +12 -1
  47. 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: 8304
34
+ - template files: 8310
@@ -0,0 +1,71 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import {
3
+ appStateKeyForBrowserTab,
4
+ compareAndSetAppState,
5
+ getCurrentRequestBrowserTabId,
6
+ readAppState,
7
+ } from "@agent-native/core/application-state";
8
+ import { z } from "zod";
9
+
10
+ const SELECTED_OBJECT_STATE_KEY = "selected-object";
11
+ const SELECTED_OBJECT_SOURCE_FIELD = "__agentNativeSelectedObjectSource";
12
+
13
+ function selectedDashboardId(value: Record<string, unknown>): string | null {
14
+ const id =
15
+ value.type === "dashboard"
16
+ ? value.id
17
+ : value.type === "dashboard-panel"
18
+ ? value.dashboardId
19
+ : null;
20
+ return typeof id === "string" ? id : null;
21
+ }
22
+
23
+ export default defineAction({
24
+ agentTool: false,
25
+ description:
26
+ "Clear the current browser tab's Analytics dashboard selection when it still belongs to that tab.",
27
+ schema: z.object({
28
+ browserTabId: z
29
+ .string()
30
+ .regex(/^[A-Za-z0-9_-]{1,96}$/)
31
+ .optional(),
32
+ dashboardId: z.string().min(1).optional(),
33
+ expectedSelection: z.record(z.string(), z.unknown()).optional(),
34
+ source: z.string().min(1).max(96),
35
+ }),
36
+ run: async ({ browserTabId, dashboardId, expectedSelection, source }) => {
37
+ const effectiveBrowserTabId =
38
+ browserTabId ?? getCurrentRequestBrowserTabId();
39
+ const scopedStateKey = appStateKeyForBrowserTab(
40
+ SELECTED_OBJECT_STATE_KEY,
41
+ effectiveBrowserTabId,
42
+ );
43
+ const tabCurrent =
44
+ scopedStateKey === SELECTED_OBJECT_STATE_KEY
45
+ ? null
46
+ : await readAppState(scopedStateKey);
47
+ const globalCurrent =
48
+ !effectiveBrowserTabId || !tabCurrent
49
+ ? await readAppState(SELECTED_OBJECT_STATE_KEY)
50
+ : null;
51
+ const current = tabCurrent ?? globalCurrent;
52
+ const stateKey = tabCurrent ? scopedStateKey : SELECTED_OBJECT_STATE_KEY;
53
+ if (!current || current[SELECTED_OBJECT_SOURCE_FIELD] !== source) {
54
+ return { cleared: false };
55
+ }
56
+
57
+ const currentDashboardId = selectedDashboardId(current);
58
+ if (!currentDashboardId) return { cleared: false };
59
+ if (dashboardId && currentDashboardId !== dashboardId) {
60
+ return { cleared: false };
61
+ }
62
+
63
+ return {
64
+ cleared: await compareAndSetAppState(
65
+ stateKey,
66
+ expectedSelection ?? current,
67
+ null,
68
+ ),
69
+ };
70
+ },
71
+ });
@@ -24,6 +24,35 @@ import { getMonitor, listMonitors } from "../server/lib/uptime-monitors.js";
24
24
 
25
25
  const SESSION_FILTER_KEYS = new Set(["range", "app", "q"]);
26
26
  const REPLAY_RANGES = new Set(["24h", "7d", "30d", "90d", "all"]);
27
+ const DASHBOARD_PATH_RE = /^\/(?:adhoc|dashboards)\/([^/]+)\/?$/;
28
+
29
+ function dashboardIdFromPathname(pathname: string): string | null {
30
+ const match = pathname.match(DASHBOARD_PATH_RE);
31
+ if (!match) return null;
32
+ try {
33
+ return decodeURIComponent(match[1]);
34
+ } catch {
35
+ return match[1];
36
+ }
37
+ }
38
+
39
+ function normalizedDashboardId(value: unknown): string | null {
40
+ if (typeof value !== "string" || value.length === 0) return null;
41
+ try {
42
+ return decodeURIComponent(value);
43
+ } catch {
44
+ return value;
45
+ }
46
+ }
47
+
48
+ function isAskPathname(pathname: string): boolean {
49
+ return (
50
+ pathname === "" ||
51
+ pathname === "/" ||
52
+ pathname === "/ask" ||
53
+ pathname === "/overview"
54
+ );
55
+ }
27
56
 
28
57
  export default defineAction({
29
58
  description:
@@ -38,10 +67,47 @@ export default defineAction({
38
67
  search?: string;
39
68
  searchParams?: Record<string, string>;
40
69
  } | null;
41
- const selectedObject = await readAppStateForCurrentTab("selected-object");
70
+ const rawNav = navigation as any;
71
+ const pathname = typeof url?.pathname === "string" ? url.pathname : "";
72
+ const hasAuthoritativePathname = pathname.length > 0;
73
+ const pathnameDashboardId = hasAuthoritativePathname
74
+ ? dashboardIdFromPathname(pathname)
75
+ : null;
76
+ const navigationDashboardId = normalizedDashboardId(rawNav?.dashboardId);
77
+ // The URL is authoritative during route transitions in both directions.
78
+ // A stale Ask navigation object must not hide the dashboard selection or
79
+ // trigger Ask-only behavior after the URL has moved to a dashboard.
80
+ const isAskView = hasAuthoritativePathname
81
+ ? isAskPathname(pathname)
82
+ : rawNav?.view === "ask";
83
+ const hasStaleAskNavigation =
84
+ hasAuthoritativePathname && !isAskView && rawNav?.view === "ask";
85
+ const hasStaleDashboardNavigation =
86
+ hasAuthoritativePathname &&
87
+ rawNav?.view === "adhoc" &&
88
+ (pathnameDashboardId === null ||
89
+ navigationDashboardId !== pathnameDashboardId);
90
+ const effectiveNavigation = isAskView
91
+ ? { view: "ask" }
92
+ : pathnameDashboardId !== null
93
+ ? {
94
+ ...(rawNav?.view === "adhoc" &&
95
+ navigationDashboardId === pathnameDashboardId
96
+ ? rawNav
97
+ : {}),
98
+ view: "adhoc",
99
+ dashboardId: pathnameDashboardId,
100
+ }
101
+ : hasStaleAskNavigation || hasStaleDashboardNavigation
102
+ ? null
103
+ : navigation;
104
+ const nav = effectiveNavigation as any;
105
+ const selectedObject = isAskView
106
+ ? null
107
+ : await readAppStateForCurrentTab("selected-object");
42
108
 
43
109
  const screen: Record<string, unknown> = {};
44
- if (navigation) screen.navigation = navigation;
110
+ if (effectiveNavigation) screen.navigation = effectiveNavigation;
45
111
  if (url?.pathname) screen.pathname = url.pathname;
46
112
  if (selectedObject) screen.selectedObject = selectedObject;
47
113
 
@@ -65,8 +131,6 @@ export default defineAction({
65
131
  }
66
132
  }
67
133
 
68
- const nav = navigation as any;
69
-
70
134
  if (nav?.view === "adhoc" && nav?.dashboardId) {
71
135
  try {
72
136
  const orgId = getRequestOrgId() || null;
@@ -5,13 +5,13 @@ import {
5
5
  type AgentSidebarStateChangeDetail,
6
6
  useAgentChatContext,
7
7
  } from "@agent-native/core/client/agent-chat";
8
- import {
9
- deleteClientAppState,
10
- readClientAppState,
11
- setClientAppState,
12
- } from "@agent-native/core/client/hooks";
13
- import { useCallback, useEffect, useState } from "react";
8
+ import { setClientAppState } from "@agent-native/core/client/hooks";
9
+ import { useCallback, useEffect, useRef, useState } from "react";
14
10
 
11
+ import {
12
+ clearSelectedDashboardObjectIfOwned,
13
+ type SelectedDashboardObject,
14
+ } from "@/lib/selected-object";
15
15
  import { TAB_ID } from "@/lib/tab-id";
16
16
 
17
17
  const DASHBOARD_CONTEXT_KEY = "analytics-selected-dashboard";
@@ -80,28 +80,6 @@ function dashboardContext(
80
80
  return lines.join("\n");
81
81
  }
82
82
 
83
- async function deleteSelectedObjectIfOwned(dashboardId: string) {
84
- try {
85
- const current = await readClientAppState<Record<string, unknown>>(
86
- SELECTED_OBJECT_STATE_KEY,
87
- );
88
- if (current?.[SELECTED_OBJECT_SOURCE_FIELD] !== TAB_ID) return;
89
- const selectedDashboardId =
90
- current.type === "dashboard"
91
- ? current.id
92
- : current.type === "dashboard-panel"
93
- ? current.dashboardId
94
- : null;
95
- if (selectedDashboardId !== dashboardId) return;
96
- await deleteClientAppState(SELECTED_OBJECT_STATE_KEY, {
97
- keepalive: true,
98
- requestSource: TAB_ID,
99
- });
100
- } catch {
101
- // Best effort only; avoid clearing another tab's selected object on errors.
102
- }
103
- }
104
-
105
83
  function panelSelectionMarker(dashboardId: string, panelId: string): string {
106
84
  return `Analytics panel selection: dashboard=${encodeURIComponent(
107
85
  dashboardId,
@@ -153,6 +131,7 @@ export function useDashboardChatContext(
153
131
  ? panelSelection.panelId
154
132
  : null;
155
133
  const hasSelectedPanel = selectedPanelId !== null;
134
+ const publishedSelectionRef = useRef<SelectedDashboardObject | null>(null);
156
135
 
157
136
  const selectPanelForChat = useCallback(
158
137
  (
@@ -179,6 +158,20 @@ export function useDashboardChatContext(
179
158
  ? "Inspect this panel with get-sql-dashboard (includeConfig: true) before changing it, then use mutate-dashboard for edits."
180
159
  : "Inspect the linked Explorer config before changing this chart.",
181
160
  ].filter(Boolean);
161
+ const selection: SelectedDashboardObject = {
162
+ type: "dashboard-panel",
163
+ dashboardId: id,
164
+ dashboardKind: kind,
165
+ dashboardTitle: displayDashboardTitle,
166
+ panelId: panel.panelId,
167
+ panelTitle: displayPanelTitle,
168
+ panelKind: panel.panelKind,
169
+ chartType: panel.chartType || undefined,
170
+ source: panel.source || undefined,
171
+ configId: panel.configId || undefined,
172
+ extensionId: panel.extensionId || undefined,
173
+ [SELECTED_OBJECT_SOURCE_FIELD]: TAB_ID,
174
+ };
182
175
 
183
176
  // The panel context occupies one stable composer slot, so selecting a
184
177
  // different panel replaces the prior chip instead of accumulating chips.
@@ -189,27 +182,11 @@ export function useDashboardChatContext(
189
182
  openSidebar: options.openSidebar === true,
190
183
  focus: options.focus ?? false,
191
184
  });
192
- setClientAppState(
193
- SELECTED_OBJECT_STATE_KEY,
194
- {
195
- type: "dashboard-panel",
196
- dashboardId: id,
197
- dashboardKind: kind,
198
- dashboardTitle: displayDashboardTitle,
199
- panelId: panel.panelId,
200
- panelTitle: displayPanelTitle,
201
- panelKind: panel.panelKind,
202
- chartType: panel.chartType || undefined,
203
- source: panel.source || undefined,
204
- configId: panel.configId || undefined,
205
- extensionId: panel.extensionId || undefined,
206
- [SELECTED_OBJECT_SOURCE_FIELD]: TAB_ID,
207
- },
208
- {
209
- keepalive: true,
210
- requestSource: TAB_ID,
211
- },
212
- ).catch(() => {});
185
+ setClientAppState(SELECTED_OBJECT_STATE_KEY, selection, {
186
+ keepalive: true,
187
+ requestSource: TAB_ID,
188
+ }).catch(() => {});
189
+ publishedSelectionRef.current = selection;
213
190
  },
214
191
  [id, isAgentSidebarOpen, kind, title],
215
192
  );
@@ -261,23 +238,21 @@ export function useDashboardChatContext(
261
238
  useEffect(() => {
262
239
  if (!id || hasSelectedPanel) return;
263
240
  const displayTitle = title?.trim() || id;
241
+ const selection: SelectedDashboardObject = {
242
+ type: "dashboard",
243
+ id,
244
+ kind,
245
+ title: displayTitle,
246
+ panelCount,
247
+ canEdit,
248
+ [SELECTED_OBJECT_SOURCE_FIELD]: TAB_ID,
249
+ };
264
250
  const timer = setTimeout(() => {
265
- setClientAppState(
266
- SELECTED_OBJECT_STATE_KEY,
267
- {
268
- type: "dashboard",
269
- id,
270
- kind,
271
- title: displayTitle,
272
- panelCount,
273
- canEdit,
274
- [SELECTED_OBJECT_SOURCE_FIELD]: TAB_ID,
275
- },
276
- {
277
- keepalive: true,
278
- requestSource: TAB_ID,
279
- },
280
- ).catch(() => {});
251
+ setClientAppState(SELECTED_OBJECT_STATE_KEY, selection, {
252
+ keepalive: true,
253
+ requestSource: TAB_ID,
254
+ }).catch(() => {});
255
+ publishedSelectionRef.current = selection;
281
256
  }, CONTEXT_PUBLISH_DELAY_MS);
282
257
  return () => clearTimeout(timer);
283
258
  }, [canEdit, hasSelectedPanel, id, kind, panelCount, title]);
@@ -293,7 +268,7 @@ export function useDashboardChatContext(
293
268
  key: DASHBOARD_PANEL_CONTEXT_KEY,
294
269
  openSidebar: false,
295
270
  });
296
- deleteSelectedObjectIfOwned(id);
271
+ clearSelectedDashboardObjectIfOwned(publishedSelectionRef.current);
297
272
  };
298
273
  }, [id]);
299
274
 
@@ -0,0 +1,53 @@
1
+ import { readClientAppState } from "@agent-native/core/client/application-state";
2
+ import { callAction } from "@agent-native/core/client/hooks";
3
+
4
+ import { TAB_ID } from "@/lib/tab-id";
5
+
6
+ export type SelectedDashboardObject = Record<string, unknown>;
7
+
8
+ export async function readSelectedDashboardObject(): Promise<SelectedDashboardObject | null> {
9
+ try {
10
+ const value =
11
+ await readClientAppState<SelectedDashboardObject>("selected-object");
12
+ return value && typeof value === "object" && !Array.isArray(value)
13
+ ? value
14
+ : null;
15
+ // A browser-side selection probe: "could not read" and "nothing selected"
16
+ // drive the same UI, and throwing breaks route transitions.
17
+ // coercion-ok: unreadable selection and no selection render identically
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Clear this tab's dashboard selection without touching another tab's state.
25
+ * The action validates the tab marker and deletes with a compare-and-set so a
26
+ * route transition cannot erase a newer selection after the read.
27
+ */
28
+ export async function clearSelectedDashboardObjectIfOwned(
29
+ dashboardIdOrSelection?: string | SelectedDashboardObject | null,
30
+ ): Promise<void> {
31
+ const dashboardId =
32
+ typeof dashboardIdOrSelection === "string"
33
+ ? dashboardIdOrSelection
34
+ : undefined;
35
+ const expectedSelection =
36
+ dashboardIdOrSelection && typeof dashboardIdOrSelection === "object"
37
+ ? dashboardIdOrSelection
38
+ : undefined;
39
+
40
+ if (!dashboardId && !expectedSelection) return;
41
+
42
+ try {
43
+ await callAction("clear-selected-dashboard-object", {
44
+ ...(dashboardId ? { dashboardId } : {}),
45
+ ...(expectedSelection ? { expectedSelection } : {}),
46
+ browserTabId: TAB_ID,
47
+ source: TAB_ID,
48
+ });
49
+ } catch {
50
+ // coercion-ok: best-effort cleanup must not break Ask navigation when state APIs fail.
51
+ // Best effort only; stale context is safer than clearing another tab's state.
52
+ }
53
+ }
@@ -7,6 +7,10 @@ import { CreativeContextComposerChip } from "@agent-native/creative-context/clie
7
7
  import { useEffect, useMemo } from "react";
8
8
 
9
9
  import { ANALYTICS_CHAT_STORAGE_KEY } from "@/lib/chat-handoff";
10
+ import {
11
+ clearSelectedDashboardObjectIfOwned,
12
+ readSelectedDashboardObject,
13
+ } from "@/lib/selected-object";
10
14
  import { TAB_ID } from "@/lib/tab-id";
11
15
 
12
16
  const DASHBOARD_CONTEXT_KEYS = new Set([
@@ -31,6 +35,23 @@ export default function AskPage() {
31
35
  }
32
36
  }, [removeChatContextItem, staleDashboardContextKey]);
33
37
 
38
+ useEffect(() => {
39
+ let mounted = true;
40
+ const pathnameAtMount = window.location.pathname;
41
+
42
+ void readSelectedDashboardObject().then((selection) => {
43
+ // If the user already navigated away, this Ask instance no longer owns
44
+ // cleanup. The action also CASes the captured selection, covering a
45
+ // selection change that happens after this read but before the write.
46
+ if (!mounted || window.location.pathname !== pathnameAtMount) return;
47
+ if (selection) void clearSelectedDashboardObjectIfOwned(selection);
48
+ });
49
+
50
+ return () => {
51
+ mounted = false;
52
+ };
53
+ }, []);
54
+
34
55
  return (
35
56
  <div className="analytics-ask-page flex h-full min-h-0 flex-col bg-background">
36
57
  <AgentChatSurface
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Analytics no longer carries a previous dashboard into a new Ask chat.
@@ -202,6 +202,13 @@ function isPublicBookingPath(pathname: string): boolean {
202
202
  );
203
203
  }
204
204
 
205
+ function isAgentNativeDesktop(): boolean {
206
+ return (
207
+ typeof navigator !== "undefined" &&
208
+ /AgentNativeDesktop/i.test(navigator.userAgent)
209
+ );
210
+ }
211
+
205
212
  function AppContent() {
206
213
  const [cmdkOpen, setCmdkOpen] = useState(false);
207
214
  const navigate = useNavigate();
@@ -249,11 +256,12 @@ export default function Root() {
249
256
  createAgentNativeQueryClient({
250
257
  defaultOptions: {
251
258
  queries: {
252
- // Calendar aggressively refetches on focus because external
253
- // calendar events can change without a DB sync event (e.g. Google
254
- // Calendar webhooks with a processing delay).
259
+ // Chrome gets one focus refresh because external calendar events can
260
+ // change without a DB sync event (e.g. delayed Google webhooks).
261
+ // Desktop already has the shell's focus-aware DB sync, and repeated
262
+ // webview focus events otherwise duplicate the events request.
255
263
  // request-storm-allow: one user-driven focus refresh for provider data.
256
- refetchOnWindowFocus: true,
264
+ refetchOnWindowFocus: !isAgentNativeDesktop(),
257
265
  // Flat retry: calendar data fetches don't need the auth-aware
258
266
  // retry function — auth errors surface through the booking flow.
259
267
  retry: 1,
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Calendar stays responsive in the desktop app without focus-triggered reloads
@@ -14,7 +14,7 @@ import {
14
14
  IconAlertTriangle,
15
15
  IconExternalLink,
16
16
  } from "@tabler/icons-react";
17
- import { useCallback, useMemo, useState } from "react";
17
+ import { useCallback, useMemo, useRef, useState } from "react";
18
18
  import { Link } from "react-router";
19
19
 
20
20
  import { AgentViewCount } from "@/components/player/recording-views-badge";
@@ -94,6 +94,8 @@ export function RecordingCard({
94
94
  const t = useT();
95
95
  const { formatDate, formatRelativeTime } = useFormatters();
96
96
  const [hovered, setHovered] = useState(false);
97
+ const [menuOpen, setMenuOpen] = useState(false);
98
+ const pendingTrashRef = useRef(false);
97
99
 
98
100
  const duration = useMemo(
99
101
  () => formatDuration(recording.durationMs),
@@ -185,6 +187,11 @@ export function RecordingCard({
185
187
  attemptOpenDesktopApp();
186
188
  }, []);
187
189
 
190
+ const requestTrash = useCallback(() => {
191
+ pendingTrashRef.current = true;
192
+ setMenuOpen(false);
193
+ }, []);
194
+
188
195
  return (
189
196
  <div
190
197
  role="article"
@@ -378,7 +385,7 @@ export function RecordingCard({
378
385
  </div>
379
386
 
380
387
  {showActions && (
381
- <DropdownMenu>
388
+ <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
382
389
  <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
383
390
  <button
384
391
  className="pointer-events-auto rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
@@ -390,6 +397,12 @@ export function RecordingCard({
390
397
  <DropdownMenuContent
391
398
  align="end"
392
399
  onClick={(e) => e.stopPropagation()}
400
+ onCloseAutoFocus={(event) => {
401
+ if (!pendingTrashRef.current) return;
402
+ event.preventDefault();
403
+ pendingTrashRef.current = false;
404
+ setTimeout(() => onTrash?.(recording), 0);
405
+ }}
393
406
  >
394
407
  {onShare && (
395
408
  <DropdownMenuItem onSelect={() => onShare(recording)}>
@@ -453,7 +466,10 @@ export function RecordingCard({
453
466
  ))}
454
467
  {onTrash && (
455
468
  <DropdownMenuItem
456
- onSelect={() => onTrash(recording)}
469
+ onSelect={(event) => {
470
+ event.preventDefault();
471
+ requestTrash();
472
+ }}
457
473
  className="text-destructive focus:text-destructive"
458
474
  >
459
475
  <IconTrash className="h-4 w-4 me-2" />{" "}
@@ -0,0 +1,70 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { useBlocker } from "react-router";
3
+
4
+ /**
5
+ * Blocks in-app route navigation while `hasRecordingAtRisk()` is true, using
6
+ * React Router's `useBlocker` — the same check the recorder's `beforeunload`
7
+ * handler already uses for tab close/reload. Without this, an in-app link
8
+ * (e.g. Library) unmounts the recorder route with no warning at all, since
9
+ * the browser never fires `beforeunload` for client-side navigation.
10
+ *
11
+ * Exposes a tiny state machine for a confirm dialog rather than a boolean,
12
+ * because confirming has to navigate away only after the dialog has actually
13
+ * finished closing — see the `onCloseAutoFocus` deferral below.
14
+ */
15
+ export function useRecordingLeaveGuard(hasRecordingAtRisk: () => boolean) {
16
+ const blocker = useBlocker(
17
+ useCallback(
18
+ ({ currentLocation, nextLocation }) =>
19
+ currentLocation.pathname !== nextLocation.pathname &&
20
+ hasRecordingAtRisk(),
21
+ [hasRecordingAtRisk],
22
+ ),
23
+ );
24
+
25
+ const [leavePromptOpen, setLeavePromptOpen] = useState(false);
26
+ // See delete-recording-menu.tsx's `deletedWhileOpenRef`: navigating away
27
+ // while Radix is still tearing down the AlertDialog portal can leave its
28
+ // body pointer-events lock behind. Close the dialog first and defer
29
+ // `blocker.proceed()` to `onCloseAutoFocus`, once Radix has actually
30
+ // finished closing it, instead of calling it straight from the click
31
+ // handler.
32
+ const proceedBlockerRef = useRef<() => void>(() => {});
33
+ proceedBlockerRef.current =
34
+ blocker.state === "blocked" ? blocker.proceed : () => {};
35
+ const leaveConfirmedRef = useRef(false);
36
+
37
+ useEffect(() => {
38
+ if (blocker.state === "blocked") setLeavePromptOpen(true);
39
+ }, [blocker.state]);
40
+
41
+ const confirmLeave = useCallback(() => {
42
+ leaveConfirmedRef.current = true;
43
+ setLeavePromptOpen(false);
44
+ }, []);
45
+
46
+ const onDialogOpenChange = useCallback(
47
+ (open: boolean) => {
48
+ if (open) return;
49
+ setLeavePromptOpen(false);
50
+ if (!leaveConfirmedRef.current && blocker.state === "blocked") {
51
+ blocker.reset();
52
+ }
53
+ },
54
+ [blocker],
55
+ );
56
+
57
+ const onCloseAutoFocus = useCallback((event: { preventDefault(): void }) => {
58
+ if (!leaveConfirmedRef.current) return;
59
+ leaveConfirmedRef.current = false;
60
+ event.preventDefault();
61
+ setTimeout(() => proceedBlockerRef.current(), 0);
62
+ }, []);
63
+
64
+ return {
65
+ leavePromptOpen,
66
+ onDialogOpenChange,
67
+ onCloseAutoFocus,
68
+ confirmLeave,
69
+ };
70
+ }
@@ -1413,6 +1413,10 @@ All notable user-facing changes to Clips are documented here. Open it any time f
1413
1413
  "Connect storage on the next screen: Builder.io (free tier storage + AI) or S3-compatible storage. Clips will finish saving it.",
1414
1414
  connectStorageToRetryLoom:
1415
1415
  "Connect storage on the next screen: Builder.io (free tier storage + AI) or S3-compatible storage. Clips will retry the import.",
1416
+ leaveConfirmTitle: "Leave and discard this recording?",
1417
+ leaveConfirmDescription:
1418
+ "Your in-progress recording hasn't finished saving. Leaving this page now will discard it.",
1419
+ leaveAndDiscard: "Leave and discard",
1416
1420
  },
1417
1421
  importRoute: {
1418
1422
  pageTitle: "Import Loom — Clips",
@@ -35,6 +35,7 @@ import { Link, useLocation, useNavigate } from "react-router";
35
35
 
36
36
  import { Skeleton } from "@/components/ui/skeleton";
37
37
  import { useDesktopPromo } from "@/hooks/use-desktop-promo";
38
+ import { useRecordingLeaveGuard } from "@/hooks/use-recording-leave-guard";
38
39
  import {
39
40
  fetchVideoStorageStatus,
40
41
  useVideoStorageStatus,
@@ -2518,6 +2519,22 @@ export default function RecordRoute() {
2518
2519
  };
2519
2520
  }, [extensionCapture, stopLiveTranscription]);
2520
2521
 
2522
+ // In-app navigation (e.g. a Library link) unmounts this route the same way
2523
+ // a tab close does, but the browser never fires `beforeunload` for it — so
2524
+ // without this, the cleanup effect above ran `releaseCapture()`
2525
+ // unconditionally and silently killed an at-risk recording. Route every
2526
+ // in-app navigation attempt through the same `hasRecordingAtRisk()` check
2527
+ // `warnBeforeDiscard` uses, so both exits are gated by one check instead of
2528
+ // two divergent ones.
2529
+ const {
2530
+ leavePromptOpen,
2531
+ onDialogOpenChange,
2532
+ onCloseAutoFocus,
2533
+ confirmLeave,
2534
+ } = useRecordingLeaveGuard(
2535
+ useCallback(() => !!engineRef.current?.hasRecordingAtRisk(), []),
2536
+ );
2537
+
2521
2538
  // -------------------------------------------------------------------------
2522
2539
  // Render.
2523
2540
  // -------------------------------------------------------------------------
@@ -2740,6 +2757,31 @@ export default function RecordRoute() {
2740
2757
  </AlertDialogContent>
2741
2758
  </AlertDialog>
2742
2759
 
2760
+ <AlertDialog open={leavePromptOpen} onOpenChange={onDialogOpenChange}>
2761
+ <AlertDialogContent onCloseAutoFocus={onCloseAutoFocus}>
2762
+ <AlertDialogHeader>
2763
+ <AlertDialogTitle>
2764
+ {t("recordRoute.leaveConfirmTitle")}
2765
+ </AlertDialogTitle>
2766
+ <AlertDialogDescription>
2767
+ {t("recordRoute.leaveConfirmDescription")}
2768
+ </AlertDialogDescription>
2769
+ </AlertDialogHeader>
2770
+ <AlertDialogFooter>
2771
+ <AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
2772
+ <AlertDialogAction
2773
+ onClick={(event) => {
2774
+ event.preventDefault();
2775
+ confirmLeave();
2776
+ }}
2777
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
2778
+ >
2779
+ {t("recordRoute.leaveAndDiscard")}
2780
+ </AlertDialogAction>
2781
+ </AlertDialogFooter>
2782
+ </AlertDialogContent>
2783
+ </AlertDialog>
2784
+
2743
2785
  {/* Uploading overlay (also covers the compressing pass which can run
2744
2786
  for several minutes on long recordings — without a distinct copy
2745
2787
  users wonder if the app froze). */}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-08-18
4
+ ---
5
+
6
+ Deleting a recording no longer freezes Clips or blocks starting a new recording.