@agent-native/toolkit 0.205.0 → 0.206.0-nightly-20261009011246

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 (143) hide show
  1. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  2. package/dist/app/agentkit/react/components.js +17 -9
  3. package/dist/app/agentkit/react/components.js.map +1 -1
  4. package/dist/app/auth/AuthPage.d.ts.map +1 -1
  5. package/dist/app/auth/AuthPage.js +11 -0
  6. package/dist/app/auth/AuthPage.js.map +1 -1
  7. package/dist/app/chat/AgentKitAssistantChat.d.ts +2 -0
  8. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  9. package/dist/app/chat/AgentKitAssistantChat.js +24 -14
  10. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  11. package/dist/app/chat/AgentPanel.d.ts.map +1 -1
  12. package/dist/app/chat/AgentPanel.js +3 -2
  13. package/dist/app/chat/AgentPanel.js.map +1 -1
  14. package/dist/app/chat/MultiTabAssistantChat.d.ts +4 -1
  15. package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
  16. package/dist/app/chat/MultiTabAssistantChat.js +61 -3
  17. package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
  18. package/dist/app/chat/agentkit-chat/approval-card.d.ts.map +1 -1
  19. package/dist/app/chat/agentkit-chat/approval-card.js +2 -1
  20. package/dist/app/chat/agentkit-chat/approval-card.js.map +1 -1
  21. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  22. package/dist/app/chat/chat/run-recovery.js +74 -8
  23. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  24. package/dist/app/chat/chat/tool-call-display.d.ts.map +1 -1
  25. package/dist/app/chat/chat/tool-call-display.js +6 -4
  26. package/dist/app/chat/chat/tool-call-display.js.map +1 -1
  27. package/dist/app/chat/chat-first/ChatFirstAgentActivityPanel.d.ts.map +1 -1
  28. package/dist/app/chat/chat-first/ChatFirstAgentActivityPanel.js +2 -1
  29. package/dist/app/chat/chat-first/ChatFirstAgentActivityPanel.js.map +1 -1
  30. package/dist/app/chat/conversation/AgentConversation.d.ts.map +1 -1
  31. package/dist/app/chat/conversation/AgentConversation.js +10 -7
  32. package/dist/app/chat/conversation/AgentConversation.js.map +1 -1
  33. package/dist/app/chat/fallback-chat-title.d.ts.map +1 -1
  34. package/dist/app/chat/fallback-chat-title.js +2 -4
  35. package/dist/app/chat/fallback-chat-title.js.map +1 -1
  36. package/dist/app/chat/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  37. package/dist/app/chat/mcp-apps/McpAppRenderer.js +3 -2
  38. package/dist/app/chat/mcp-apps/McpAppRenderer.js.map +1 -1
  39. package/dist/app/chat/session-replay-privacy.d.ts +7 -0
  40. package/dist/app/chat/session-replay-privacy.d.ts.map +1 -0
  41. package/dist/app/chat/session-replay-privacy.js +6 -0
  42. package/dist/app/chat/session-replay-privacy.js.map +1 -0
  43. package/dist/app/extensions/ExtensionViewer.d.ts +4 -1
  44. package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
  45. package/dist/app/extensions/ExtensionViewer.js +3 -3
  46. package/dist/app/extensions/ExtensionViewer.js.map +1 -1
  47. package/dist/app/extensions/ExtensionViewerPage.d.ts +4 -1
  48. package/dist/app/extensions/ExtensionViewerPage.d.ts.map +1 -1
  49. package/dist/app/extensions/ExtensionViewerPage.js +2 -2
  50. package/dist/app/extensions/ExtensionViewerPage.js.map +1 -1
  51. package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
  52. package/dist/app/onboarding/FirstRunOnboarding.js +53 -12
  53. package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
  54. package/dist/app/onboarding/OnboardingPanel.d.ts.map +1 -1
  55. package/dist/app/onboarding/OnboardingPanel.js +14 -4
  56. package/dist/app/onboarding/OnboardingPanel.js.map +1 -1
  57. package/dist/app/resources/ResourceEditor.d.ts +4 -1
  58. package/dist/app/resources/ResourceEditor.d.ts.map +1 -1
  59. package/dist/app/resources/ResourceEditor.js +18 -7
  60. package/dist/app/resources/ResourceEditor.js.map +1 -1
  61. package/dist/app/resources/ResourcesPanel.d.ts +4 -1
  62. package/dist/app/resources/ResourcesPanel.d.ts.map +1 -1
  63. package/dist/app/resources/ResourcesPanel.js +18 -18
  64. package/dist/app/resources/ResourcesPanel.js.map +1 -1
  65. package/dist/app/resources/custom-agent-model-options.d.ts +16 -0
  66. package/dist/app/resources/custom-agent-model-options.d.ts.map +1 -0
  67. package/dist/app/resources/custom-agent-model-options.js +53 -0
  68. package/dist/app/resources/custom-agent-model-options.js.map +1 -0
  69. package/dist/app/settings/SettingsPanel.d.ts.map +1 -1
  70. package/dist/app/settings/SettingsPanel.js +34 -3
  71. package/dist/app/settings/SettingsPanel.js.map +1 -1
  72. package/dist/app/settings/SettingsTabsPage.d.ts.map +1 -1
  73. package/dist/app/settings/SettingsTabsPage.js +2 -0
  74. package/dist/app/settings/SettingsTabsPage.js.map +1 -1
  75. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts.map +1 -1
  76. package/dist/app/settings/api-keys/ApiKeyDialogs.js +49 -9
  77. package/dist/app/settings/api-keys/ApiKeyDialogs.js.map +1 -1
  78. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js +2 -1
  79. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js.map +1 -1
  80. package/dist/app/settings/model/ModelSettingsPage.d.ts.map +1 -1
  81. package/dist/app/settings/model/ModelSettingsPage.js +43 -9
  82. package/dist/app/settings/model/ModelSettingsPage.js.map +1 -1
  83. package/dist/app/settings/model/ProviderDialog.d.ts +4 -0
  84. package/dist/app/settings/model/ProviderDialog.d.ts.map +1 -1
  85. package/dist/app/settings/model/ProviderDialog.js +107 -11
  86. package/dist/app/settings/model/ProviderDialog.js.map +1 -1
  87. package/dist/app/settings/useBuilderStatus.d.ts.map +1 -1
  88. package/dist/app/settings/useBuilderStatus.js +13 -10
  89. package/dist/app/settings/useBuilderStatus.js.map +1 -1
  90. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  91. package/dist/composer/TiptapComposer.js +51 -13
  92. package/dist/composer/TiptapComposer.js.map +1 -1
  93. package/package.json +4 -4
  94. package/src/app/agentkit/react/components.interaction.spec.tsx +229 -1
  95. package/src/app/agentkit/react/components.spec.tsx +42 -1
  96. package/src/app/agentkit/react/components.tsx +46 -12
  97. package/src/app/auth/AuthPage.tsx +13 -0
  98. package/src/app/chat/AgentKitAssistantChat.spec.tsx +116 -5
  99. package/src/app/chat/AgentKitAssistantChat.tsx +41 -20
  100. package/src/app/chat/AgentPanel.tsx +5 -1
  101. package/src/app/chat/MultiTabAssistantChat.spec.tsx +152 -7
  102. package/src/app/chat/MultiTabAssistantChat.tsx +71 -4
  103. package/src/app/chat/agentkit-chat/approval-card.spec.tsx +17 -0
  104. package/src/app/chat/agentkit-chat/approval-card.tsx +2 -0
  105. package/src/app/chat/chat/run-recovery.spec.tsx +354 -9
  106. package/src/app/chat/chat/run-recovery.tsx +178 -10
  107. package/src/app/chat/chat/tool-call-display.spec.tsx +77 -0
  108. package/src/app/chat/chat/tool-call-display.tsx +23 -6
  109. package/src/app/chat/chat-first/ChatFirstAgentActivityPanel.tsx +2 -1
  110. package/src/app/chat/conversation/AgentConversation.spec.tsx +190 -1
  111. package/src/app/chat/conversation/AgentConversation.tsx +32 -8
  112. package/src/app/chat/fallback-chat-title.spec.ts +44 -0
  113. package/src/app/chat/fallback-chat-title.ts +3 -4
  114. package/src/app/chat/mcp-apps/McpAppRenderer.spec.ts +24 -0
  115. package/src/app/chat/mcp-apps/McpAppRenderer.tsx +10 -2
  116. package/src/app/chat/session-replay-privacy.spec.ts +14 -0
  117. package/src/app/chat/session-replay-privacy.ts +5 -0
  118. package/src/app/extensions/ExtensionViewer.tsx +15 -3
  119. package/src/app/extensions/ExtensionViewerPage.tsx +7 -3
  120. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +252 -2
  121. package/src/app/onboarding/FirstRunOnboarding.tsx +56 -9
  122. package/src/app/onboarding/OnboardingPanel.save-scope.spec.tsx +1 -0
  123. package/src/app/onboarding/OnboardingPanel.tsx +15 -3
  124. package/src/app/resources/ResourceEditor.spec.tsx +240 -5
  125. package/src/app/resources/ResourceEditor.tsx +57 -10
  126. package/src/app/resources/ResourcesPanel.spec.ts +86 -4
  127. package/src/app/resources/ResourcesPanel.tsx +49 -16
  128. package/src/app/resources/custom-agent-model-options.ts +92 -0
  129. package/src/app/settings/SettingsPanel.model-defaults.spec.tsx +26 -13
  130. package/src/app/settings/SettingsPanel.tsx +35 -3
  131. package/src/app/settings/SettingsTabsPage.spec.tsx +97 -0
  132. package/src/app/settings/SettingsTabsPage.tsx +2 -0
  133. package/src/app/settings/api-keys/ApiKeyDialogs.tsx +84 -22
  134. package/src/app/settings/api-keys/ApiKeysSettingsPage.spec.tsx +229 -1
  135. package/src/app/settings/api-keys/ApiKeysSettingsPage.tsx +3 -2
  136. package/src/app/settings/model/ModelSettingsPage.spec.tsx +99 -9
  137. package/src/app/settings/model/ModelSettingsPage.tsx +79 -11
  138. package/src/app/settings/model/ProviderDialog.spec.tsx +192 -1
  139. package/src/app/settings/model/ProviderDialog.tsx +225 -29
  140. package/src/app/settings/useBuilderStatus.spec.tsx +49 -0
  141. package/src/app/settings/useBuilderStatus.ts +16 -10
  142. package/src/composer/TiptapComposer.spec.ts +97 -2
  143. package/src/composer/TiptapComposer.tsx +67 -13
