@agent-native/toolkit 0.209.1 → 0.209.2-nightly-20261016005836

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 (112) hide show
  1. package/agent-native.eject.json +3 -0
  2. package/dist/app/chat/AgentPanel.d.ts.map +1 -1
  3. package/dist/app/chat/AgentPanel.js +8 -3
  4. package/dist/app/chat/AgentPanel.js.map +1 -1
  5. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  6. package/dist/app/chat/AgentSidebar.js +2 -0
  7. package/dist/app/chat/AgentSidebar.js.map +1 -1
  8. package/dist/app/chat/FileStorageSetupPopover.d.ts.map +1 -1
  9. package/dist/app/chat/FileStorageSetupPopover.js +11 -2
  10. package/dist/app/chat/FileStorageSetupPopover.js.map +1 -1
  11. package/dist/app/chat/agent-chat-presence.d.ts +3 -0
  12. package/dist/app/chat/agent-chat-presence.d.ts.map +1 -0
  13. package/dist/app/chat/agent-chat-presence.js +24 -0
  14. package/dist/app/chat/agent-chat-presence.js.map +1 -0
  15. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  16. package/dist/app/chat/chat/run-recovery.js +15 -3
  17. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  18. package/dist/app/feedback/ReportProblemButton.d.ts +6 -0
  19. package/dist/app/feedback/ReportProblemButton.d.ts.map +1 -0
  20. package/dist/app/feedback/ReportProblemButton.js +17 -0
  21. package/dist/app/feedback/ReportProblemButton.js.map +1 -0
  22. package/dist/app/feedback/ReportProblemDialog.d.ts +6 -0
  23. package/dist/app/feedback/ReportProblemDialog.d.ts.map +1 -0
  24. package/dist/app/feedback/ReportProblemDialog.js +90 -0
  25. package/dist/app/feedback/ReportProblemDialog.js.map +1 -0
  26. package/dist/app/feedback/index.d.ts +1 -0
  27. package/dist/app/feedback/index.d.ts.map +1 -1
  28. package/dist/app/feedback/index.js +1 -0
  29. package/dist/app/feedback/index.js.map +1 -1
  30. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  31. package/dist/app/i18n/catalogs/ar-SA.js +13 -0
  32. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  33. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  34. package/dist/app/i18n/catalogs/de-DE.js +9 -0
  35. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  36. package/dist/app/i18n/catalogs/en-US.d.ts +9 -0
  37. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  38. package/dist/app/i18n/catalogs/en-US.js +9 -0
  39. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  40. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  41. package/dist/app/i18n/catalogs/es-ES.js +10 -0
  42. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  43. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  44. package/dist/app/i18n/catalogs/fr-FR.js +10 -0
  45. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  46. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  47. package/dist/app/i18n/catalogs/hi-IN.js +9 -0
  48. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  49. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  50. package/dist/app/i18n/catalogs/ja-JP.js +8 -0
  51. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  52. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  53. package/dist/app/i18n/catalogs/ko-KR.js +8 -0
  54. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  55. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  56. package/dist/app/i18n/catalogs/pt-BR.js +10 -0
  57. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  58. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  59. package/dist/app/i18n/catalogs/zh-CN.js +8 -0
  60. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  61. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  62. package/dist/app/i18n/catalogs/zh-TW.js +8 -0
  63. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  64. package/dist/app/settings/BuilderConnectPopover.d.ts.map +1 -1
  65. package/dist/app/settings/BuilderConnectPopover.js +13 -7
  66. package/dist/app/settings/BuilderConnectPopover.js.map +1 -1
  67. package/dist/app/shared/AppSidebar.d.ts.map +1 -1
  68. package/dist/app/shared/AppSidebar.js +11 -3
  69. package/dist/app/shared/AppSidebar.js.map +1 -1
  70. package/dist/app/shared/CommandMenu.d.ts.map +1 -1
  71. package/dist/app/shared/CommandMenu.js +21 -4
  72. package/dist/app/shared/CommandMenu.js.map +1 -1
  73. package/dist/design-tweaks/scrub-input.d.ts +6 -0
  74. package/dist/design-tweaks/scrub-input.d.ts.map +1 -1
  75. package/dist/design-tweaks/scrub-input.js +12 -0
  76. package/dist/design-tweaks/scrub-input.js.map +1 -1
  77. package/dist/design-tweaks/visual-style-controls.d.ts +1 -1
  78. package/dist/design-tweaks/visual-style-controls.d.ts.map +1 -1
  79. package/dist/design-tweaks/visual-style-controls.js +1 -1
  80. package/dist/design-tweaks/visual-style-controls.js.map +1 -1
  81. package/package.json +4 -4
  82. package/src/app/chat/AgentPanel.header.spec.ts +1 -1
  83. package/src/app/chat/AgentPanel.tsx +23 -0
  84. package/src/app/chat/AgentSidebar.tsx +2 -0
  85. package/src/app/chat/FileStorageSetupPopover.spec.tsx +66 -5
  86. package/src/app/chat/FileStorageSetupPopover.tsx +15 -6
  87. package/src/app/chat/agent-chat-presence.ts +30 -0
  88. package/src/app/chat/chat/run-recovery.spec.tsx +60 -0
  89. package/src/app/chat/chat/run-recovery.tsx +28 -2
  90. package/src/app/feedback/ReportProblemButton.tsx +59 -0
  91. package/src/app/feedback/ReportProblemDialog.spec.tsx +190 -0
  92. package/src/app/feedback/ReportProblemDialog.tsx +212 -0
  93. package/src/app/feedback/index.ts +4 -0
  94. package/src/app/i18n/catalogs/ar-SA.ts +20 -0
  95. package/src/app/i18n/catalogs/de-DE.ts +12 -0
  96. package/src/app/i18n/catalogs/en-US.ts +12 -0
  97. package/src/app/i18n/catalogs/es-ES.ts +14 -0
  98. package/src/app/i18n/catalogs/fr-FR.ts +14 -0
  99. package/src/app/i18n/catalogs/hi-IN.ts +12 -0
  100. package/src/app/i18n/catalogs/ja-JP.ts +10 -0
  101. package/src/app/i18n/catalogs/ko-KR.ts +10 -0
  102. package/src/app/i18n/catalogs/pt-BR.ts +14 -0
  103. package/src/app/i18n/catalogs/zh-CN.ts +9 -0
  104. package/src/app/i18n/catalogs/zh-TW.ts +10 -0
  105. package/src/app/settings/BuilderConnectPopover.spec.tsx +19 -14
  106. package/src/app/settings/BuilderConnectPopover.tsx +80 -64
  107. package/src/app/shared/AppSidebar.spec.tsx +22 -1
  108. package/src/app/shared/AppSidebar.tsx +24 -3
  109. package/src/app/shared/CommandMenu.tsx +41 -1
  110. package/src/design-tweaks/scrub-input.tsx +19 -0
  111. package/src/design-tweaks/visual-style-controls.spec.tsx +31 -0
  112. package/src/design-tweaks/visual-style-controls.tsx +2 -0
