@agent-native/toolkit 0.201.0 → 0.201.1

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 (109) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  3. package/dist/app/agentkit/react/components.js +21 -13
  4. package/dist/app/agentkit/react/components.js.map +1 -1
  5. package/dist/app/agentkit/react/context.d.ts +15 -1
  6. package/dist/app/agentkit/react/context.d.ts.map +1 -1
  7. package/dist/app/agentkit/react/context.js +30 -4
  8. package/dist/app/agentkit/react/context.js.map +1 -1
  9. package/dist/app/agentkit/react/styles.css +11 -7
  10. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  11. package/dist/app/chat/AgentKitAssistantChat.js +9 -4
  12. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  13. package/dist/app/chat/FileStorageSetupPopover.d.ts.map +1 -1
  14. package/dist/app/chat/FileStorageSetupPopover.js +3 -2
  15. package/dist/app/chat/FileStorageSetupPopover.js.map +1 -1
  16. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  17. package/dist/app/i18n/catalogs/ar-SA.js +11 -5
  18. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  19. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  20. package/dist/app/i18n/catalogs/de-DE.js +11 -5
  21. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  22. package/dist/app/i18n/catalogs/en-US.d.ts +11 -5
  23. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  24. package/dist/app/i18n/catalogs/en-US.js +11 -5
  25. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  26. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  27. package/dist/app/i18n/catalogs/es-ES.js +11 -5
  28. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  29. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  30. package/dist/app/i18n/catalogs/fr-FR.js +11 -5
  31. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  32. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  33. package/dist/app/i18n/catalogs/hi-IN.js +11 -5
  34. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  35. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/ja-JP.js +11 -5
  37. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  38. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/ko-KR.js +11 -5
  40. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  41. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  42. package/dist/app/i18n/catalogs/pt-BR.js +11 -5
  43. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  44. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  45. package/dist/app/i18n/catalogs/zh-CN.js +11 -5
  46. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  47. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  48. package/dist/app/i18n/catalogs/zh-TW.js +11 -5
  49. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  50. package/dist/app/onboarding/BuilderIncludedServices.d.ts +23 -0
  51. package/dist/app/onboarding/BuilderIncludedServices.d.ts.map +1 -0
  52. package/dist/app/onboarding/BuilderIncludedServices.js +67 -0
  53. package/dist/app/onboarding/BuilderIncludedServices.js.map +1 -0
  54. package/dist/app/onboarding/FirstRunOnboarding.d.ts.map +1 -1
  55. package/dist/app/onboarding/FirstRunOnboarding.js +10 -29
  56. package/dist/app/onboarding/FirstRunOnboarding.js.map +1 -1
  57. package/dist/app/resources/McpConnectionSuggestion.d.ts.map +1 -1
  58. package/dist/app/resources/McpConnectionSuggestion.js +55 -21
  59. package/dist/app/resources/McpConnectionSuggestion.js.map +1 -1
  60. package/dist/app/settings/BuilderConnectPopover.d.ts +1 -2
  61. package/dist/app/settings/BuilderConnectPopover.d.ts.map +1 -1
  62. package/dist/app/settings/BuilderConnectPopover.js +35 -21
  63. package/dist/app/settings/BuilderConnectPopover.js.map +1 -1
  64. package/dist/app/settings/deferred-builder-connect-popover.d.ts.map +1 -1
  65. package/dist/app/settings/deferred-builder-connect-popover.js +28 -1
  66. package/dist/app/settings/deferred-builder-connect-popover.js.map +1 -1
  67. package/dist/app/settings/useBuilderStatus.d.ts.map +1 -1
  68. package/dist/app/settings/useBuilderStatus.js +8 -1
  69. package/dist/app/settings/useBuilderStatus.js.map +1 -1
  70. package/dist/app/styles/tokens/agent-kit.css +23 -11
  71. package/dist/composer/MessageQueueDrawer.d.ts +5 -0
  72. package/dist/composer/MessageQueueDrawer.d.ts.map +1 -1
  73. package/dist/composer/MessageQueueDrawer.js +11 -5
  74. package/dist/composer/MessageQueueDrawer.js.map +1 -1
  75. package/package.json +4 -3
  76. package/src/app/agentkit/react/components.interaction.spec.tsx +13 -5
  77. package/src/app/agentkit/react/components.tsx +24 -14
  78. package/src/app/agentkit/react/context.tsx +53 -6
  79. package/src/app/agentkit/react/styles.css +11 -7
  80. package/src/app/chat/AgentKitAssistantChat.spec.tsx +35 -16
  81. package/src/app/chat/AgentKitAssistantChat.tsx +13 -4
  82. package/src/app/chat/FileStorageSetupPopover.spec.tsx +129 -0
  83. package/src/app/chat/FileStorageSetupPopover.tsx +10 -3
  84. package/src/app/i18n/catalogs/ar-SA.ts +14 -5
  85. package/src/app/i18n/catalogs/de-DE.ts +15 -5
  86. package/src/app/i18n/catalogs/en-US.ts +14 -5
  87. package/src/app/i18n/catalogs/es-ES.ts +14 -5
  88. package/src/app/i18n/catalogs/fr-FR.ts +15 -5
  89. package/src/app/i18n/catalogs/hi-IN.ts +14 -5
  90. package/src/app/i18n/catalogs/ja-JP.ts +14 -5
  91. package/src/app/i18n/catalogs/ko-KR.ts +14 -5
  92. package/src/app/i18n/catalogs/pt-BR.ts +15 -5
  93. package/src/app/i18n/catalogs/zh-CN.ts +13 -5
  94. package/src/app/i18n/catalogs/zh-TW.ts +13 -5
  95. package/src/app/onboarding/BuilderIncludedServices.tsx +263 -0
  96. package/src/app/onboarding/FirstRunOnboarding.spec.tsx +23 -8
  97. package/src/app/onboarding/FirstRunOnboarding.tsx +22 -112
  98. package/src/app/resources/McpConnectionSuggestion.render.spec.tsx +54 -2
  99. package/src/app/resources/McpConnectionSuggestion.tsx +73 -25
  100. package/src/app/settings/BuilderConnectPopover.spec.tsx +175 -6
  101. package/src/app/settings/BuilderConnectPopover.tsx +87 -67
  102. package/src/app/settings/deferred-builder-connect-popover.tsx +46 -0
  103. package/src/app/settings/model/ModelSettingsPage.spec.tsx +8 -24
  104. package/src/app/settings/useBuilderStatus.spec.tsx +5 -0
  105. package/src/app/settings/useBuilderStatus.ts +8 -0
  106. package/src/app/styles/agent-native.spec.ts +5 -8
  107. package/src/app/styles/tokens/agent-kit.css +23 -11
  108. package/src/composer/MessageQueueDrawer.spec.tsx +20 -7
  109. package/src/composer/MessageQueueDrawer.tsx +47 -21
