@agent-native/core 0.161.10 → 0.161.14

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 (49) 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/changelog/2026-08-18-deleting-a-recording-no-longer-freezes-clips.md +6 -0
  12. package/corpus/templates/clips/desktop/src/app.tsx +9 -10
  13. package/corpus/templates/clips/desktop/src/lib/recorder.ts +4 -3
  14. package/corpus/templates/clips/desktop/src/lib/recording-start-guard.ts +26 -0
  15. package/corpus/templates/content/actions/export-document.ts +68 -3
  16. package/corpus/templates/content/changelog/2026-08-06-database-exports-now-include-their-pages.md +6 -0
  17. package/corpus/templates/content/shared/document-export.ts +25 -0
  18. package/corpus/templates/design/.agents/skills/creative-context/SKILL.md +3 -0
  19. package/corpus/templates/design/app/components/editor/PromptDialog.tsx +66 -0
  20. package/corpus/templates/design/app/pages/Index.tsx +45 -1
  21. package/corpus/templates/design/app/pages/design-editor/creative-context-precedent.ts +233 -0
  22. package/corpus/templates/design/changelog/2026-08-11-new-designs-now-skip-the-intake-questions-and-follow-your-ex.md +6 -0
  23. package/corpus/templates/dispatch/app/chat-first-pages.ts +40 -40
  24. package/corpus/templates/dispatch/app/i18n-data.ts +5 -5
  25. package/corpus/templates/factory/AGENTS.md +30 -29
  26. package/corpus/templates/slides/app/components/layout/AgentWorkIndicator.tsx +56 -9
  27. package/dist/action.js +68 -2
  28. package/dist/agent/chat-health-alert.js +10 -1
  29. package/dist/client/AssistantChat.js +1 -0
  30. package/dist/client/chat-first/app-open-actions.js +1 -1
  31. package/dist/client/chat-first/copy.js +2 -2
  32. package/dist/client/conversation/use-near-bottom-autoscroll.js +34 -4
  33. package/dist/client/embed-auth.js +40 -3
  34. package/dist/client/mcp-apps/McpAppRenderer.js +7 -2
  35. package/dist/client/route-state.js +42 -5
  36. package/dist/client/use-change-version.js +71 -25
  37. package/dist/client/use-db-sync.d.ts +1 -0
  38. package/dist/client/use-db-sync.js +223 -45
  39. package/dist/collab/awareness.js +37 -4
  40. package/dist/collab/client.js +17 -1
  41. package/dist/collab/struct-routes.d.ts +1 -1
  42. package/dist/mcp/embed-app.js +71 -2
  43. package/dist/notifications/routes.d.ts +1 -1
  44. package/dist/observability/routes.d.ts +3 -3
  45. package/dist/secrets/routes.d.ts +9 -9
  46. package/dist/server/poll.d.ts +22 -14
  47. package/dist/server/poll.js +243 -67
  48. package/dist/styles/agent-conversation.css +25 -4
  49. 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: 8301
34
+ - template files: 8309
@@ -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,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.
@@ -112,6 +112,7 @@ import {
112
112
  copyRecordingShareLink,
113
113
  recordingShareUrl,
114
114
  } from "./lib/recording-link";
115
+ import { boundedCleanup } from "./lib/recording-start-guard";
115
116
  import { REWIND_AGENT_PROMPT } from "./lib/rewind-agent-prompt";
116
117
  import { getRewindStatusPresentation } from "./lib/rewind-status";
117
118
  import {
@@ -3218,16 +3219,14 @@ export function App() {
3218
3219
  bubbleStreamTransferredToRecorder.current = false;
3219
3220
  recordingFlowGateRef.current = false;
3220
3221
  setRecordingFlowActive(false);
3221
- try {
3222
- await invoke("set_recording_state", { active: false });
3223
- } catch {
3224
- // ignore — best-effort
3225
- }
3226
- try {
3227
- await invoke("show_popover");
3228
- } catch {
3229
- // ignore — best-effort
3230
- }
3222
+ // Bounded, not just best-effort: a plain unbounded await here would
3223
+ // let a stuck native command (e.g. a ScreenCaptureKit handshake that
3224
+ // never returns) turn this "always recovers" block into another
3225
+ // permanent hang on top of the one that just failed — exactly the
3226
+ // "stuck on Preparing…, have to restart" symptom this exists to
3227
+ // prevent.
3228
+ await boundedCleanup(invoke("set_recording_state", { active: false }));
3229
+ await boundedCleanup(invoke("show_popover"));
3231
3230
  }
3232
3231
  }