@@ -10,6 +10,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
10
 
11
11
  import { FileStorageSetupPopover } from "./FileStorageSetupPopover.js";
12
12
 
13
+ const setupMocks = vi.hoisted(() => ({
14
+ connectFlow: null as null | {
15
+ connecting: boolean;
16
+ start: (options: { provisionAccount: boolean }) => void;
17
+ cancel?: () => void;
18
+ },
19
+ }));
20
+
13
21
  vi.mock("@agent-native/core/client/i18n", () => ({
14
22
  useT: () => (key: string) =>
15
23
  (
@@ -19,6 +27,7 @@ vi.mock("@agent-native/core/client/i18n", () => ({
19
27
  "Use Builder.io (free) or configure your own S3-compatible object storage.",
20
28
  "onboarding.fileStorage.custom": "Use custom keys",
21
29
  "composer.connectBuilder": "Connect Builder.io",
30
+ "common.cancel": "Cancel",
22
31
  }) as Record<string, string>
23
32
  )[key] ?? key,
24
33
  }));
@@ -56,9 +65,20 @@ vi.mock("@tabler/icons-react", () => ({ IconCloudUpload: () => null }));
56
65
  vi.mock("../settings/deferred-builder-connect-popover.js", () => ({
57
66
  DeferredBuilderConnectPopover: ({
58
67
  children,
68
+ flow,
59
69
  }: {
60
70
  children: React.ReactNode;
61
- }) => children,
71
+ flow: { cancel?: () => void; connecting: boolean };
72
+ }) => (
73
+ <div>
74
+ {children}
75
+ {flow.connecting ? (
76
+ <button type="button" onClick={flow.cancel}>
77
+ Cancel
78
+ </button>
79
+ ) : null}
80
+ </div>
81
+ ),
62
82
  }));
