@agent-native/toolkit 0.204.1-nightly-20261008153353 → 0.205.0
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/agent-native.eject.json +1 -0
- package/dist/app/auth/AuthPage.d.ts.map +1 -1
- package/dist/app/auth/AuthPage.js +3 -11
- package/dist/app/auth/AuthPage.js.map +1 -1
- package/dist/app/chat/AgentSidebar.d.ts +3 -1
- package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
- package/dist/app/chat/AgentSidebar.js +14 -11
- package/dist/app/chat/AgentSidebar.js.map +1 -1
- package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
- package/dist/app/chat/RunStuckBanner.js +43 -6
- package/dist/app/chat/RunStuckBanner.js.map +1 -1
- package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
- package/dist/app/onboarding/FirstRunOnboarding.js +9 -1
- package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
- package/dist/app/sharing/ShareButton.d.ts +2 -0
- package/dist/app/sharing/ShareButton.d.ts.map +1 -1
- package/dist/app/sharing/ShareButton.js +2 -2
- package/dist/app/sharing/ShareButton.js.map +1 -1
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +4 -4
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/package.json +4 -4
- package/src/app/auth/AuthPage.interaction.spec.tsx +10 -1
- package/src/app/auth/AuthPage.tsx +3 -10
- package/src/app/chat/AgentSidebar.spec.tsx +16 -0
- package/src/app/chat/AgentSidebar.tsx +20 -13
- package/src/app/chat/RunStuckBanner.spec.tsx +131 -1
- package/src/app/chat/RunStuckBanner.tsx +54 -8
- package/src/app/onboarding/FirstRunOnboarding.spec.tsx +81 -0
- package/src/app/onboarding/FirstRunOnboarding.tsx +22 -0
- package/src/app/sharing/ShareButton.spec.tsx +22 -0
- package/src/app/sharing/ShareButton.tsx +8 -2
- package/src/composer/TiptapComposer.spec.ts +8 -0
- package/src/composer/TiptapComposer.tsx +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-native/toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.205.0",
|
|
4
4
|
"description": "Reusable app-building UI and helpers for Agent-Native apps.",
|
|
5
5
|
"homepage": "https://github.com/BuilderIO/agent-native#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -673,7 +673,7 @@
|
|
|
673
673
|
"y-protocols": "^1.0.7",
|
|
674
674
|
"yjs": "^13.6.33",
|
|
675
675
|
"zod": "^4.3.6",
|
|
676
|
-
"@agent-native/agentkit": "^0.
|
|
676
|
+
"@agent-native/agentkit": "^0.205.0"
|
|
677
677
|
},
|
|
678
678
|
"devDependencies": {
|
|
679
679
|
"@excalidraw/excalidraw": "0.18.1",
|
|
@@ -697,10 +697,10 @@
|
|
|
697
697
|
"typescript-7": "npm:typescript@^7.0.2",
|
|
698
698
|
"vite": "8.1.0",
|
|
699
699
|
"vitest": "^4.1.5",
|
|
700
|
-
"@agent-native/core": "^0.
|
|
700
|
+
"@agent-native/core": "^0.205.0"
|
|
701
701
|
},
|
|
702
702
|
"peerDependencies": {
|
|
703
|
-
"@agent-native/core": "0.
|
|
703
|
+
"@agent-native/core": ">=0.196.0",
|
|
704
704
|
"@excalidraw/excalidraw": ">=0.18",
|
|
705
705
|
"@excalidraw/mermaid-to-excalidraw": ">=2",
|
|
706
706
|
"@tanstack/react-query": "^5.99.2",
|
|
@@ -290,9 +290,18 @@ describe("AuthPage interactions", () => {
|
|
|
290
290
|
});
|
|
291
291
|
|
|
292
292
|
expect(sendBeacon).toHaveBeenCalled();
|
|
293
|
-
|
|
293
|
+
const sessionId = window.localStorage.getItem("agent-native.session_id");
|
|
294
|
+
expect(sessionId).toBeTruthy();
|
|
295
|
+
for (const [url, body] of sendBeacon.mock.calls as unknown as [
|
|
296
|
+
string,
|
|
297
|
+
string,
|
|
298
|
+
][]) {
|
|
294
299
|
expect(url).toBe("https://beta.analytics.agent-native.com/track");
|
|
300
|
+
expect(JSON.parse(body).sessionId).toBe(sessionId);
|
|
295
301
|
}
|
|
302
|
+
expect(document.cookie).toContain(
|
|
303
|
+
`an_sid=${encodeURIComponent(sessionId!)}`,
|
|
304
|
+
);
|
|
296
305
|
} finally {
|
|
297
306
|
delete (window as Window & { __AGENT_NATIVE_CONFIG__?: unknown })
|
|
298
307
|
.__AGENT_NATIVE_CONFIG__;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** @jsxRuntime classic */
|
|
2
2
|
|
|
3
|
+
import { getAnalyticsSessionId } from "@agent-native/core/client/analytics";
|
|
3
4
|
import { frameworkRoutePrefix } from "@agent-native/core/client/api-path";
|
|
4
5
|
import {
|
|
5
6
|
isAgentNativeDesktop,
|
|
@@ -60,7 +61,6 @@ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
60
61
|
const TAB_STORAGE_KEY = "an.onboarding.tab";
|
|
61
62
|
const PENDING_SIGNUP_EMAIL_STORAGE_KEY = "an.onboarding.pendingSignupEmail";
|
|
62
63
|
const ANALYTICS_ANONYMOUS_ID_KEY = "agent-native.anonymous_id";
|
|
63
|
-
const ANALYTICS_SESSION_ID_KEY = "agent-native.session_id";
|
|
64
64
|
const FIRST_TOUCH_STORAGE_KEY = "an_attribution";
|
|
65
65
|
const FIRST_TOUCH_COOKIE = "an_ft";
|
|
66
66
|
const GOOGLE_AUTH_URL_PATH = "/_agent-native/google/auth-url";
|
|
@@ -265,14 +265,7 @@ function trackAuth(
|
|
|
265
265
|
if (!config?.agentNativeAnalyticsPublicKey) return;
|
|
266
266
|
const anonymousId = readStorage(ANALYTICS_ANONYMOUS_ID_KEY);
|
|
267
267
|
if (!anonymousId) return;
|
|
268
|
-
const sessionId = (
|
|
269
|
-
try {
|
|
270
|
-
return window.sessionStorage.getItem(ANALYTICS_SESSION_ID_KEY) ?? "";
|
|
271
|
-
} catch {
|
|
272
|
-
// coercion-ok: analytics session storage is optional.
|
|
273
|
-
return "";
|
|
274
|
-
}
|
|
275
|
-
})();
|
|
268
|
+
const sessionId = getAnalyticsSessionId();
|
|
276
269
|
const endpoint = resolveLaneEndpoint(
|
|
277
270
|
config.agentNativeAnalyticsEndpoint ??
|
|
278
271
|
"https://analytics.agent-native.com/track",
|
|
@@ -291,7 +284,7 @@ function trackAuth(
|
|
|
291
284
|
event: event.name,
|
|
292
285
|
properties: event.properties,
|
|
293
286
|
anonymousId,
|
|
294
|
-
sessionId
|
|
287
|
+
sessionId,
|
|
295
288
|
timestamp: new Date().toISOString(),
|
|
296
289
|
});
|
|
297
290
|
if (navigator.sendBeacon?.(endpoint, body)) continue;
|
|
@@ -105,6 +105,7 @@ function renderSidebar(
|
|
|
105
105
|
disableChatShortcut = false,
|
|
106
106
|
enabled = true,
|
|
107
107
|
screenRefreshEnabled = true,
|
|
108
|
+
forceOverlay = false,
|
|
108
109
|
) {
|
|
109
110
|
const render = (nextEnabled: boolean) => {
|
|
110
111
|
flushSync(() => {
|
|
@@ -114,6 +115,7 @@ function renderSidebar(
|
|
|
114
115
|
defaultOpen={defaultOpen}
|
|
115
116
|
disableChatShortcut={disableChatShortcut}
|
|
116
117
|
enabled={nextEnabled}
|
|
118
|
+
forceOverlay={forceOverlay}
|
|
117
119
|
position={position}
|
|
118
120
|
screenRefreshEnabled={screenRefreshEnabled}
|
|
119
121
|
>
|
|
@@ -260,6 +262,20 @@ describe("AgentSidebar panel", () => {
|
|
|
260
262
|
expect(composer?.value).toBe("hello");
|
|
261
263
|
});
|
|
262
264
|
|
|
265
|
+
it("can overlay the agent panel above the mobile breakpoint", () => {
|
|
266
|
+
renderSidebar(true, "right", false, true, true, true);
|
|
267
|
+
|
|
268
|
+
const panel = container?.querySelector<HTMLElement>(
|
|
269
|
+
".agent-sidebar-panel[data-agent-sidebar-state='open']",
|
|
270
|
+
);
|
|
271
|
+
expect(panel?.dataset.agentSidebarLayout).toBe("overlay");
|
|
272
|
+
expect(panel?.style.position).toBe("fixed");
|
|
273
|
+
expect(container?.querySelector(".agent-sidebar-backdrop")).toBeTruthy();
|
|
274
|
+
expect(
|
|
275
|
+
container?.querySelector("[data-agent-sidebar-main-state='closed']"),
|
|
276
|
+
).toBeTruthy();
|
|
277
|
+
});
|
|
278
|
+
|
|
263
279
|
it("opens from the global shortcut with the composer available", async () => {
|
|
264
280
|
renderSidebar(false);
|
|
265
281
|
await act(async () => {});
|
|
@@ -249,6 +249,8 @@ export interface AgentSidebarProps {
|
|
|
249
249
|
/** @deprecated Use `defaultSidebarWidth` — this prop is mount-only. */
|
|
250
250
|
sidebarWidth?: number;
|
|
251
251
|
position?: "left" | "right";
|
|
252
|
+
/** Render the open panel over content even above the mobile breakpoint. */
|
|
253
|
+
forceOverlay?: boolean;
|
|
252
254
|
defaultOpen?: boolean;
|
|
253
255
|
disableChatShortcut?: boolean;
|
|
254
256
|
showCollapseButton?: boolean;
|
|
@@ -326,6 +328,7 @@ export function AgentSidebar({
|
|
|
326
328
|
defaultSidebarWidth,
|
|
327
329
|
sidebarWidth,
|
|
328
330
|
position,
|
|
331
|
+
forceOverlay = false,
|
|
329
332
|
defaultOpen,
|
|
330
333
|
disableChatShortcut = false,
|
|
331
334
|
showCollapseButton = true,
|
|
@@ -978,10 +981,12 @@ export function AgentSidebar({
|
|
|
978
981
|
const handleResizeEnd = useCallback(() => setIsResizing(false), []);
|
|
979
982
|
|
|
980
983
|
const isLeft = effectivePosition === "left";
|
|
981
|
-
const
|
|
982
|
-
const
|
|
984
|
+
const isOverlay = isMobile || forceOverlay;
|
|
985
|
+
const wideDrawerEnabled = isWideDrawer && !isOverlay;
|
|
986
|
+
const mobileAnimationEnabled =
|
|
987
|
+
!presentationMode && isOverlay && animateMobile;
|
|
983
988
|
const desktopAnimationEnabled =
|
|
984
|
-
!presentationMode && !
|
|
989
|
+
!presentationMode && !isOverlay && effectiveAnimateDesktop;
|
|
985
990
|
const sidebarAnimationEnabled =
|
|
986
991
|
mobileAnimationEnabled || desktopAnimationEnabled;
|
|
987
992
|
const [renderAnimatedPanel, setRenderAnimatedPanel] =
|
|
@@ -1014,15 +1019,17 @@ export function AgentSidebar({
|
|
|
1014
1019
|
enabled &&
|
|
1015
1020
|
(sidebarAnimationEnabled ? renderAnimatedPanel : shouldMountPanel);
|
|
1016
1021
|
const panelOpen = enabled && open && shouldMountPanel;
|
|
1017
|
-
const panelLayout =
|
|
1018
|
-
?
|
|
1022
|
+
const panelLayout = isOverlay
|
|
1023
|
+
? forceOverlay && !isMobile
|
|
1024
|
+
? "overlay"
|
|
1025
|
+
: "mobile"
|
|
1019
1026
|
: wideDrawerEnabled
|
|
1020
1027
|
? "drawer"
|
|
1021
1028
|
: "desktop";
|
|
1022
|
-
const showResizeHandle = !
|
|
1029
|
+
const showResizeHandle = !isOverlay && !wideDrawerEnabled && panelOpen;
|
|
1023
1030
|
|
|
1024
1031
|
let panelStyle: AgentPanelStyle;
|
|
1025
|
-
if (
|
|
1032
|
+
if (isOverlay) {
|
|
1026
1033
|
panelStyle = {
|
|
1027
1034
|
...AGENT_PANEL_ROOT_STYLE,
|
|
1028
1035
|
position: "fixed",
|
|
@@ -1169,9 +1176,9 @@ export function AgentSidebar({
|
|
|
1169
1176
|
defaultMode={defaultMode}
|
|
1170
1177
|
onCollapse={() => setOpenPersisted(false)}
|
|
1171
1178
|
showCollapseButton={showCollapseButton}
|
|
1172
|
-
onSnapTo75Percent={
|
|
1173
|
-
isWideDrawer={
|
|
1174
|
-
onExitWideDrawer={
|
|
1179
|
+
onSnapTo75Percent={isOverlay ? undefined : snapTo75Percent}
|
|
1180
|
+
isWideDrawer={isOverlay ? false : isWideDrawer}
|
|
1181
|
+
onExitWideDrawer={isOverlay ? undefined : exitWideDrawer}
|
|
1175
1182
|
onFullViewRequest={onFullscreenRequest}
|
|
1176
1183
|
onOpenSettings={onOpenSettings}
|
|
1177
1184
|
onNewCliTab={onNewCliTab}
|
|
@@ -1232,8 +1239,8 @@ export function AgentSidebar({
|
|
|
1232
1239
|
data-agent-native-hosted-chat={isPerAppChatHosted ? "true" : undefined}
|
|
1233
1240
|
data-agent-sidebar-resizing={isResizing ? "true" : undefined}
|
|
1234
1241
|
>
|
|
1235
|
-
{/*
|
|
1236
|
-
{
|
|
1242
|
+
{/* Overlay backdrop — tapping it closes the sidebar */}
|
|
1243
|
+
{isOverlay &&
|
|
1237
1244
|
!isPerAppChatHosted &&
|
|
1238
1245
|
!presentationMode &&
|
|
1239
1246
|
enabled &&
|
|
@@ -1265,7 +1272,7 @@ export function AgentSidebar({
|
|
|
1265
1272
|
className="agent-sidebar-main-surface flex flex-1 flex-col overflow-auto min-w-0"
|
|
1266
1273
|
data-agent-sidebar-main-position={effectivePosition}
|
|
1267
1274
|
data-agent-sidebar-main-state={
|
|
1268
|
-
!
|
|
1275
|
+
!isOverlay && !presentationMode && panelOpen ? "open" : "closed"
|
|
1269
1276
|
}
|
|
1270
1277
|
data-agent-sidebar-resizing={isResizing ? "true" : undefined}
|
|
1271
1278
|
>
|
|
@@ -37,7 +37,11 @@ const hookState = vi.hoisted(() => ({
|
|
|
37
37
|
awaitingResponse: undefined as boolean | undefined,
|
|
38
38
|
}));
|
|
39
39
|
|
|
40
|
-
const abortRunMock = vi.hoisted(() =>
|
|
40
|
+
const abortRunMock = vi.hoisted(() =>
|
|
41
|
+
vi.fn(
|
|
42
|
+
async (_runId: string, _reason?: string): Promise<string | null> => null,
|
|
43
|
+
),
|
|
44
|
+
);
|
|
41
45
|
|
|
42
46
|
vi.mock("@agent-native/core/client/agent-chat", () => ({
|
|
43
47
|
useRunStuckDetection: (options: { awaitingResponse?: boolean }) => {
|
|
@@ -287,6 +291,132 @@ describe("RunStuckBanner", () => {
|
|
|
287
291
|
expect(container.querySelectorAll("button")).toHaveLength(0);
|
|
288
292
|
});
|
|
289
293
|
|
|
294
|
+
describe.each([
|
|
295
|
+
{ action: "retry", label: "agentChat.common.retry" },
|
|
296
|
+
{ action: "cancel", label: "agentChat.common.cancel" },
|
|
297
|
+
])("when the user clicks $action on a stuck run", ({ action, label }) => {
|
|
298
|
+
const button = () =>
|
|
299
|
+
Array.from(container.querySelectorAll("button")).find(
|
|
300
|
+
(candidate) => candidate.textContent === label,
|
|
301
|
+
)!;
|
|
302
|
+
const click = () =>
|
|
303
|
+
act(async () => {
|
|
304
|
+
button().click();
|
|
305
|
+
});
|
|
306
|
+
const bothButtonsEnabled = () =>
|
|
307
|
+
Array.from(container.querySelectorAll("button")).every(
|
|
308
|
+
(candidate) => !candidate.disabled,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
it("keeps both buttons disabled while the abort is in flight", async () => {
|
|
312
|
+
let finishAbort: (runId: string | null) => void = () => {};
|
|
313
|
+
abortRunMock.mockImplementationOnce(
|
|
314
|
+
() =>
|
|
315
|
+
new Promise((resolve) => {
|
|
316
|
+
finishAbort = resolve;
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
await render({ threadId: "thread-1" });
|
|
320
|
+
|
|
321
|
+
await click();
|
|
322
|
+
|
|
323
|
+
expect(abortRunMock).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(button().disabled).toBe(true);
|
|
325
|
+
expect(container.querySelector(".animate-spin")).not.toBeNull();
|
|
326
|
+
await act(async () => finishAbort(null));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("re-enables the buttons when the abort fails", async () => {
|
|
330
|
+
await render({ threadId: "thread-1" });
|
|
331
|
+
|
|
332
|
+
await click();
|
|
333
|
+
|
|
334
|
+
expect(abortRunMock).toHaveBeenCalledWith(
|
|
335
|
+
"run-1",
|
|
336
|
+
`user_stuck_${action}`,
|
|
337
|
+
);
|
|
338
|
+
expect(bothButtonsEnabled()).toBe(true);
|
|
339
|
+
expect(container.querySelector(".animate-spin")).toBeNull();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("aborts again on a second click after a failed abort", async () => {
|
|
343
|
+
await render({ threadId: "thread-1" });
|
|
344
|
+
|
|
345
|
+
await click();
|
|
346
|
+
await click();
|
|
347
|
+
|
|
348
|
+
expect(abortRunMock).toHaveBeenCalledTimes(2);
|
|
349
|
+
expect(bothButtonsEnabled()).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("stays busy once the abort succeeds, until the run is replaced", async () => {
|
|
353
|
+
abortRunMock.mockResolvedValueOnce("run-1");
|
|
354
|
+
const onRetry = vi.fn();
|
|
355
|
+
await render({ threadId: "thread-1", onRetry });
|
|
356
|
+
|
|
357
|
+
await click();
|
|
358
|
+
|
|
359
|
+
expect(onRetry).toHaveBeenCalledTimes(action === "retry" ? 1 : 0);
|
|
360
|
+
expect(button().disabled).toBe(true);
|
|
361
|
+
expect(container.querySelector(".animate-spin")).not.toBeNull();
|
|
362
|
+
|
|
363
|
+
hookState.current = { ...STUCK_STATE, runId: "run-2" };
|
|
364
|
+
await render({ threadId: "thread-1", onRetry });
|
|
365
|
+
expect(bothButtonsEnabled()).toBe(true);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it.each([
|
|
370
|
+
{ remount: false, laterMs: 0, where: "in the same mount" },
|
|
371
|
+
{ remount: true, laterMs: 0, where: "after the chat remounts or reloads" },
|
|
372
|
+
{
|
|
373
|
+
remount: true,
|
|
374
|
+
laterMs: 30 * 60_000,
|
|
375
|
+
where: "after a reload past the five-minute retry lease",
|
|
376
|
+
},
|
|
377
|
+
])(
|
|
378
|
+
"never auto-retries a run the user cancelled, even when the abort failed, $where",
|
|
379
|
+
async ({ remount, laterMs }) => {
|
|
380
|
+
const onRetry = vi.fn();
|
|
381
|
+
const props = {
|
|
382
|
+
threadId: "thread-cancelled",
|
|
383
|
+
autoRetry: true,
|
|
384
|
+
autoRetryOwnerId: "owner-1",
|
|
385
|
+
onRetry,
|
|
386
|
+
};
|
|
387
|
+
// A server-continued dispatch keeps the banner up but defers auto-retry.
|
|
388
|
+
hookState.current = { ...STUCK_STATE, dispatchMode: "background" };
|
|
389
|
+
await render(props);
|
|
390
|
+
expect(abortRunMock).not.toHaveBeenCalled();
|
|
391
|
+
|
|
392
|
+
await act(async () => {
|
|
393
|
+
Array.from(container.querySelectorAll("button"))
|
|
394
|
+
.find(
|
|
395
|
+
(candidate) => candidate.textContent === "agentChat.common.cancel",
|
|
396
|
+
)!
|
|
397
|
+
.click();
|
|
398
|
+
});
|
|
399
|
+
expect(abortRunMock).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(abortRunMock).toHaveBeenCalledWith("run-1", "user_stuck_cancel");
|
|
401
|
+
|
|
402
|
+
if (remount) {
|
|
403
|
+
// A reload drops component state; only the persisted claim remembers.
|
|
404
|
+
await act(async () => root.unmount());
|
|
405
|
+
root = createRoot(container);
|
|
406
|
+
}
|
|
407
|
+
if (laterMs) vi.setSystemTime(Date.now() + laterMs);
|
|
408
|
+
|
|
409
|
+
// The run is still stuck and no longer a continued dispatch: auto-retry is
|
|
410
|
+
// eligible again, but the user already acted on it.
|
|
411
|
+
hookState.current = { ...STUCK_STATE };
|
|
412
|
+
await render(props);
|
|
413
|
+
await act(async () => {});
|
|
414
|
+
|
|
415
|
+
expect(abortRunMock).toHaveBeenCalledTimes(1);
|
|
416
|
+
expect(onRetry).not.toHaveBeenCalled();
|
|
417
|
+
},
|
|
418
|
+
);
|
|
419
|
+
|
|
290
420
|
it("does not auto-abort a run on a stuck verdict it cannot confirm", async () => {
|
|
291
421
|
const props = {
|
|
292
422
|
threadId: "thread-auto-retry",
|
|
@@ -33,6 +33,9 @@ export type RunReconcileOutcome = "settled" | "still_running";
|
|
|
33
33
|
|
|
34
34
|
const RECONCILE_RETRY_MS = 30_000;
|
|
35
35
|
const AUTO_RETRY_CLAIM_TTL_MS = 5 * 60 * 1000;
|
|
36
|
+
// A user's Cancel or Retry outlives the short retry lease: a stuck run stays
|
|
37
|
+
// stuck for as long as it takes someone to reload the page.
|
|
38
|
+
const USER_ACTION_CLAIM_TTL_MS = 6 * 60 * 60 * 1000;
|
|
36
39
|
const BACKGROUND_WORKER_FRESH_HEARTBEAT_MS = 30_000;
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -43,6 +46,14 @@ const reportedStuckRunIds = new Set<string>();
|
|
|
43
46
|
|
|
44
47
|
type BusyState = { type: "none" } | { type: "cancel" | "retry"; runId: string };
|
|
45
48
|
|
|
49
|
+
// Only the click that set busy for `runId` may clear it.
|
|
50
|
+
const releaseBusy =
|
|
51
|
+
(runId: string) =>
|
|
52
|
+
(current: BusyState): BusyState =>
|
|
53
|
+
current.type !== "none" && current.runId === runId
|
|
54
|
+
? { type: "none" }
|
|
55
|
+
: current;
|
|
56
|
+
|
|
46
57
|
type MaybeLockManager = {
|
|
47
58
|
request<T>(
|
|
48
59
|
name: string,
|
|
@@ -100,6 +111,32 @@ function markAutoRetryClaim(key: string, ownerId: string) {
|
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
/**
|
|
115
|
+
* A run the user cancelled or retried must stay out of auto-retry across a
|
|
116
|
+
* reload too: the claim every owner checks is the one place that survives it, so
|
|
117
|
+
* the click takes the claim for itself. Best effort, like the claim itself.
|
|
118
|
+
*/
|
|
119
|
+
function markUserActedOnRun(
|
|
120
|
+
threadId: string | null | undefined,
|
|
121
|
+
runId: string,
|
|
122
|
+
) {
|
|
123
|
+
if (!threadId || typeof window === "undefined") return;
|
|
124
|
+
try {
|
|
125
|
+
window.localStorage.setItem(
|
|
126
|
+
autoRetryClaimKey(threadId, runId),
|
|
127
|
+
JSON.stringify({
|
|
128
|
+
ownerId: "user-action",
|
|
129
|
+
expiresAt: Date.now() + USER_ACTION_CLAIM_TTL_MS,
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.warn(
|
|
134
|
+
"[agent-chat] could not persist the stuck-run user action; it only holds until reload:",
|
|
135
|
+
error instanceof Error ? error.message : String(error),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
103
140
|
async function claimAutoRetryAttempt(
|
|
104
141
|
threadId: string | null | undefined,
|
|
105
142
|
runId: string,
|
|
@@ -152,6 +189,9 @@ export function RunStuckBanner({
|
|
|
152
189
|
const [busy, setBusy] = useState<BusyState>({ type: "none" });
|
|
153
190
|
const [autoRetriedRunId, setAutoRetriedRunId] = useState<string | null>(null);
|
|
154
191
|
const autoRetriedRunIdsRef = useRef<Set<string>>(new Set());
|
|
192
|
+
// A run the user already acted on is never auto-retried: a failed Cancel frees
|
|
193
|
+
// `busy` again, and an auto-retry gated on other state must not then resume it.
|
|
194
|
+
const userActedRunIdsRef = useRef<Set<string>>(new Set());
|
|
155
195
|
const generatedOwnerIdRef = useRef<string | null>(null);
|
|
156
196
|
if (!generatedOwnerIdRef.current) {
|
|
157
197
|
generatedOwnerIdRef.current = createAutoRetryOwnerId();
|
|
@@ -274,7 +314,8 @@ export function RunStuckBanner({
|
|
|
274
314
|
!isStuck ||
|
|
275
315
|
!state.runId ||
|
|
276
316
|
busy.type !== "none" ||
|
|
277
|
-
autoRetriedRunIdsRef.current.has(state.runId)
|
|
317
|
+
autoRetriedRunIdsRef.current.has(state.runId) ||
|
|
318
|
+
userActedRunIdsRef.current.has(state.runId)
|
|
278
319
|
) {
|
|
279
320
|
return;
|
|
280
321
|
}
|
|
@@ -282,7 +323,7 @@ export function RunStuckBanner({
|
|
|
282
323
|
const runId = state.runId;
|
|
283
324
|
void claimAutoRetryAttempt(threadId, runId, ownerId).then((claimed) => {
|
|
284
325
|
autoRetriedRunIdsRef.current.add(runId);
|
|
285
|
-
if (!claimed) return;
|
|
326
|
+
if (!claimed || userActedRunIdsRef.current.has(runId)) return;
|
|
286
327
|
setBusy({ type: "retry", runId });
|
|
287
328
|
setAutoRetriedRunId(runId);
|
|
288
329
|
trackEvent("agent_chat_stuck_auto_retry", {
|
|
@@ -291,11 +332,7 @@ export function RunStuckBanner({
|
|
|
291
332
|
stuckSinceMs: state.stuckSinceMs ?? null,
|
|
292
333
|
});
|
|
293
334
|
void abortRun(runId, "auto_stuck_retry").then((aborted) => {
|
|
294
|
-
setBusy((
|
|
295
|
-
current.type !== "none" && current.runId === runId
|
|
296
|
-
? { type: "none" }
|
|
297
|
-
: current,
|
|
298
|
-
);
|
|
335
|
+
setBusy(releaseBusy(runId));
|
|
299
336
|
if (aborted) onRetry?.(aborted);
|
|
300
337
|
});
|
|
301
338
|
});
|
|
@@ -357,13 +394,19 @@ export function RunStuckBanner({
|
|
|
357
394
|
const handleCancel = async () => {
|
|
358
395
|
if (!state.runId || busy.type !== "none") return;
|
|
359
396
|
const runId = state.runId;
|
|
397
|
+
userActedRunIdsRef.current.add(runId);
|
|
398
|
+
markUserActedOnRun(threadId, runId);
|
|
360
399
|
setBusy({ type: "cancel", runId });
|
|
361
400
|
trackEvent("agent_chat_stuck_cancel", {
|
|
362
401
|
runId,
|
|
363
402
|
threadId: threadId ?? null,
|
|
364
403
|
stuckSinceMs: state.stuckSinceMs ?? null,
|
|
365
404
|
});
|
|
366
|
-
|
|
405
|
+
// A replaced run clears busy through the state effect; a failed abort never
|
|
406
|
+
// replaces it, so the buttons would stay disabled for the same stuck run.
|
|
407
|
+
if (!(await abortRun(runId, "user_stuck_cancel"))) {
|
|
408
|
+
setBusy(releaseBusy(runId));
|
|
409
|
+
}
|
|
367
410
|
};
|
|
368
411
|
|
|
369
412
|
const handleRetry = async () => {
|
|
@@ -376,6 +419,8 @@ export function RunStuckBanner({
|
|
|
376
419
|
return;
|
|
377
420
|
}
|
|
378
421
|
const runId = state.runId;
|
|
422
|
+
userActedRunIdsRef.current.add(runId);
|
|
423
|
+
markUserActedOnRun(threadId, runId);
|
|
379
424
|
setBusy({ type: "retry", runId });
|
|
380
425
|
trackEvent("agent_chat_stuck_retry", {
|
|
381
426
|
runId,
|
|
@@ -384,6 +429,7 @@ export function RunStuckBanner({
|
|
|
384
429
|
});
|
|
385
430
|
const aborted = await abortRun(runId, "user_stuck_retry");
|
|
386
431
|
if (aborted) onRetry?.(aborted);
|
|
432
|
+
else setBusy(releaseBusy(runId));
|
|
387
433
|
};
|
|
388
434
|
|
|
389
435
|
const busyType = busy.type;
|
|
@@ -1302,6 +1302,9 @@ describe("FirstRunOnboarding", () => {
|
|
|
1302
1302
|
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1303
1303
|
});
|
|
1304
1304
|
|
|
1305
|
+
expect(
|
|
1306
|
+
document.body.querySelector("[data-testid='first-run-setup-skip']"),
|
|
1307
|
+
).toBeNull();
|
|
1305
1308
|
expect(completedSteps()).toEqual([]);
|
|
1306
1309
|
expect(skippedSteps()).toEqual(["role"]);
|
|
1307
1310
|
|
|
@@ -1333,6 +1336,84 @@ describe("FirstRunOnboarding", () => {
|
|
|
1333
1336
|
window.history.replaceState(null, "", "/");
|
|
1334
1337
|
});
|
|
1335
1338
|
|
|
1339
|
+
it("lets Clips skip provider setup and finish first-run onboarding", async () => {
|
|
1340
|
+
let resolveCompletion: (() => void) | undefined;
|
|
1341
|
+
mocks.completeFirstRun.mockImplementation(
|
|
1342
|
+
() =>
|
|
1343
|
+
new Promise<void>((resolve) => {
|
|
1344
|
+
resolveCompletion = resolve;
|
|
1345
|
+
}),
|
|
1346
|
+
);
|
|
1347
|
+
mocks.useOnboarding.mockReturnValue({
|
|
1348
|
+
firstRun: true,
|
|
1349
|
+
loading: false,
|
|
1350
|
+
error: null,
|
|
1351
|
+
profile: {
|
|
1352
|
+
appId: "clips",
|
|
1353
|
+
appName: "Clips",
|
|
1354
|
+
capabilities: [],
|
|
1355
|
+
},
|
|
1356
|
+
completeFirstRun: mocks.completeFirstRun,
|
|
1357
|
+
completeFirstRunError: null,
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
await act(async () => {
|
|
1361
|
+
root.render(
|
|
1362
|
+
<TooltipProvider>
|
|
1363
|
+
<FirstRunOnboarding />
|
|
1364
|
+
</TooltipProvider>,
|
|
1365
|
+
);
|
|
1366
|
+
await Promise.resolve();
|
|
1367
|
+
});
|
|
1368
|
+
act(() => {
|
|
1369
|
+
document.body
|
|
1370
|
+
.querySelector("[data-testid='first-run-role-skip']")
|
|
1371
|
+
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
const skipSetupButton = document.body.querySelector(
|
|
1375
|
+
"[data-testid='first-run-setup-skip']",
|
|
1376
|
+
);
|
|
1377
|
+
expect(skipSetupButton?.textContent).toBe("Skip for now");
|
|
1378
|
+
|
|
1379
|
+
act(() => {
|
|
1380
|
+
skipSetupButton?.dispatchEvent(
|
|
1381
|
+
new MouseEvent("click", { bubbles: true }),
|
|
1382
|
+
);
|
|
1383
|
+
skipSetupButton?.dispatchEvent(
|
|
1384
|
+
new MouseEvent("click", { bubbles: true }),
|
|
1385
|
+
);
|
|
1386
|
+
});
|
|
1387
|
+
expect(mocks.completeFirstRun).toHaveBeenCalledOnce();
|
|
1388
|
+
expect(
|
|
1389
|
+
mocks.trackOnboardingEvent.mock.calls.filter(
|
|
1390
|
+
([event, properties]) =>
|
|
1391
|
+
event === "onboarding_step_skipped" &&
|
|
1392
|
+
(properties as Record<string, unknown>).step_id === "choice",
|
|
1393
|
+
),
|
|
1394
|
+
).toHaveLength(1);
|
|
1395
|
+
|
|
1396
|
+
await act(async () => {
|
|
1397
|
+
resolveCompletion?.();
|
|
1398
|
+
await Promise.resolve();
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
expect(mocks.completeFirstRun).toHaveBeenCalledOnce();
|
|
1402
|
+
expect(window.location.pathname).toBe("/");
|
|
1403
|
+
expect(mocks.trackOnboardingEvent).toHaveBeenCalledWith(
|
|
1404
|
+
"onboarding_step_skipped",
|
|
1405
|
+
expect.objectContaining({
|
|
1406
|
+
flow: "first_run",
|
|
1407
|
+
step_id: "choice",
|
|
1408
|
+
reason: "user_action",
|
|
1409
|
+
}),
|
|
1410
|
+
);
|
|
1411
|
+
expect(mocks.trackOnboardingEvent).not.toHaveBeenCalledWith(
|
|
1412
|
+
"onboarding_method_clicked",
|
|
1413
|
+
expect.anything(),
|
|
1414
|
+
);
|
|
1415
|
+
});
|
|
1416
|
+
|
|
1336
1417
|
it("does not start duplicate manual setup attempts while completion is pending", async () => {
|
|
1337
1418
|
let resolveCompletion: (() => void) | undefined;
|
|
1338
1419
|
mocks.completeFirstRun.mockImplementation(
|
|
@@ -303,6 +303,7 @@ export function FirstRunOnboarding({
|
|
|
303
303
|
extensionIndex: number;
|
|
304
304
|
} | null>(null);
|
|
305
305
|
const completionInFlightRef = useRef(false);
|
|
306
|
+
const setupSkipStartedRef = useRef(false);
|
|
306
307
|
const onboardingTerminalRef = useRef(false);
|
|
307
308
|
const abandonmentTrackedRef = useRef(false);
|
|
308
309
|
const setupAttemptRef = useRef<FirstRunSetupAttempt | null>(null);
|
|
@@ -824,6 +825,27 @@ export function FirstRunOnboarding({
|
|
|
824
825
|
</button>
|
|
825
826
|
</section>
|
|
826
827
|
</div>
|
|
828
|
+
{profile.appId === "clips" && (
|
|
829
|
+
<div className="flex justify-center">
|
|
830
|
+
<button
|
|
831
|
+
type="button"
|
|
832
|
+
data-testid="first-run-setup-skip"
|
|
833
|
+
className="inline-flex min-h-9 items-center justify-center rounded-lg px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
834
|
+
onClick={() => {
|
|
835
|
+
if (
|
|
836
|
+
setupSkipStartedRef.current ||
|
|
837
|
+
completionInFlightRef.current
|
|
838
|
+
)
|
|
839
|
+
return;
|
|
840
|
+
setupSkipStartedRef.current = true;
|
|
841
|
+
trackFirstRunStepSkipped("choice");
|
|
842
|
+
handleFinish(null);
|
|
843
|
+
}}
|
|
844
|
+
>
|
|
845
|
+
{t("agentChat.onboarding.skipForNow")}
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
)}
|
|
827
849
|
</div>
|
|
828
850
|
<p className="text-center text-xs leading-5 text-muted-foreground">
|
|
829
851
|
{t("agentChat.onboarding.builderConsentPrefix")}{" "}
|
|
@@ -440,6 +440,28 @@ describe("ShareButton", () => {
|
|
|
440
440
|
).toBe("recover-me@example.com");
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
it("opens without a trigger when invoked from an external menu", async () => {
|
|
444
|
+
popoverTestState.simulateMounting = true;
|
|
445
|
+
|
|
446
|
+
await act(async () => {
|
|
447
|
+
root.render(
|
|
448
|
+
<QueryClientProvider client={queryClient}>
|
|
449
|
+
<ShareButton
|
|
450
|
+
resourceType="document"
|
|
451
|
+
resourceId="doc-1"
|
|
452
|
+
defaultOpen
|
|
453
|
+
hideTrigger
|
|
454
|
+
/>
|
|
455
|
+
</QueryClientProvider>,
|
|
456
|
+
);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
expect(container.querySelector('button[aria-label="Share"]')).toBeNull();
|
|
460
|
+
expect(
|
|
461
|
+
container.querySelector("[data-agent-native-share-overlay]"),
|
|
462
|
+
).not.toBeNull();
|
|
463
|
+
});
|
|
464
|
+
|
|
443
465
|
it("shows the copy action for share URLs regardless of visibility", async () => {
|
|
444
466
|
await act(async () => {
|
|
445
467
|
root.render(
|