@@ -44,32 +44,65 @@ export interface McpConnectionSuggestionProps {
44
44
 
45
45
  const DISMISSED_MCP_SUGGESTIONS_STORAGE_KEY =
46
46
  "agent-native:mcp-connection-suggestions-dismissed";
47
+ const MCP_SUGGESTION_DISMISSAL_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
47
48
 
48
- function readDismissedMcpSuggestionIds(): string[] {
49
- if (typeof window === "undefined") return [];
49
+ function readDismissedMcpSuggestionTimestamps(
50
+ now = Date.now(),
51
+ ): Record<string, number> {
52
+ if (typeof window === "undefined") return {};
50
53
  try {
51
54
  const raw = window.localStorage.getItem(
52
55
  DISMISSED_MCP_SUGGESTIONS_STORAGE_KEY,
53
56
  );
54
- if (!raw) return [];
57
+ if (!raw) return {};
55
58
  const parsed: unknown = JSON.parse(raw);
56
- return Array.isArray(parsed)
57
- ? parsed.filter((value): value is string => typeof value === "string")
58
- : [];
59
+ if (Array.isArray(parsed)) {
60
+ const timestamps = Object.fromEntries(
61
+ parsed
62
+ .filter((id): id is string => typeof id === "string")
63
+ .map((id) => [id, now]),
64
+ ) as Record<string, number>;
65
+ try {
66
+ // Legacy IDs have no timestamp, so start their cooldown at migration.
67
+ window.localStorage.setItem(
68
+ DISMISSED_MCP_SUGGESTIONS_STORAGE_KEY,
69
+ JSON.stringify(timestamps),
70
+ );
71
+ } catch {
72
+ // coercion-ok: unavailable storage cannot persist the migration.
73
+ }
74
+ return timestamps;
75
+ }
76
+ if (!parsed || typeof parsed !== "object") {
77
+ return {};
78
+ }
79
+ const timestamps: Record<string, number> = {};
80
+ for (const [id, dismissedAt] of Object.entries(
81
+ parsed as Record<string, unknown>,
82
+ )) {
83
+ if (
84
+ typeof dismissedAt === "number" &&
85
+ Number.isFinite(dismissedAt) &&
86
+ dismissedAt <= now &&
87
+ now - dismissedAt < MCP_SUGGESTION_DISMISSAL_WINDOW_MS
88
+ ) {
89
+ timestamps[id] = dismissedAt;
90
+ }
91
+ }
92
+ return timestamps;
59
93
  } catch {
60
94
  // coercion-ok: unavailable browser storage means no persisted dismissals.
61
- return [];
95
+ return {};
62
96
  }
63
97
  }
64
98
 
65
- function rememberMcpSuggestionDismissal(id: string): void {
99
+ function rememberMcpSuggestionDismissal(id: string, dismissedAt: number): void {
66
100
  if (typeof window === "undefined") return;
67
101
  try {
68
- const ids = readDismissedMcpSuggestionIds();
69
- if (ids.includes(id)) return;
102
+ const timestamps = readDismissedMcpSuggestionTimestamps(dismissedAt);
70
103
  window.localStorage.setItem(
71
104
  DISMISSED_MCP_SUGGESTIONS_STORAGE_KEY,
72
- JSON.stringify([...ids, id]),
105
+ JSON.stringify({ ...timestamps, [id]: dismissedAt }),
73
106
  );
74
107
  } catch {
75
108
  // coercion-ok: unavailable browser storage only skips persistence.
@@ -180,8 +213,8 @@ export function McpConnectionSuggestion({
180
213
  const [quickConnectIntegrationId, setQuickConnectIntegrationId] = useState<
181
214
  string | null
182
215
  >(null);
183
- const [dismissedIds, setDismissedIds] = useState(
184
- readDismissedMcpSuggestionIds,
216
+ const [dismissedAtById, setDismissedAtById] = useState(
217
+ readDismissedMcpSuggestionTimestamps,
185
218
  );
186
219
  const [connecting, setConnecting] = useState(false);
187
220
  const [error, setError] = useState<string | null>(null);
@@ -230,11 +263,15 @@ export function McpConnectionSuggestion({
230
263
  (mcpServersQuery.data?.role === "owner" ||
231
264
  mcpServersQuery.data?.role === "admin"),
232
265
  );
266
+ const dismissedAt = integration ? dismissedAtById[integration.id] : undefined;
267
+ const dismissed =
268
+ dismissedAt !== undefined &&
269
+ Date.now() - dismissedAt < MCP_SUGGESTION_DISMISSAL_WINDOW_MS;
233
270
  const shouldSuggest =
234
271
  mcpServersQuery.isSuccess &&
235
272
  integration &&
236
273
  !connected &&
237
- !dismissedIds.includes(integration.id) &&
274
+ !dismissed &&
238
275
  (Boolean(integrationId) ||
239
276
  variant === "composer" ||
240
277
  isMcpConnectionFailureText(text) ||
@@ -249,6 +286,18 @@ export function McpConnectionSuggestion({
249
286
  setConnecting(false);
250
287
  }, [integration?.id, integration?.logoUrl, variant]);
251
288
 
289
+ useEffect(() => {
290
+ if (!integration || dismissedAt === undefined) return;
291
+ const timeout = window.setTimeout(
292
+ () => setDismissedAtById(readDismissedMcpSuggestionTimestamps()),
293
+ Math.max(
294
+ 0,
295
+ dismissedAt + MCP_SUGGESTION_DISMISSAL_WINDOW_MS - Date.now(),
296
+ ),
297
+ );
298
+ return () => window.clearTimeout(timeout);
299
+ }, [dismissedAt, integration?.id]);
300
+
252
301
  if (!shouldSuggest) return null;
253
302
 
254
303
  const connect = async () => {
@@ -314,12 +363,12 @@ export function McpConnectionSuggestion({
314
363
  if (connecting) return;
315
364
  setError(null);
316
365
  setConnecting(true);
317
- setDismissedIds((current) =>
318
- current.includes(integration.id)
319
- ? current
320
- : [...current, integration.id],
321
- );
322
- rememberMcpSuggestionDismissal(integration.id);
366
+ const dismissedAt = Date.now();
367
+ setDismissedAtById((current) => ({
368
+ ...current,
369
+ [integration.id]: dismissedAt,
370
+ }));
371
+ rememberMcpSuggestionDismissal(integration.id, dismissedAt);
323
372
  void Promise.resolve(onDismiss?.())
324
373
  .catch((cause: unknown) => {
325
374
  setError(
@@ -372,11 +421,10 @@ export function McpConnectionSuggestion({
372
421
  : undefined
373
422
  }
374
423
  onCreated={() => {
375
- setDismissedIds((current) =>
376
- current.includes(integration.id)
377
- ? current
378
- : [...current, integration.id],
379
- );
424
+ setDismissedAtById((current) => ({
425
+ ...current,
426
+ [integration.id]: Date.now(),
427
+ }));
380
428
  saveMcpConnectionResume(variant === "response" ? contextText : text);
381
429
  notifyMcpConnectionComplete();
382
430
  onConnected?.();
@@ -1,15 +1,55 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import { TooltipProvider } from "@agent-native/toolkit/ui/tooltip";
3
4
  import React, { act } from "react";
4
5
  import { createRoot, type Root } from "react-dom/client";
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
7
 
7
8
  vi.mock("@agent-native/core/client/i18n", () => ({
8
- useT: () => (_key: string, options?: { defaultValue?: string }) =>
9
- options?.defaultValue ?? _key,
9
+ useT:
10
+ () => (_key: string, options?: { defaultValue?: string; count?: number }) =>
11
+ (options?.defaultValue ?? _key).replace(
12
+ "{{count}}",
13
+ String(options?.count ?? ""),
14
+ ),
15
+ }));
16
+
17
+ vi.mock("@agent-native/core/client/onboarding/use-onboarding", () => ({
18
+ useOnboarding: () => ({
19
+ loading: false,
20
+ error: null,
21
+ profile: {
22
+ capabilities: [
23
+ {
24
+ id: "llm",
25
+ label: "AI model",
26
+ required: true,
27
+ builderIncluded: true,
28
+ service: "model",
29
+ keySummary: "Connect your own AI model",
30
+ why: "The agent uses a language model.",
31
+ },
32
+ {
33
+ id: "design-system-intelligence",
34
+ label: "Design system intelligence",
35
+ required: false,
36
+ builderIncluded: true,
37
+ service: "design-system-intelligence",
38
+ },
39
+ {
40
+ id: "background-agents",
41
+ label: "Background agents",
42
+ required: false,
43
+ builderIncluded: true,
44
+ service: "background-agents",
45
+ },
46
+ ],
47
+ },
48
+ }),
10
49
  }));
11
50
 
12
51
  import { BuilderConnectPopover } from "./BuilderConnectPopover.js";
52
+ import { DeferredBuilderConnectPopover } from "./deferred-builder-connect-popover.js";
13
53
 
14
54
  let container: HTMLDivElement;
15
55
  let root: Root;
@@ -46,7 +86,7 @@ function click(element: HTMLElement) {
46
86
  }
47
87
 
48
88
  function render(node: React.ReactElement) {
49
- act(() => root.render(node));
89
+ act(() => root.render(React.createElement(TooltipProvider, null, node)));
50
90
  }
51
91
 
52
92
  function trigger() {
@@ -57,6 +97,23 @@ function trigger() {
57
97
  }
58
98
 
59
99
  describe("BuilderConnectPopover before the status read resolves", () => {
100
+ it("starts the OAuth path from the original trigger click", () => {
101
+ const flow = {
102
+ connecting: false,
103
+ start: vi.fn(),
104
+ statusResolved: true,
105
+ agentNativeProvisioningEnabled: false,
106
+ accountExists: false,
107
+ };
108
+
109
+ render(
110
+ React.createElement(DeferredBuilderConnectPopover, { flow }, trigger()),
111
+ );
112
+ click(connectButton());
113
+
114
+ expect(flow.start).toHaveBeenCalledWith({ provisionAccount: false });
115
+ });
116
+
60
117
  it("uses existing-account sign-in when provisioning is unavailable", () => {
61
118
  const onConnect = vi.fn();
62
119
  const flow = {
@@ -182,6 +239,120 @@ describe("BuilderConnectPopover before the status read resolves", () => {
182
239
  expect(onConnect).toHaveBeenCalledWith(true);
183
240
  });
184
241
 
242
+ it("shows one sign-in action when Builder reports an existing account", () => {
243
+ const onConnect = vi.fn();
244
+ const flow = {
245
+ connecting: false,
246
+ start: vi.fn(),
247
+ statusResolved: true,
248
+ agentNativeProvisioningEnabled: true,
249
+ accountExists: true,
250
+ };
251
+
252
+ render(
253
+ React.createElement(
254
+ BuilderConnectPopover,
255
+ {
256
+ flow,
257
+ onConnect,
258
+ contentTestId: "consent",
259
+ primaryTestId: "create",
260
+ secondaryTestId: "sign-in",
261
+ },
262
+ trigger(),
263
+ ),
264
+ );
265
+ expect(document.querySelector("[data-testid='consent']")).toBeNull();
266
+ click(connectButton());
267
+
268
+ const consent = document.querySelector("[data-testid='consent']");
269
+ expect(consent?.textContent).toContain("builderActivateTitle");
270
+ expect(consent?.querySelector("p[role='status']")?.textContent).toBe(
271
+ "You already have a Builder.io account",
272
+ );
273
+ expect(consent?.textContent).toContain("Included free");
274
+ expect(consent?.textContent).not.toContain(
275
+ "Included free with a Builder.io account",
276
+ );
277
+ expect(consent?.textContent).toContain("60 monthly Agent Credits");
278
+ expect(consent?.textContent).toContain("+ 2 more services");
279
+ const services = consent?.querySelector<HTMLElement>(
280
+ "[data-testid='builder-included-services']",
281
+ );
282
+ const servicesToggle = services?.querySelector<HTMLButtonElement>(
283
+ "button[aria-expanded]",
284
+ );
285
+ expect(servicesToggle?.getAttribute("aria-expanded")).toBe("false");
286
+ expect(
287
+ Array.from(
288
+ servicesToggle?.querySelectorAll(":scope > span > span") ?? [],
289
+ ).map((line) => line.textContent),
290
+ ).toEqual([
291
+ "Included free",
292
+ "60 monthly Agent Credits",
293
+ "+ 2 more services",
294
+ ]);
295
+ expect(
296
+ consent?.querySelector<HTMLButtonElement>("[data-testid='create']")
297
+ ?.textContent,
298
+ ).toBe("agentChat.onboarding.builderSignInWithAccount");
299
+ expect(
300
+ consent?.querySelector<HTMLButtonElement>("[data-testid='sign-in']"),
301
+ ).toBeNull();
302
+ expect(
303
+ consent?.querySelector<HTMLButtonElement>("[data-testid='create'] svg"),
304
+ ).toBeNull();
305
+ expect(consent?.textContent).not.toContain("agentChat.auth.logIn");
306
+
307
+ click(servicesToggle!);
308
+ expect(servicesToggle?.getAttribute("aria-expanded")).toBe("true");
309
+ click(consent?.querySelector("[data-testid='create']") as HTMLElement);
310
+ expect(onConnect).toHaveBeenCalledWith(false);
311
+ });
312
+
313
+ it("reopens the same account choices when activation finds an existing account", () => {
314
+ const flow = {
315
+ connecting: false,
316
+ start: vi.fn(),
317
+ statusResolved: true,
318
+ agentNativeProvisioningEnabled: true,
319
+ accountExists: false,
320
+ };
321
+ const props = {
322
+ flow,
323
+ contentTestId: "consent",
324
+ primaryTestId: "create",
325
+ secondaryTestId: "sign-in",
326
+ };
327
+
328
+ render(React.createElement(BuilderConnectPopover, props, trigger()));
329
+ click(connectButton());
330
+ click(
331
+ document.querySelector(
332
+ "[data-testid='consent'] [data-testid='create']",
333
+ ) as HTMLElement,
334
+ );
335
+ expect(flow.start).toHaveBeenCalledWith({ provisionAccount: true });
336
+
337
+ render(
338
+ React.createElement(
339
+ BuilderConnectPopover,
340
+ { ...props, flow: { ...flow, accountExists: true } },
341
+ trigger(),
342
+ ),
343
+ );
344
+
345
+ const consent = document.querySelector("[data-testid='consent']");
346
+ expect(consent?.querySelector("p[role='status']")?.textContent).toBe(
347
+ "You already have a Builder.io account",
348
+ );
349
+ expect(consent?.querySelector("[data-testid='create']")).not.toBeNull();
350
+ expect(consent?.querySelector("[data-testid='sign-in']")).toBeNull();
351
+
352
+ click(consent?.querySelector("[data-testid='create']") as HTMLElement);
353
+ expect(flow.start).toHaveBeenLastCalledWith({ provisionAccount: false });
354
+ });
355
+
185
356
  it("stacks the create and sign-in buttons together with the terms below them", () => {
186
357
  const flow = {
187
358
  connecting: false,
@@ -225,9 +396,7 @@ describe("BuilderConnectPopover before the status read resolves", () => {
225
396
  p.querySelector("a"),
226
397
  );
227
398
  expect(create && signIn && terms).toBeTruthy();
228
- expect(consent?.textContent).toContain(
229
- "Included free with a Builder.io account",
230
- );
399
+ expect(consent?.textContent).toContain("Included free");
231
400
  expect(consent?.textContent).toContain("60 monthly Agent Credits");
232
401
  expect(signIn?.parentElement).toBe(create?.parentElement);
233
402
  expect(create!.compareDocumentPosition(signIn!)).toBe(
@@ -1,4 +1,5 @@
1
1
  import { useT } from "@agent-native/core/client/i18n";
2
+ import { useOnboarding } from "@agent-native/core/client/onboarding/use-onboarding";
2
3
  import { Button } from "@agent-native/toolkit/ui/button";
3
4
  import {
4
5
  Popover,
@@ -6,9 +7,12 @@ import {
6
7
  PopoverTrigger,
7
8
  } from "@agent-native/toolkit/ui/popover";
8
9
  import { Spinner } from "@agent-native/toolkit/ui/spinner";
9
- import { IconArrowRight } from "@tabler/icons-react";
10
10
  import React, { useEffect, useRef, useState } from "react";
11
11
 
12
+ import {
13
+ BuilderIncludedBenefitsDisclosure,
14
+ getBuilderIncludedCapabilities,
15
+ } from "../onboarding/BuilderIncludedServices.js";
12
16
  import type { BuilderConnectFlow } from "./useBuilderStatus.js";
13
17
 
14
18
  type BuilderConnectTrigger = React.ReactElement<{
@@ -19,13 +23,12 @@ type BuilderConnectTrigger = React.ReactElement<{
19
23
 
20
24
  export interface BuilderConnectPopoverProps {
21
25
  flow: Pick<BuilderConnectFlow, "connecting" | "start"> & {
26
+ accountExists?: boolean;
22
27
  cancel?: BuilderConnectFlow["cancel"];
23
28
  agentNativeProvisioningEnabled?: boolean;
24
- accountExists?: boolean;
25
29
  retry?: () => boolean | void;
26
30
  statusResolved?: boolean;
27
31
  statusReadSettledCount?: number;
28
- canConnect?: BuilderConnectFlow["canConnect"];
29
32
  provisionAccount?: boolean;
30
33
  };
31
34
  children: BuilderConnectTrigger;
@@ -49,30 +52,31 @@ export function BuilderConnectPopover({
49
52
  }: BuilderConnectPopoverProps) {
50
53
  const t = useT();
51
54
  const [open, setOpen] = useState(false);
55
+ const provisioningAttemptRef = useRef(false);
52
56
  const capabilityResolved = flow.statusResolved === true;
53
57
  const showPopover =
54
58
  (defaultProvisionAccount && !capabilityResolved) ||
55
59
  (capabilityResolved && flow.agentNativeProvisioningEnabled === true);
56
- const accountExists = capabilityResolved && flow.accountExists;
57
- const initiatedByThisTriggerRef = useRef(false);
58
60
  const [queuedClick, setQueuedClick] = useState<{ settledAt: number } | null>(
59
61
  null,
60
62
  );
61
63
  const settledCount = flow.statusReadSettledCount ?? 0;
62
64
 
63
65
  useEffect(() => {
64
- if (accountExists && initiatedByThisTriggerRef.current) {
65
- initiatedByThisTriggerRef.current = false;
66
+ if (flow.accountExists && provisioningAttemptRef.current) {
67
+ provisioningAttemptRef.current = false;
66
68
  setOpen(true);
69
+ } else if (!flow.connecting) {
70
+ provisioningAttemptRef.current = false;
67
71
  }
68
- }, [accountExists]);
72
+ }, [flow.accountExists, flow.connecting]);
69
73
 
70
74
  const start = (provisionAccount?: boolean) => {
71
75
  const shouldProvision =
72
76
  provisionAccount ??
73
77
  (flow.agentNativeProvisioningEnabled === true &&
74
78
  (defaultProvisionAccount || flow.provisionAccount === true));
75
- initiatedByThisTriggerRef.current = true;
79
+ if (shouldProvision) provisioningAttemptRef.current = true;
76
80
  setOpen(false);
77
81
  if (onConnect) {
78
82
  onConnect(shouldProvision);
@@ -139,11 +143,11 @@ export function BuilderConnectPopover({
139
143
  if (!showPopover) {
140
144
  return cancelAction ? (
141
145
  <span className="inline-flex max-w-full items-center gap-2">
142
- {trigger}
146
+ <span className="contents">{trigger}</span>
143
147
  {cancelAction}
144
148
  </span>
145
149
  ) : (
146
- trigger
150
+ <span className="contents">{trigger}</span>
147
151
  );
148
152
  }
149
153
 
@@ -151,60 +155,50 @@ export function BuilderConnectPopover({
151
155
  <Popover open={open} onOpenChange={setOpen}>
152
156
  <PopoverTrigger asChild>{trigger}</PopoverTrigger>
153
157
  <PopoverContent
154
- align="end"
155
- side="bottom"
156
- sideOffset={-40}
158
+ align="center"
159
+ side="right"
160
+ sideOffset={8}
157
161
  aria-labelledby="builder-connect-popover-title"
158
162
  data-testid={contentTestId}
159
- className="z-[330] w-80 max-w-[calc(100vw-2rem)] p-3 text-left"
163
+ className="z-[330] max-h-[min(640px,calc(100dvh-2rem),var(--radix-popover-content-available-height))] w-80 max-w-[calc(100vw-2rem)] overflow-y-auto p-3 text-left"
160
164
  >
161
165
  <div className="space-y-2.5">
162
166
  <h2
163
167
  id="builder-connect-popover-title"
164
168
  className="text-sm font-semibold text-foreground"
165
169
  >
166
- {accountExists
167
- ? t("agentChat.onboarding.builderAccountExistsTitle")
168
- : t("agentChat.onboarding.builderActivateTitle")}
170
+ {t("agentChat.onboarding.builderActivateTitle")}
169
171
  </h2>
170
- <p className="text-xs leading-5 text-muted-foreground">
171
- {accountExists
172
- ? t("agentChat.onboarding.builderAccountExistsDescription")
173
- : flow.canConnect?.org
174
- ? t("agentChat.onboarding.builderOrgActivationDescription")
175
- : t("agentChat.onboarding.builderActivationDescription")}
172
+ <p
173
+ role={flow.accountExists ? "status" : undefined}
174
+ className="text-xs leading-5 text-muted-foreground"
175
+ >
176
+ {flow.accountExists
177
+ ? t("agentChat.onboarding.builderAccountExistsTitle", {
178
+ defaultValue: "You already have a Builder.io account",
179
+ })
180
+ : t("agentChat.onboarding.builderActivationDescription", {
181
+ defaultValue:
182
+ "Create or connect a Builder.io account in one click to get free credits.",
183
+ })}
176
184
  </p>
177
- <div className="flex flex-col gap-1 rounded-[10px] bg-emerald-50 px-4 py-3 dark:bg-emerald-950/30">
178
- <p className="text-[13px] font-semibold text-foreground">
179
- {t("agentChat.onboarding.builderIncludedFreeWithAccount", {
180
- defaultValue: "Included free with a Builder.io account",
181
- })}
182
- </p>
183
- <p className="text-xs font-medium text-emerald-700 dark:text-emerald-400">
184
- {t("agentChat.onboarding.builderMonthlyCredits", {
185
- defaultValue: "60 monthly Agent Credits",
186
- })}
187
- </p>
188
- </div>
185
+ <BuilderConnectIncludedServices />
189
186
  <div className="flex flex-col gap-2">
190
187
  <Button
191
188
  type="button"
192
189
  data-testid={primaryTestId}
193
190
  className="w-full"
194
- onClick={() => start(accountExists ? false : true)}
191
+ onClick={() => start(!flow.accountExists)}
195
192
  disabled={flow.connecting}
196
193
  >
197
194
  {flow.connecting ? <Spinner aria-hidden /> : null}
198
- {accountExists
199
- ? t("agentChat.auth.logIn")
195
+ {flow.accountExists
196
+ ? t("agentChat.onboarding.builderSignInWithAccount")
200
197
  : flow.connecting
201
198
  ? t("agentChat.onboarding.builderActivating")
202
199
  : t("agentChat.onboarding.builderCreateAndActivate")}
203
- {!accountExists && !flow.connecting ? (
204
- <IconArrowRight aria-hidden />
205
- ) : null}
206
200
  </Button>
207
- {!accountExists && (
201
+ {!flow.accountExists ? (
208
202
  <Button
209
203
  type="button"
210
204
  variant="secondary"
@@ -215,31 +209,29 @@ export function BuilderConnectPopover({
215
209
  >
216
210
  {t("agentChat.onboarding.builderExistingAccount")}
217
211
  </Button>
218
- )}
212
+ ) : null}
219
213
  </div>
220
- {!accountExists && (
221
- <p className="text-[11px] leading-4 text-muted-foreground">
222
- {t("agentChat.onboarding.builderConsentPrefix")}{" "}
223
- <a
224
- href="https://www.builder.io/legal/terms"
225
- target="_blank"
226
- rel="noreferrer"
227
- className="underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
228
- >
229
- {t("agentChat.onboarding.builderTerms")}
230
- </a>{" "}
231
- {t("agentChat.onboarding.builderConsentAnd")}{" "}
232
- <a
233
- href="https://www.builder.io/legal/privacy"
234
- target="_blank"
235
- rel="noreferrer"
236
- className="underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
237
- >
238
- {t("agentChat.onboarding.builderPrivacy")}
239
- </a>
240
- .
241
- </p>
242
- )}
214
+ <p className="text-[11px] leading-4 text-muted-foreground">
215
+ {t("agentChat.onboarding.builderConsentPrefix")}{" "}
216
+ <a
217
+ href="https://www.builder.io/legal/terms"
218
+ target="_blank"
219
+ rel="noreferrer"
220
+ className="underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
221
+ >
222
+ {t("agentChat.onboarding.builderTerms")}
223
+ </a>{" "}
224
+ {t("agentChat.onboarding.builderConsentAnd")}{" "}
225
+ <a
226
+ href="https://www.builder.io/legal/privacy"
227
+ target="_blank"
228
+ rel="noreferrer"
229
+ className="underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
230
+ >
231
+ {t("agentChat.onboarding.builderPrivacy")}
232
+ </a>
233
+ .
234
+ </p>
243
235
  </div>
244
236
  </PopoverContent>
245
237
  </Popover>
@@ -254,3 +246,31 @@ export function BuilderConnectPopover({
254
246
  popover
255
247
  );
256
248
  }
249
+
250
+ function BuilderConnectIncludedServices() {
251
+ const t = useT();
252
+ const { profile, loading, error } = useOnboarding();
253
+ const capabilities = profile
254
+ ? getBuilderIncludedCapabilities(profile.capabilities)
255
+ : [];
256
+
257
+ return (
258
+ <BuilderIncludedBenefitsDisclosure
259
+ capabilities={capabilities}
260
+ includedLabel={t("agentChat.onboarding.builderIncludedFree", {
261
+ defaultValue: "Included free",
262
+ })}
263
+ creditsLabel={t("agentChat.onboarding.builderMonthlyCredits", {
264
+ defaultValue: "60 monthly Agent Credits",
265
+ })}
266
+ loading={loading}
267
+ loadingLabel={t("agentChat.common.loading")}
268
+ error={
269
+ error || (!loading && !profile)
270
+ ? t("agentChat.common.chunkLoadFailed")
271
+ : null
272
+ }
273
+ testId="builder-included-services"
274
+ />
275
+ );
276
+ }