63
83
 
64
84
  vi.mock("../setup-connections/BuilderConnectCard.js", () => ({
@@ -66,11 +86,19 @@ vi.mock("../setup-connections/BuilderConnectCard.js", () => ({
66
86
  render,
67
87
  }: {
68
88
  render: (context: {
69
- viewModel: { connectFlow: null; pending: false; error: null };
89
+ viewModel: {
90
+ connectFlow: typeof setupMocks.connectFlow;
91
+ pending: false;
92
+ error: null;
93
+ };
70
94
  }) => React.ReactNode;
71
95
  }) =>
72
96
  render({
73
- viewModel: { connectFlow: null, pending: false, error: null },
97
+ viewModel: {
98
+ connectFlow: setupMocks.connectFlow,
99
+ pending: false,
100
+ error: null,
101
+ },
74
102
  }),
75
103
  }));
76
104
 
@@ -80,6 +108,7 @@ describe("FileStorageSetupPopover", () => {
80
108
 
81
109
  beforeEach(() => {
82
110
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
111
+ setupMocks.connectFlow = null;
83
112
  container = document.createElement("div");
84
113
  document.body.appendChild(container);
85
114
  root = createRoot(container);
@@ -122,8 +151,40 @@ describe("FileStorageSetupPopover", () => {
122
151
  expect(container.querySelector("h2")?.parentElement?.className).toBe(
123
152
  "grid gap-1",
124
153
  );
125
- expect(container.querySelector("button")?.parentElement?.className).toBe(
126
- "flex gap-2",
154
+ const choices = container.querySelector("button")?.parentElement;
155
+ expect(choices?.className).toBe("grid grid-cols-2 gap-2");
156
+ expect(choices?.querySelectorAll("button")).toHaveLength(2);
157
+ for (const button of choices?.querySelectorAll("button") ?? []) {
158
+ expect(button.className).toContain("whitespace-normal");
159
+ }
160
+ });
161
+
162
+ it("closes the storage popover immediately when Builder setup is canceled", () => {
163
+ const cancel = vi.fn();
164
+ setupMocks.connectFlow = {
165
+ connecting: true,
166
+ start: vi.fn(),
167
+ cancel,
168
+ };
169
+ const onOpenChange = vi.fn();
170
+
171
+ act(() => {
172
+ root.render(
173
+ <FileStorageSetupPopover
174
+ open
175
+ onOpenChange={onOpenChange}
176
+ status="missing"
177
+ />,
178
+ );
179
+ });
180
+
181
+ const cancelButton = [...container.querySelectorAll("button")].find(
182
+ (button) => button.textContent === "Cancel",
127
183
  );
184
+ expect(cancelButton).toBeDefined();
185
+ act(() => cancelButton?.click());
186
+
187
+ expect(cancel).toHaveBeenCalledOnce();
188
+ expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false, "dismiss");
128
189
  });
129
190
  });
@@ -127,11 +127,20 @@ export function FileStorageSetupPopover(props: FileStorageSetupPopoverProps) {
127
127
  onConnected={onConnected}
128
128
  render={({ viewModel }) => {
129
129
  const flow = viewModel.connectFlow;
130
+ const connectFlow = flow
131
+ ? {
132
+ ...flow,
133
+ cancel: () => {
134
+ flow.cancel?.();
135
+ onOpenChange(false, "dismiss");
136
+ },
137
+ }
138
+ : null;
130
139
  const connectButton = (
131
140
  <Button
132
141
  type="button"
133
142
  size="sm"
134
- className="min-w-0 flex-1 px-2 text-xs"
143
+ className="h-auto min-h-9 w-full min-w-0 break-words whitespace-normal px-2 py-2 text-center text-xs leading-4"
135
144
  disabled={!flow || viewModel.pending}
136
145
  aria-busy={viewModel.pending}
137
146
  >
@@ -147,12 +156,12 @@ export function FileStorageSetupPopover(props: FileStorageSetupPopoverProps) {
147
156
  {description}
148
157
  </p>
149
158
  </div>
150
- <div className="flex gap-2">
151
- {flow ? (
159
+ <div className="grid grid-cols-2 gap-2">
160
+ {connectFlow ? (
152
161
  <BuilderConnectPopover
153
- flow={flow}
162
+ flow={connectFlow}
154
163
  onConnect={(provisionAccount) =>
155
- flow.start({ provisionAccount })
164
+ connectFlow.start({ provisionAccount })
156
165
  }
157
166
  >
158
167
  {connectButton}
@@ -164,7 +173,7 @@ export function FileStorageSetupPopover(props: FileStorageSetupPopoverProps) {
164
173
  type="button"
165
174
  size="sm"
166
175
  variant="outline"
167
- className="min-w-0 flex-1 px-2 text-xs"
176
+ className="h-auto min-h-9 w-full min-w-0 break-words whitespace-normal px-2 py-2 text-center text-xs leading-4"
168
177
  onClick={() => {
169
178
  onOpenChange(false, "setup");
170
179
  if (typeof window !== "undefined") {
@@ -0,0 +1,30 @@
1
+ import { useEffect, useSyncExternalStore } from "react";
2
+
3
+ let mountedSurfaces = 0;
4
+ const listeners = new Set<() => void>();
5
+
6
+ function subscribe(listener: () => void): () => void {
7
+ listeners.add(listener);
8
+ return () => listeners.delete(listener);
9
+ }
10
+
11
+ function setMountedSurfaces(next: number): void {
12
+ mountedSurfaces = next;
13
+ for (const listener of listeners) listener();
14
+ }
15
+
16
+ export function useRegisterAgentChatSurface(enabled: boolean): void {
17
+ useEffect(() => {
18
+ if (!enabled) return;
19
+ setMountedSurfaces(mountedSurfaces + 1);
20
+ return () => setMountedSurfaces(mountedSurfaces - 1);
21
+ }, [enabled]);
22
+ }
23
+
24
+ export function useHasAgentChat(): boolean {
25
+ return useSyncExternalStore(
26
+ subscribe,
27
+ () => mountedSurfaces > 0,
28
+ () => false,
29
+ );
30
+ }
@@ -700,6 +700,66 @@ describe("run recovery surfaces", () => {
700
700
  expect(newChatButton?.textContent).toBe("");
701
701
  });
702
702
 
703
+ it.each([
704
+ "http_429",
705
+ "http_529",
706
+ "provider_rate_limited",
707
+ "provider_transient_rejection",
708
+ ])("shows a visible Retry action for %s", async (errorCode) => {
709
+ const onRetry = vi.fn();
710
+
711
+ await act(async () => {
712
+ root.render(
713
+ <AgentNativeI18nProvider
714
+ initialLocale="en-US"
715
+ initialPreference="en-US"
716
+ persistPreference={false}
717
+ >
718
+ <RunErrorRecoveryCard
719
+ info={{
720
+ message: "The model provider temporarily rejected this request.",
721
+ errorCode,
722
+ }}
723
+ onContinue={vi.fn()}
724
+ onRetry={onRetry}
725
+ onDismiss={vi.fn()}
726
+ />
727
+ </AgentNativeI18nProvider>,
728
+ );
729
+ });
730
+
731
+ const retryButton = container.querySelector<HTMLButtonElement>(
732
+ 'button[aria-label="Retry"]',
733
+ );
734
+ expect(retryButton?.textContent?.trim()).toBe("Retry");
735
+ await act(async () => retryButton?.click());
736
+ expect(onRetry).toHaveBeenCalledOnce();
737
+ });
738
+
739
+ it("does not offer Retry for a daily provider request cap", async () => {
740
+ await act(async () => {
741
+ root.render(
742
+ <AgentNativeI18nProvider
743
+ initialLocale="en-US"
744
+ initialPreference="en-US"
745
+ persistPreference={false}
746
+ >
747
+ <RunErrorRecoveryCard
748
+ info={{
749
+ message: "The daily model request cap was reached.",
750
+ errorCode: "rate_limit_exceeded",
751
+ }}
752
+ onContinue={vi.fn()}
753
+ onRetry={vi.fn()}
754
+ onDismiss={vi.fn()}
755
+ />
756
+ </AgentNativeI18nProvider>,
757
+ );
758
+ });
759
+
760
+ expect(container.querySelector('button[aria-label="Retry"]')).toBeNull();
761
+ });
762
+
703
763
  it("does not offer Retry for a terminal invalid-attachment provider error", async () => {
704
764
  const onRetry = vi.fn();
705
765
 
@@ -203,6 +203,17 @@ export function runErrorHeadline(
203
203
  return info.recoverable === true ? labels.recoverable : labels.terminal;
204
204
  }
205
205
 
206
+ function isManuallyRetryableProviderError(errorCode?: string): boolean {
207
+ const code = errorCode?.trim().toLowerCase();
208
+ // Keep daily/account caps terminal; these codes represent transient rejections.
209
+ return (
210
+ code === "http_429" ||
211
+ code === "http_529" ||
212
+ code === "provider_rate_limited" ||
213
+ code === "provider_transient_rejection"
214
+ );
215
+ }
216
+
206
217
  export function getRequestModeMetadata(
207
218
  message: unknown,
208
219
  ): "act" | "plan" | null {
@@ -794,6 +805,9 @@ export function RunErrorRecoveryCard({
794
805
  // Retrying or continuing sends the rejected attachment again.
795
806
  const attachmentRejected = info.errorCode === "invalid_attachment";
796
807
  const canRecover = info.recoverable === true && !attachmentRejected;
808
+ const manuallyRetryableProviderError = isManuallyRetryableProviderError(
809
+ info.errorCode,
810
+ );
797
811
  const retryWithoutAttachments =
798
812
  onRetryWithoutAttachments ?? onRetryWithoutAttachment;
799
813
  const canRetryWithoutAttachments =
@@ -840,7 +854,11 @@ export function RunErrorRecoveryCard({
840
854
  // credentials. Without this the common case — a rejected workspace or
841
855
  // deployment credential the reader cannot see, let alone edit — rendered a
842
856
  // "Connected ✓" panel with no action at all.
843
- const canRetry = canRecover || isUnblockableExternally || isProviderAuthError;
857
+ const canRetry =
858
+ canRecover ||
859
+ isUnblockableExternally ||
860
+ isProviderAuthError ||
861
+ manuallyRetryableProviderError;
844
862
  const builderReconnectResolved =
845
863
  shouldShowBuilderReconnect &&
846
864
  builderReconnect.hasFetchedStatus &&
@@ -1158,9 +1176,17 @@ export function RunErrorRecoveryCard({
1158
1176
  ? t("agentChat.recovery.diagnoseRetry")
1159
1177
  : t("agentChat.common.retry")
1160
1178
  }
1161
- className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
1179
+ className={cn(
1180
+ "inline-flex h-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground",
1181
+ manuallyRetryableProviderError ? "gap-1.5 px-2" : "w-7",
1182
+ )}
1162
1183
  >
1163
1184
  <IconRefresh size={14} />
1185
+ {manuallyRetryableProviderError && (
1186
+ <span className="text-[11px] font-medium">
1187
+ {t("agentChat.common.retry")}
1188
+ </span>
1189
+ )}
1164
1190
  </button>
1165
1191
  )}
1166
1192
  {canRecover && isConnectionRecoveryError && (
@@ -0,0 +1,59 @@
1
+ import { useT } from "@agent-native/core/client/i18n";
2
+ import {
3
+ Tooltip,
4
+ TooltipContent,
5
+ TooltipProvider,
6
+ TooltipTrigger,
7
+ } from "@agent-native/toolkit/ui/tooltip";
8
+ import { cn } from "@agent-native/toolkit/utils";
9
+ import { IconMessageReport } from "@tabler/icons-react";
10
+ import { useState } from "react";
11
+
12
+ import { ReportProblemDialog } from "./ReportProblemDialog.js";
13
+
14
+ export interface ReportProblemButtonProps {
15
+ variant?: "sidebar" | "icon";
16
+ className?: string;
17
+ }
18
+
19
+ export function ReportProblemButton({
20
+ variant = "sidebar",
21
+ className,
22
+ }: ReportProblemButtonProps) {
23
+ const t = useT();
24
+ const [open, setOpen] = useState(false);
25
+ const label = t("agentChat.reportProblem.menuItem");
26
+
27
+ const button = (
28
+ <button
29
+ type="button"
30
+ aria-label={variant === "icon" ? label : undefined}
31
+ onClick={() => setOpen(true)}
32
+ className={cn(
33
+ variant === "icon"
34
+ ? "flex size-9 items-center justify-center rounded-md bg-transparent text-primary hover:bg-accent/60 hover:text-primary"
35
+ : "flex h-auto w-full items-center justify-start gap-2 rounded bg-transparent px-2 py-1.5 text-xs font-normal text-primary hover:bg-accent/60 hover:text-primary",
36
+ className,
37
+ )}
38
+ >
39
+ <IconMessageReport className="size-4 shrink-0 text-primary" />
40
+ {variant === "sidebar" && <span>{label}</span>}
41
+ </button>
42
+ );
43
+
44
+ return (
45
+ <>
46
+ {variant === "icon" ? (
47
+ <TooltipProvider delayDuration={200}>
48
+ <Tooltip>
49
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
50
+ <TooltipContent side="right">{label}</TooltipContent>
51
+ </Tooltip>
52
+ </TooltipProvider>
53
+ ) : (
54
+ button
55
+ )}
56
+ <ReportProblemDialog open={open} onOpenChange={setOpen} />
57
+ </>
58
+ );
59
+ }
@@ -0,0 +1,190 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import type { RecorderReport } from "@agent-native/core/client/recorder";
4
+ import React, { act } from "react";
5
+ import { createRoot, type Root } from "react-dom/client";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ const mocks = vi.hoisted(() => ({
9
+ report: vi.fn(),
10
+ uploadProblemReport: vi.fn(),
11
+ sendToAgentChat: vi.fn(),
12
+ writeClipboardText: vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@agent-native/core/client/recorder", () => ({
16
+ recorder: { report: mocks.report },
17
+ }));
18
+ vi.mock("@agent-native/core/client/problem-report", async (importOriginal) => ({
19
+ ...(await importOriginal<
20
+ typeof import("@agent-native/core/client/problem-report")
21
+ >()),
22
+ uploadProblemReport: mocks.uploadProblemReport,
23
+ }));
24
+ vi.mock("@agent-native/core/client/agent-chat", () => ({
25
+ sendToAgentChat: mocks.sendToAgentChat,
26
+ }));
27
+ vi.mock("@agent-native/toolkit/clipboard", () => ({
28
+ writeClipboardText: mocks.writeClipboardText,
29
+ }));
30
+
31
+ import { useRegisterAgentChatSurface } from "../chat/agent-chat-presence.js";
32
+ import { ReportProblemDialog } from "./ReportProblemDialog.js";
33
+
34
+ const REF = "attachment:v1:report-ref";
35
+
36
+ function fixture(reason: RecorderReport["reason"]): RecorderReport {
37
+ return {
38
+ app: "mail",
39
+ build: "abc123",
40
+ url: "/inbox",
41
+ at: "2026-10-14T16:20:05.120Z",
42
+ reason,
43
+ windowSeconds: 120,
44
+ events: [
45
+ { t: -3, at: 1, type: "action", name: "list-labels", ok: true },
46
+ {
47
+ t: -0.8,
48
+ at: 2,
49
+ type: "action",
50
+ name: "list-folders",
51
+ ok: false,
52
+ status: 403,
53
+ },
54
+ { t: -0.5, at: 3, type: "error", name: "TypeError" },
55
+ ],
56
+ watched: {},
57
+ pending: [],
58
+ dropped: 0,
59
+ };
60
+ }
61
+
62
+ function AgentChatStandIn() {
63
+ useRegisterAgentChatSurface(true);
64
+ return null;
65
+ }
66
+
67
+ let container: HTMLDivElement;
68
+ let root: Root;
69
+ const onOpenChange = vi.fn();
70
+
71
+ function render({ withAgentChat }: { withAgentChat: boolean }) {
72
+ act(() => {
73
+ root.render(
74
+ <>
75
+ {withAgentChat ? <AgentChatStandIn /> : null}
76
+ <ReportProblemDialog open onOpenChange={onOpenChange} />
77
+ </>,
78
+ );
79
+ });
80
+ }
81
+
82
+ function button(name: string): HTMLButtonElement {
83
+ const match = [...document.body.querySelectorAll("button")].find(
84
+ (candidate) => candidate.textContent?.trim() === name,
85
+ );
86
+ if (!match) throw new Error(`No button named ${name}`);
87
+ return match;
88
+ }
89
+
90
+ async function click(name: string) {
91
+ await act(async () => {
92
+ button(name).click();
93
+ });
94
+ }
95
+
96
+ beforeEach(() => {
97
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
98
+ mocks.report.mockReset();
99
+ mocks.report.mockImplementation((options?: { reason?: string }) =>
100
+ fixture(options?.reason === "user" ? "user" : "manual"),
101
+ );
102
+ mocks.uploadProblemReport.mockReset();
103
+ mocks.sendToAgentChat.mockReset();
104
+ mocks.writeClipboardText.mockReset();
105
+ onOpenChange.mockReset();
106
+ container = document.createElement("div");
107
+ document.body.appendChild(container);
108
+ root = createRoot(container);
109
+ });
110
+
111
+ afterEach(() => {
112
+ act(() => root.unmount());
113
+ container.remove();
114
+ document.body.innerHTML = "";
115
+ vi.unstubAllGlobals();
116
+ });
117
+
118
+ describe("ReportProblemDialog", () => {
119
+ it("previews counts, failures and first events without building a user report", () => {
120
+ render({ withAgentChat: true });
121
+
122
+ const text = document.body.textContent ?? "";
123
+ expect(text).toContain("Attach what the app did in the last 120 seconds?");
124
+ expect(text).toContain("action 2 · error 1");
125
+ expect(text).toContain("-0.8s action list-folders failed 403");
126
+ expect(text).toContain("-3.0s action list-labels ok");
127
+ expect(mocks.report).toHaveBeenCalledWith();
128
+ expect(mocks.report).not.toHaveBeenCalledWith({ reason: "user" });
129
+ });
130
+
131
+ it("attaches the user report and sends the bounded summary to the agent", async () => {
132
+ mocks.uploadProblemReport.mockResolvedValue({ status: "ok", ref: REF });
133
+ render({ withAgentChat: true });
134
+
135
+ await click("Send to agent");
136
+
137
+ expect(mocks.uploadProblemReport).toHaveBeenCalledWith(
138
+ expect.objectContaining({ reason: "user" }),
139
+ );
140
+ expect(mocks.sendToAgentChat).toHaveBeenCalledWith(
141
+ expect.objectContaining({
142
+ context: expect.stringContaining(REF),
143
+ submit: true,
144
+ openSidebar: true,
145
+ }),
146
+ );
147
+ expect(onOpenChange).toHaveBeenCalledWith(false);
148
+ });
149
+
150
+ it("says the report was not attached and retries the same report", async () => {
151
+ mocks.uploadProblemReport.mockResolvedValue({
152
+ status: "failed",
153
+ errorCode: "attachment_storage_unavailable",
154
+ message: "No object storage is connected.",
155
+ });
156
+ render({ withAgentChat: true });
157
+
158
+ await click("Send to agent");
159
+
160
+ expect(document.body.querySelector('[role="alert"]')?.textContent).toBe(
161
+ "The report was not attached. No object storage is connected.",
162
+ );
163
+ expect(mocks.sendToAgentChat).not.toHaveBeenCalled();
164
+ expect(onOpenChange).not.toHaveBeenCalled();
165
+
166
+ mocks.uploadProblemReport.mockResolvedValue({ status: "ok", ref: REF });
167
+ await click("Retry");
168
+
169
+ const [first, second] = mocks.uploadProblemReport.mock.calls;
170
+ expect(second[0]).toBe(first[0]);
171
+ expect(
172
+ mocks.report.mock.calls.filter(([options]) => options?.reason === "user"),
173
+ ).toHaveLength(1);
174
+ expect(mocks.sendToAgentChat).toHaveBeenCalledTimes(1);
175
+ });
176
+
177
+ it("copies the summary with its ref when the app has no agent chat", async () => {
178
+ mocks.uploadProblemReport.mockResolvedValue({ status: "ok", ref: REF });
179
+ mocks.writeClipboardText.mockResolvedValue(true);
180
+ render({ withAgentChat: false });
181
+
182
+ await click("Copy problem report");
183
+
184
+ expect(mocks.writeClipboardText).toHaveBeenCalledWith(
185
+ expect.stringContaining(REF),
186
+ );
187
+ expect(mocks.sendToAgentChat).not.toHaveBeenCalled();
188
+ expect(button("Copied")).toBeTruthy();
189
+ });
190
+ });