@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.
Files changed (34) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/dist/app/auth/AuthPage.d.ts.map +1 -1
  3. package/dist/app/auth/AuthPage.js +3 -11
  4. package/dist/app/auth/AuthPage.js.map +1 -1
  5. package/dist/app/chat/AgentSidebar.d.ts +3 -1
  6. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  7. package/dist/app/chat/AgentSidebar.js +14 -11
  8. package/dist/app/chat/AgentSidebar.js.map +1 -1
  9. package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
  10. package/dist/app/chat/RunStuckBanner.js +43 -6
  11. package/dist/app/chat/RunStuckBanner.js.map +1 -1
  12. package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
  13. package/dist/app/onboarding/FirstRunOnboarding.js +9 -1
  14. package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
  15. package/dist/app/sharing/ShareButton.d.ts +2 -0
  16. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  17. package/dist/app/sharing/ShareButton.js +2 -2
  18. package/dist/app/sharing/ShareButton.js.map +1 -1
  19. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  20. package/dist/composer/TiptapComposer.js +4 -4
  21. package/dist/composer/TiptapComposer.js.map +1 -1
  22. package/package.json +4 -4
  23. package/src/app/auth/AuthPage.interaction.spec.tsx +10 -1
  24. package/src/app/auth/AuthPage.tsx +3 -10
  25. package/src/app/chat/AgentSidebar.spec.tsx +16 -0
  26. package/src/app/chat/AgentSidebar.tsx +20 -13
  27. package/src/app/chat/RunStuckBanner.spec.tsx +131 -1
  28. package/src/app/chat/RunStuckBanner.tsx +54 -8
  29. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +81 -0
  30. package/src/app/onboarding/FirstRunOnboarding.tsx +22 -0
  31. package/src/app/sharing/ShareButton.spec.tsx +22 -0
  32. package/src/app/sharing/ShareButton.tsx +8 -2
  33. package/src/composer/TiptapComposer.spec.ts +8 -0
  34. 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.204.1-nightly-20261008153353",
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.204.1-nightly-20261008153353"
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.204.1-nightly-20261008153353"
700
+ "@agent-native/core": "^0.205.0"
701
701
  },
702
702
  "peerDependencies": {
703
- "@agent-native/core": "0.204.1-nightly-20261008153353",
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
- for (const [url] of sendBeacon.mock.calls as unknown as [string][]) {
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: sessionId || undefined,
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 wideDrawerEnabled = isWideDrawer && !isMobile;
982
- const mobileAnimationEnabled = !presentationMode && isMobile && animateMobile;
984
+ const isOverlay = isMobile || forceOverlay;
985
+ const wideDrawerEnabled = isWideDrawer && !isOverlay;
986
+ const mobileAnimationEnabled =
987
+ !presentationMode && isOverlay && animateMobile;
983
988
  const desktopAnimationEnabled =
984
- !presentationMode && !isMobile && effectiveAnimateDesktop;
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 = isMobile
1018
- ? "mobile"
1022
+ const panelLayout = isOverlay
1023
+ ? forceOverlay && !isMobile
1024
+ ? "overlay"
1025
+ : "mobile"
1019
1026
  : wideDrawerEnabled
1020
1027
  ? "drawer"
1021
1028
  : "desktop";
1022
- const showResizeHandle = !isMobile && !wideDrawerEnabled && panelOpen;
1029
+ const showResizeHandle = !isOverlay && !wideDrawerEnabled && panelOpen;
1023
1030
 
1024
1031
  let panelStyle: AgentPanelStyle;
1025
- if (isMobile) {
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={isMobile ? undefined : snapTo75Percent}
1173
- isWideDrawer={isMobile ? false : isWideDrawer}
1174
- onExitWideDrawer={isMobile ? undefined : exitWideDrawer}
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
- {/* Mobile backdrop — tapping it closes the sidebar */}
1236
- {isMobile &&
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
- !isMobile && !presentationMode && panelOpen ? "open" : "closed"
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(() => vi.fn(async () => null));
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((current) =>
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
- await abortRun(runId, "user_stuck_cancel");
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(