3233
3232
 
@@ -84,6 +84,7 @@ import {
84
84
  type NativeRecordingRequestOptions,
85
85
  } from "./recording-request";
86
86
  import {
87
+ boundedCleanup,
87
88
  guardRecordingStart,
88
89
  RECORDING_START_TIMEOUT_MS,
89
90
  RecordingStartCancelledError,
@@ -2748,7 +2749,7 @@ async function tryStartRewindFullscreenRecording(
2748
2749
  },
2749
2750
  async countdown() {
2750
2751
  console.log("[rewind-latency] countdown shown after preparation");
2751
- await invoke("hide_preparing").catch(() => {});
2752
+ await boundedCleanup(invoke("hide_preparing"));
2752
2753
  // Overlap whisper startup with the countdown so the capture boundary
2753
2754
  // never waits on it, but have both settled before activation.
2754
2755
  await Promise.all([
@@ -2792,7 +2793,7 @@ async function tryStartRewindFullscreenRecording(
2792
2793
  });
2793
2794
  }
2794
2795
  } catch (err) {
2795
- await invoke("hide_preparing").catch(() => {});
2796
+ await boundedCleanup(invoke("hide_preparing"));
2796
2797
  transcriptionAborted = true;
2797
2798
  // Cast: `transcriptionCapture` is only assigned inside the
2798
2799
  // `startRewindTranscription` closure, which TS's control-flow analysis
@@ -4090,7 +4091,7 @@ export async function startRecording(
4090
4091
  },
4091
4092
  });
4092
4093
  } catch (err) {
4093
- await invoke("hide_recording_chrome").catch(() => {});
4094
+ await boundedCleanup(invoke("hide_recording_chrome"));
4094
4095
  const e = err as { name?: string; message?: string } | null;
4095
4096
  console.error(
4096
4097
  "[clips-recorder] startRecording threw:",
@@ -1,5 +1,12 @@
1
1
  export const RECORDING_START_TIMEOUT_MS = 90_000;
2
2
 
3
+ // Cleanup invokes (closing overlay windows, clearing recording state) are
4
+ // normally instant. This bounds them well above any real duration but far
5
+ // below forever, so a stuck native call (e.g. a ScreenCaptureKit handshake
6
+ // that never returns its completion) can't turn "recovery" into another
7
+ // permanent hang on top of the one the start guard already gave up on.
8
+ export const RECOVERY_INVOKE_TIMEOUT_MS = 5_000;
9
+
3
10
  export class RecordingStartTimeoutError extends Error {
4
11
  constructor(timeoutMs: number) {
5
12
  super(
@@ -82,3 +89,22 @@ export function guardRecordingStart<T>(
82
89
  }, timeoutMs);
83
90
  });
84
91
  }
92
+
93
+ /**
94
+ * Best-effort recovery step that must never block the caller. Recording-start
95
+ * failure paths (and their "always restore the UI" `finally` blocks) await
96
+ * cleanup invokes like `hide_recording_chrome` — if the underlying native
97
+ * command hangs, an unbounded `await ...catch(() => {})` hangs the whole
98
+ * recovery with it, leaving the toolbar/"Preparing…" state stuck until the
99
+ * user restarts the app. Swallows both success and failure; only existence
100
+ * to cap how long a single cleanup step can stall recovery.
101
+ */
102
+ export function boundedCleanup(
103
+ operation: Promise<unknown>,
104
+ timeoutMs = RECOVERY_INVOKE_TIMEOUT_MS,
105
+ ): Promise<void> {
106
+ return guardRecordingStart(operation, { timeoutMs }).then(
107
+ () => undefined,
108
+ () => undefined,
109
+ );
110
+ }