@@ -1,6 +1,11 @@
1
1
  import { getAgentProviderOption } from "@agent-native/core/client/agent-provider-catalog";
2
2
  import { callAction, useActionQuery } from "@agent-native/core/client/hooks";
3
3
  import { useT } from "@agent-native/core/client/i18n";
4
+ import {
5
+ requestCustomKeyOnboardingAbandonment,
6
+ trackCustomKeyOnboardingOutcome,
7
+ withCustomKeyOnboardingCredentialSave,
8
+ } from "@agent-native/core/client/onboarding/use-onboarding";
4
9
  import { useOrg } from "@agent-native/core/client/org";
5
10
  import type { SecretRemovalPreview } from "@agent-native/core/secrets/usage";
6
11
  import { Alert, AlertDescription } from "@agent-native/toolkit/ui/alert";
@@ -83,9 +88,33 @@ export interface KeyValueDialogProps {
83
88
 
84
89
  /** Add key (Name, Value, Available to), or Replace value on a saved key. */
85
90
  export function KeyValueDialog(props: KeyValueDialogProps) {
91
+ const savePending = useRef(false);
92
+ const dismiss = () => {
93
+ if (savePending.current) {
94
+ requestCustomKeyOnboardingAbandonment();
95
+ } else {
96
+ trackCustomKeyOnboardingOutcome("credential_skipped");
97
+ }
98
+ props.onOpenChange(false);
99
+ };
100
+
86
101
  return (
87
- <Dialog open={props.open} onOpenChange={props.onOpenChange}>
88
- {props.open ? <KeyValueDialogContent {...props} /> : null}
102
+ <Dialog
103
+ open={props.open}
104
+ onOpenChange={(open) => {
105
+ if (open) props.onOpenChange(true);
106
+ else dismiss();
107
+ }}
108
+ >
109
+ {props.open ? (
110
+ <KeyValueDialogContent
111
+ {...props}
112
+ onDismiss={dismiss}
113
+ onSavingChange={(saving) => {
114
+ savePending.current = saving;
115
+ }}
116
+ />
117
+ ) : null}
89
118
  </Dialog>
90
119
  );
91
120
  }
@@ -114,11 +143,16 @@ function FormError({ message }: { message: string | null }) {
114
143
 
115
144
  function KeyValueDialogContent({
116
145
  onOpenChange,
146
+ onDismiss = () => onOpenChange(false),
147
+ onSavingChange,
117
148
  dialog,
118
149
  listing,
119
150
  orgName,
120
151
  onSaved,
121
- }: KeyValueDialogProps) {
152
+ }: KeyValueDialogProps & {
153
+ onDismiss?: () => void;
154
+ onSavingChange?: (saving: boolean) => void;
155
+ }) {
122
156
  const t = useT();
123
157
  const queryClient = useQueryClient();
124
158
  const mounted = useMounted();
@@ -175,14 +209,17 @@ function KeyValueDialogContent({
175
209
  const save = async () => {
176
210
  if (saving || !valid) return;
177
211
  setSaving(true);
212
+ onSavingChange?.(true);
178
213
  setError(null);
179
214
  try {
180
- await saveApiKeyValue({
181
- name: target.kind === "registered" ? target.key.name : target.name,
182
- value: value.trim(),
183
- registered: target.kind === "registered",
184
- shared: isShared,
185
- });
215
+ await withCustomKeyOnboardingCredentialSave(() =>
216
+ saveApiKeyValue({
217
+ name: target.kind === "registered" ? target.key.name : target.name,
218
+ value: value.trim(),
219
+ registered: target.kind === "registered",
220
+ shared: isShared,
221
+ }),
222
+ );
186
223
  void refreshKeys(queryClient);
187
224
  toast.success(replacing ? t(`${K}valueReplaced`) : t(`${K}keyAdded`));
188
225
  onSaved?.();
@@ -191,6 +228,7 @@ function KeyValueDialogContent({
191
228
  setError(err instanceof Error ? err.message : String(err));
192
229
  } finally {
193
230
  setSaving(false);
231
+ onSavingChange?.(false);
194
232
  }
195
233
  };
196
234
 
@@ -273,6 +311,9 @@ function KeyValueDialogContent({
273
311
  onChange={(event) => {
274
312
  setValue(event.target.value);
275
313
  setError(null);
314
+ if (event.target.value.trim()) {
315
+ trackCustomKeyOnboardingOutcome("credential_entry_started");
316
+ }
276
317
  }}
277
318
  />
278
319
  {docsUrl ? (
@@ -328,11 +369,7 @@ function KeyValueDialogContent({
328
369
  ) : null}
329
370
  <FormError message={error} />
330
371
  <DialogFooter className="gap-2 sm:space-x-0">
331
- <Button
332
- type="button"
333
- variant="secondary"
334
- onClick={() => onOpenChange(false)}
335
- >
372
+ <Button type="button" variant="secondary" onClick={onDismiss}>
336
373
  {t(`${M}cancel`)}
337
374
  </Button>
338
375
  <Button type="submit" disabled={saving || !valid}>
@@ -366,10 +403,32 @@ export interface ServiceKeyDialogProps {
366
403
  * from Infrastructure. Reads the listing the dialog needs itself.
367
404
  */
368
405
  export function ServiceKeyDialog(props: ServiceKeyDialogProps) {
406
+ const savePending = useRef(false);
407
+ const dismiss = () => {
408
+ if (savePending.current) {
409
+ requestCustomKeyOnboardingAbandonment();
410
+ } else {
411
+ trackCustomKeyOnboardingOutcome("credential_skipped");
412
+ }
413
+ props.onOpenChange(false);
414
+ };
415
+
369
416
  return (
370
- <Dialog open={props.open} onOpenChange={props.onOpenChange}>
417
+ <Dialog
418
+ open={props.open}
419
+ onOpenChange={(open) => {
420
+ if (open) props.onOpenChange(true);
421
+ else dismiss();
422
+ }}
423
+ >
371
424
  {props.open && props.keyName ? (
372
- <ServiceKeyDialogContent {...props} />
425
+ <ServiceKeyDialogContent
426
+ {...props}
427
+ onDismiss={dismiss}
428
+ onSavingChange={(saving) => {
429
+ savePending.current = saving;
430
+ }}
431
+ />
373
432
  ) : null}
374
433
  </Dialog>
375
434
  );
@@ -380,7 +439,12 @@ function ServiceKeyDialogContent({
380
439
  keyName,
381
440
  mode,
382
441
  onSaved,
383
- }: ServiceKeyDialogProps) {
442
+ onDismiss = () => onOpenChange(false),
443
+ onSavingChange,
444
+ }: ServiceKeyDialogProps & {
445
+ onDismiss?: () => void;
446
+ onSavingChange?: (saving: boolean) => void;
447
+ }) {
384
448
  const t = useT();
385
449
  const org = useOrg();
386
450
  const listing = useActionQuery<ApiKeysListing>("list-api-keys" as never);
@@ -403,6 +467,8 @@ function ServiceKeyDialogContent({
403
467
  }
404
468
  listing={listing.data}
405
469
  orgName={org.data?.orgName ?? ""}
470
+ onDismiss={onDismiss}
471
+ onSavingChange={onSavingChange}
406
472
  {...(onSaved ? { onSaved } : {})}
407
473
  />
408
474
  );
@@ -451,11 +517,7 @@ function ServiceKeyDialogContent({
451
517
  </div>
452
518
  )}
453
519
  <DialogFooter className="gap-2 sm:space-x-0">
454
- <Button
455
- type="button"
456
- variant="secondary"
457
- onClick={() => onOpenChange(false)}
458
- >
520
+ <Button type="button" variant="secondary" onClick={onDismiss}>
459
521
  {t(`${M}cancel`)}
460
522
  </Button>
461
523
  <Button type="button" disabled>
@@ -21,6 +21,22 @@ const clientMock = vi.hoisted(() => ({
21
21
  test: vi.fn(),
22
22
  notify: vi.fn(),
23
23
  }));
24
+ const onboardingOutcomeMock = vi.hoisted(() => vi.fn());
25
+ const onboardingAbandonmentRequestMock = vi.hoisted(() => vi.fn());
26
+ const credentialSaveBoundaryMock = vi.hoisted(() => vi.fn());
27
+
28
+ vi.mock("@agent-native/core/client/onboarding/use-onboarding", () => ({
29
+ requestCustomKeyOnboardingAbandonment: onboardingAbandonmentRequestMock,
30
+ trackCustomKeyOnboardingOutcome: onboardingOutcomeMock,
31
+ withCustomKeyOnboardingCredentialSave: async (
32
+ save: () => Promise<unknown>,
33
+ ) => {
34
+ credentialSaveBoundaryMock();
35
+ const result = await save();
36
+ onboardingOutcomeMock("credential_saved");
37
+ return result;
38
+ },
39
+ }));
24
40
 
25
41
  vi.mock("@agent-native/core/client/hooks", () => ({
26
42
  getBrowserTabId: () => "test-tab",
@@ -88,7 +104,11 @@ vi.mock("@agent-native/core/client/i18n", () => ({
88
104
  }),
89
105
  }));
90
106
 
91
- import { DeleteKeyDialog, ServiceKeyDialog } from "./ApiKeyDialogs.js";
107
+ import {
108
+ DeleteKeyDialog,
109
+ KeyValueDialog,
110
+ ServiceKeyDialog,
111
+ } from "./ApiKeyDialogs.js";
92
112
  import ApiKeysSettingsPage from "./ApiKeysSettingsPage.js";
93
113
 
94
114
  function entry(overrides: Partial<ApiKeyEntry>): ApiKeyEntry {
@@ -176,6 +196,17 @@ function buttonByText(text: string, scope: ParentNode = document) {
176
196
  return button;
177
197
  }
178
198
 
199
+ function pointerDown(element: Element | null | undefined) {
200
+ if (!element) throw new Error("Nothing to open");
201
+ element.dispatchEvent(
202
+ new PointerEvent("pointerdown", {
203
+ bubbles: true,
204
+ button: 0,
205
+ pointerType: "mouse",
206
+ }),
207
+ );
208
+ }
209
+
179
210
  function typeInto(input: HTMLInputElement, value: string) {
180
211
  const setter = Object.getOwnPropertyDescriptor(
181
212
  HTMLInputElement.prototype,
@@ -199,6 +230,9 @@ describe("ApiKeysSettingsPage", () => {
199
230
  navigateMock.mockReset();
200
231
  clientMock.save.mockReset();
201
232
  clientMock.test.mockReset();
233
+ onboardingOutcomeMock.mockReset();
234
+ onboardingAbandonmentRequestMock.mockReset();
235
+ credentialSaveBoundaryMock.mockReset();
202
236
  window.history.replaceState(null, "", "/settings/api-keys");
203
237
  container = document.createElement("div");
204
238
  document.body.appendChild(container);
@@ -297,6 +331,32 @@ describe("ApiKeysSettingsPage", () => {
297
331
  });
298
332
  });
299
333
 
334
+ it("does not count testing an arbitrary saved key as onboarding validation", async () => {
335
+ state.listing = listing({ keys: [entry({ canTest: true })] });
336
+ clientMock.test.mockResolvedValue({ ok: true });
337
+ await render();
338
+
339
+ const keyRow = row("secrets:STRIPE_SECRET_KEY");
340
+ const menuTrigger = keyRow.querySelector(
341
+ '[aria-label="Manage STRIPE_SECRET_KEY"]',
342
+ );
343
+ await act(async () => pointerDown(menuTrigger));
344
+ const testItem = [...document.querySelectorAll('[role="menuitem"]')].find(
345
+ (item) => item.textContent?.trim() === "Test",
346
+ );
347
+ if (!testItem) throw new Error("No Test menu item");
348
+ await act(async () =>
349
+ testItem.dispatchEvent(new MouseEvent("click", { bubbles: true })),
350
+ );
351
+
352
+ await vi.waitFor(() =>
353
+ expect(clientMock.test).toHaveBeenCalledWith("STRIPE_SECRET_KEY"),
354
+ );
355
+ expect(onboardingOutcomeMock).not.toHaveBeenCalledWith(
356
+ "credential_validated",
357
+ );
358
+ });
359
+
300
360
  it("lists organization keys for admins", async () => {
301
361
  state.listing = listing({
302
362
  canManageOrg: true,
@@ -407,6 +467,93 @@ describe("ApiKeysSettingsPage", () => {
407
467
  expect(dialog.textContent).toContain("Get key");
408
468
  });
409
469
 
470
+ it("does not count saving a registered key as validation", async () => {
471
+ clientMock.save.mockResolvedValue(undefined);
472
+ await render();
473
+ const header = await renderHeader();
474
+ await act(async () => buttonByText("Add key", header).click());
475
+
476
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
477
+ const [name, value] = [
478
+ ...dialog.querySelectorAll("input"),
479
+ ] as HTMLInputElement[];
480
+ await act(async () => typeInto(name!, "git"));
481
+ await act(async () => buttonByText("GITHUB_TOKEN", dialog).click());
482
+ await act(async () => typeInto(value!, "fake-github-token"));
483
+ await act(async () => {
484
+ dialog
485
+ .querySelector("form")!
486
+ .dispatchEvent(
487
+ new Event("submit", { bubbles: true, cancelable: true }),
488
+ );
489
+ });
490
+
491
+ const outcomes = onboardingOutcomeMock.mock.calls.map(
492
+ ([outcome]) => outcome,
493
+ );
494
+ expect(outcomes).toContain("credential_saved");
495
+ expect(outcomes).not.toContain("credential_validated");
496
+ });
497
+
498
+ it("allows leaving a key dialog while preserving its pending save outcome", async () => {
499
+ let resolveSave!: () => void;
500
+ clientMock.save.mockReturnValue(
501
+ new Promise<void>((resolve) => {
502
+ resolveSave = resolve;
503
+ }),
504
+ );
505
+ const onOpenChange = vi.fn();
506
+ await act(async () => {
507
+ root.render(
508
+ <QueryClientProvider client={new QueryClient()}>
509
+ <KeyValueDialog
510
+ open
511
+ onOpenChange={onOpenChange}
512
+ dialog={{ mode: "add" }}
513
+ listing={listing()}
514
+ orgName="Acme"
515
+ />
516
+ </QueryClientProvider>,
517
+ );
518
+ });
519
+
520
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
521
+ const [name, value] = [
522
+ ...dialog.querySelectorAll("input"),
523
+ ] as HTMLInputElement[];
524
+ await act(async () => typeInto(name!, "CUSTOM_API_KEY"));
525
+ await act(async () => typeInto(value!, "fake-custom-value"));
526
+ await act(async () => {
527
+ dialog
528
+ .querySelector("form")!
529
+ .dispatchEvent(
530
+ new Event("submit", { bubbles: true, cancelable: true }),
531
+ );
532
+ });
533
+ expect(clientMock.save).toHaveBeenCalledWith({
534
+ name: "CUSTOM_API_KEY",
535
+ value: "fake-custom-value",
536
+ registered: false,
537
+ shared: false,
538
+ });
539
+
540
+ await act(async () => buttonByText("Close", dialog).click());
541
+ expect(onOpenChange).toHaveBeenCalledWith(false);
542
+ expect(onboardingAbandonmentRequestMock).toHaveBeenCalledTimes(1);
543
+ expect(
544
+ onboardingOutcomeMock.mock.calls.map(([outcome]) => outcome),
545
+ ).not.toContain("credential_skipped");
546
+
547
+ await act(async () => resolveSave());
548
+ await vi.waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
549
+ const outcomes = onboardingOutcomeMock.mock.calls.map(
550
+ ([outcome]) => outcome,
551
+ );
552
+ expect(outcomes).toContain("credential_saved");
553
+ expect(outcomes).not.toContain("credential_skipped");
554
+ expect(credentialSaveBoundaryMock).toHaveBeenCalledTimes(1);
555
+ });
556
+
410
557
  it("opens Add key for a #secrets:KEY link to a key nobody saved", async () => {
411
558
  window.history.replaceState(
412
559
  null,
@@ -505,6 +652,9 @@ describe("DeleteKeyDialog", () => {
505
652
  beforeEach(() => {
506
653
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
507
654
  callActionMock.mockReset();
655
+ clientMock.save.mockReset();
656
+ onboardingOutcomeMock.mockReset();
657
+ credentialSaveBoundaryMock.mockReset();
508
658
  container = document.createElement("div");
509
659
  document.body.appendChild(container);
510
660
  root = createRoot(container);
@@ -617,6 +767,84 @@ describe("DeleteKeyDialog", () => {
617
767
  expect(onOpenChange).toHaveBeenCalledWith(false);
618
768
  });
619
769
 
770
+ it("records a service-key attempt as skipped when canceled", async () => {
771
+ state.listing = listing({ canManageOrg: true });
772
+ const onOpenChange = vi.fn();
773
+ await act(async () => {
774
+ root.render(
775
+ <QueryClientProvider client={new QueryClient()}>
776
+ <ServiceKeyDialog
777
+ open
778
+ onOpenChange={onOpenChange}
779
+ keyName="VOYAGE_API_KEY"
780
+ mode="add"
781
+ />
782
+ </QueryClientProvider>,
783
+ );
784
+ });
785
+
786
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
787
+ await act(async () => buttonByText("Cancel", dialog).click());
788
+
789
+ expect(onOpenChange).toHaveBeenCalledWith(false);
790
+ expect(onboardingOutcomeMock).toHaveBeenCalledWith("credential_skipped");
791
+ });
792
+
793
+ it("allows leaving a service-key dialog while preserving its pending save outcome", async () => {
794
+ let resolveSave!: () => void;
795
+ clientMock.save.mockReturnValue(
796
+ new Promise<void>((resolve) => {
797
+ resolveSave = resolve;
798
+ }),
799
+ );
800
+ state.listing = listing({ canManageOrg: true });
801
+ const onOpenChange = vi.fn();
802
+ await act(async () => {
803
+ root.render(
804
+ <QueryClientProvider client={new QueryClient()}>
805
+ <ServiceKeyDialog
806
+ open
807
+ onOpenChange={onOpenChange}
808
+ keyName="VOYAGE_API_KEY"
809
+ mode="add"
810
+ />
811
+ </QueryClientProvider>,
812
+ );
813
+ });
814
+
815
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
816
+ const [, value] = [
817
+ ...dialog.querySelectorAll("input"),
818
+ ] as HTMLInputElement[];
819
+ await act(async () => typeInto(value!, "fake-voyage-value"));
820
+ await act(async () => {
821
+ dialog
822
+ .querySelector("form")!
823
+ .dispatchEvent(
824
+ new Event("submit", { bubbles: true, cancelable: true }),
825
+ );
826
+ });
827
+ await vi.waitFor(() => expect(clientMock.save).toHaveBeenCalled());
828
+
829
+ await act(async () => buttonByText("Close", dialog).click());
830
+ expect(onOpenChange).toHaveBeenCalledWith(false);
831
+ expect(onboardingAbandonmentRequestMock).toHaveBeenCalledTimes(1);
832
+ expect(
833
+ onboardingOutcomeMock.mock.calls.map(([outcome]) => outcome),
834
+ ).not.toContain("credential_skipped");
835
+
836
+ await act(async () => {
837
+ resolveSave();
838
+ await vi.waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false));
839
+ });
840
+ const outcomes = onboardingOutcomeMock.mock.calls.map(
841
+ ([outcome]) => outcome,
842
+ );
843
+ expect(outcomes).toContain("credential_saved");
844
+ expect(outcomes).not.toContain("credential_skipped");
845
+ expect(credentialSaveBoundaryMock).toHaveBeenCalledTimes(1);
846
+ });
847
+
620
848
  it("replaces a service's saved organization key from Manage", async () => {
621
849
  state.listing = listing({
622
850
  canManageOrg: true,
@@ -383,8 +383,9 @@ function KeyMenu({
383
383
  const t = useT();
384
384
  const test = async () => {
385
385
  const result = await testSavedApiKey(entry.name);
386
- if (result.ok) toast.success(t(`${K}testPassed`));
387
- else toast.error(result.error);
386
+ if (result.ok) {
387
+ toast.success(t(`${K}testPassed`));
388
+ } else toast.error(result.error);
388
389
  };
389
390
  return (
390
391
  <DropdownMenu>
@@ -3,6 +3,7 @@
3
3
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
4
  import React, { act } from "react";
5
5
  import { createRoot, type Root } from "react-dom/client";
6
+ import { MemoryRouter, useLocation } from "react-router";
6
7
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
8
 
8
9
  import englishMessages from "../../i18n/catalogs/en-US.js";
@@ -23,6 +24,7 @@ const state = vi.hoisted(() => ({
23
24
  modelsRefetch: vi.fn(),
24
25
  builder: {} as Record<string, unknown>,
25
26
  header: null as { action?: unknown } | null,
27
+ location: null as unknown,
26
28
  loop: {
27
29
  maxIterations: 400,
28
30
  defaultMaxIterations: 400,
@@ -150,6 +152,11 @@ vi.mock("@agent-native/core/client/i18n", () => ({
150
152
 
151
153
  import ModelSettingsPage from "./ModelSettingsPage.js";
152
154
 
155
+ function LocationProbe() {
156
+ state.location = useLocation();
157
+ return null;
158
+ }
159
+
153
160
  const PROVIDERS = [
154
161
  "openrouter",
155
162
  "ollama",
@@ -324,6 +331,8 @@ describe("ModelSettingsPage", () => {
324
331
  };
325
332
  state.builder = builderFlow();
326
333
  state.header = null;
334
+ state.location = null;
335
+ dialogProps.last = null;
327
336
  queryClient = new QueryClient();
328
337
  state.loop = { ...state.loop, canUpdate: true };
329
338
  callActionMock.mockReset();
@@ -331,6 +340,7 @@ describe("ModelSettingsPage", () => {
331
340
  popupMock.mockReset();
332
341
  navigateMock.mockReset();
333
342
  loopMock.save.mockReset();
343
+ window.history.replaceState(null, "", "/settings/model");
334
344
  container = document.createElement("div");
335
345
  document.body.appendChild(container);
336
346
  root = createRoot(container);
@@ -343,21 +353,77 @@ describe("ModelSettingsPage", () => {
343
353
  vi.unstubAllGlobals();
344
354
  });
345
355
 
346
- async function render() {
356
+ async function render(locationState?: unknown) {
357
+ const entry = new URL(window.location.href);
347
358
  await act(async () => {
348
359
  root.render(
349
- <QueryClientProvider client={queryClient}>
350
- <ModelSettingsPage
351
- pageId="model"
352
- sub={null}
353
- context={{} as never}
354
- bridge={{} as never}
355
- />
356
- </QueryClientProvider>,
360
+ <MemoryRouter
361
+ initialEntries={[
362
+ {
363
+ pathname: entry.pathname,
364
+ search: entry.search,
365
+ hash: entry.hash,
366
+ state: locationState,
367
+ },
368
+ ]}
369
+ >
370
+ <LocationProbe />
371
+ <QueryClientProvider client={queryClient}>
372
+ <ModelSettingsPage
373
+ pageId="model"
374
+ sub={null}
375
+ context={{} as never}
376
+ bridge={{} as never}
377
+ />
378
+ </QueryClientProvider>
379
+ </MemoryRouter>,
357
380
  );
358
381
  });
359
382
  }
360
383
 
384
+ it("uses chat setup attribution for the first provider dialog opening only", async () => {
385
+ window.history.replaceState(null, "", "/settings/model?from=chat#llm");
386
+ state.listing = listing(
387
+ {},
388
+ {
389
+ anthropic: {
390
+ personal: { scope: "user", masked: "••••1234", updatedAt: 1 },
391
+ },
392
+ },
393
+ );
394
+ await render({
395
+ providerSetupTrackingFlow: "chat_setup",
396
+ returnTo: "/chat",
397
+ });
398
+
399
+ await act(async () => {
400
+ row("provider-personal-anthropic").querySelector("button")!.click();
401
+ });
402
+ expect(dialogProps.last).toMatchObject({
403
+ open: true,
404
+ trackingFlow: "chat_setup",
405
+ });
406
+ expect(state.location).toMatchObject({
407
+ pathname: "/settings/model",
408
+ search: "?from=chat",
409
+ hash: "#llm",
410
+ state: { returnTo: "/chat" },
411
+ });
412
+
413
+ await act(async () => {
414
+ (
415
+ dialogProps.last as { onOpenChange: (open: boolean) => void }
416
+ ).onOpenChange(false);
417
+ });
418
+ await act(async () => {
419
+ row("provider-personal-anthropic").querySelector("button")!.click();
420
+ });
421
+ expect(dialogProps.last).toMatchObject({
422
+ open: true,
423
+ trackingFlow: "settings",
424
+ });
425
+ });
426
+
361
427
  it("shows members organization providers read-only and their own as manageable", async () => {
362
428
  state.listing = listing(
363
429
  {},
@@ -709,6 +775,30 @@ describe("ModelSettingsPage", () => {
709
775
  expect(row("llm").textContent).not.toContain("Add a model provider");
710
776
  });
711
777
 
778
+ it("shows the current Builder model for a retired stored default", async () => {
779
+ state.listing = listing({
780
+ canManageOrg: true,
781
+ canUpdateDefault: true,
782
+ defaultModel: { engine: "builder", model: "claude-sonnet-5" },
783
+ });
784
+ state.models = {
785
+ providers: models().providers.map((provider) =>
786
+ provider.provider === "builder"
787
+ ? { ...provider, recommendedModels: ["claude-sonnet-5-5"] }
788
+ : provider,
789
+ ),
790
+ };
791
+ state.builder = builderFlow();
792
+ await render();
793
+
794
+ const defaultRow = row("default-model");
795
+ await vi.waitFor(() => {
796
+ expect(defaultRow.textContent).toContain(
797
+ "claude-sonnet-5-5 · Builder.io",
798
+ );
799
+ });
800
+ });
801
+
712
802
  it("offers a deployment-configured provider when there is no saved key or default", async () => {
713
803
  state.listing = listing(
714
804
  { canManageOrg: true, canUpdateDefault: true, defaultModel: null },