@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/actions/clear-selected-dashboard-object.ts +71 -0
- package/corpus/templates/analytics/actions/view-screen.ts +68 -4
- package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +41 -66
- package/corpus/templates/analytics/app/lib/selected-object.ts +53 -0
- package/corpus/templates/analytics/app/pages/Ask.tsx +21 -0
- package/corpus/templates/analytics/changelog/2026-08-18-ask-no-stale-dashboard-context.md +6 -0
- package/corpus/templates/calendar/app/root.tsx +12 -4
- package/corpus/templates/calendar/changelog/2026-08-18-calendar-stays-responsive-in-the-desktop-app-without-focus-t.md +6 -0
- package/corpus/templates/clips/app/components/library/recording-card.tsx +19 -3
- package/corpus/templates/clips/app/hooks/use-recording-leave-guard.ts +70 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
- package/corpus/templates/clips/app/routes/record.tsx +42 -0
- package/corpus/templates/clips/changelog/2026-08-18-deleting-a-recording-no-longer-freezes-clips.md +6 -0
- package/corpus/templates/clips/desktop/src/app.tsx +9 -10
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +4 -3
- package/corpus/templates/clips/desktop/src/lib/recording-start-guard.ts +26 -0
- package/corpus/templates/dispatch/app/chat-first-pages.ts +40 -40
- package/corpus/templates/dispatch/app/i18n-data.ts +5 -5
- package/corpus/templates/factory/AGENTS.md +30 -29
- package/corpus/templates/slides/app/root.tsx +21 -0
- package/corpus/templates/slides/server/handlers/decks.ts +7 -3
- package/corpus/templates/slides/server/handlers/request-auth-context.ts +58 -1
- package/corpus/templates/slides/server/handlers/share.ts +7 -2
- package/corpus/templates/slides/server/handlers/uploads.ts +7 -2
- package/dist/action.js +49 -0
- package/dist/agent/engine/translate-anthropic.js +19 -1
- package/dist/agent/engine/types.d.ts +7 -0
- package/dist/agent/production-agent.js +32 -0
- package/dist/agent/thread-data-builder.d.ts +16 -4
- package/dist/agent/thread-data-builder.js +159 -5
- package/dist/client/AssistantChat.js +1 -0
- package/dist/client/agent-chat-adapter.js +13 -1
- package/dist/client/chat-first/app-open-actions.js +1 -1
- package/dist/client/chat-first/copy.js +2 -2
- package/dist/client/mcp-apps/McpAppRenderer.js +7 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/mcp/embed-app.js +71 -2
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +2 -2
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-teams.js +3 -1
- package/dist/server/cors-origins.js +24 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/styles/agent-conversation.css +25 -4
- package/dist/transcription/builder-transcription.js +12 -1
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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:
|
|
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,
|
|
@@ -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={() =>
|
|
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). */}
|