@agent-native/toolkit 0.216.0 → 0.217.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 (76) hide show
  1. package/agent-native.eject.json +5 -0
  2. package/dist/app/auth/AuthPage.d.ts.map +1 -1
  3. package/dist/app/auth/AuthPage.js +33 -44
  4. package/dist/app/auth/AuthPage.js.map +1 -1
  5. package/dist/app/onboarding/GettingStartedCard.d.ts +35 -0
  6. package/dist/app/onboarding/GettingStartedCard.d.ts.map +1 -0
  7. package/dist/app/onboarding/GettingStartedCard.js +38 -0
  8. package/dist/app/onboarding/GettingStartedCard.js.map +1 -0
  9. package/dist/app/onboarding/index.d.ts +1 -0
  10. package/dist/app/onboarding/index.d.ts.map +1 -1
  11. package/dist/app/onboarding/index.js +1 -0
  12. package/dist/app/onboarding/index.js.map +1 -1
  13. package/dist/app/providers/AppProviders.d.ts.map +1 -1
  14. package/dist/app/providers/AppProviders.js +3 -1
  15. package/dist/app/providers/AppProviders.js.map +1 -1
  16. package/dist/app/settings/AccountSettingsCard.d.ts.map +1 -1
  17. package/dist/app/settings/AccountSettingsCard.js +2 -1
  18. package/dist/app/settings/AccountSettingsCard.js.map +1 -1
  19. package/dist/app/settings/index.d.ts +1 -0
  20. package/dist/app/settings/index.d.ts.map +1 -1
  21. package/dist/app/settings/index.js +1 -0
  22. package/dist/app/settings/index.js.map +1 -1
  23. package/dist/app/settings/shell/SettingsShell.d.ts +40 -1
  24. package/dist/app/settings/shell/SettingsShell.d.ts.map +1 -1
  25. package/dist/app/settings/shell/SettingsShell.js +87 -20
  26. package/dist/app/settings/shell/SettingsShell.js.map +1 -1
  27. package/dist/app/settings/shell/bridge.d.ts +5 -3
  28. package/dist/app/settings/shell/bridge.d.ts.map +1 -1
  29. package/dist/app/settings/shell/bridge.js +8 -5
  30. package/dist/app/settings/shell/bridge.js.map +1 -1
  31. package/dist/app/settings/shell/index.d.ts +2 -2
  32. package/dist/app/settings/shell/index.d.ts.map +1 -1
  33. package/dist/app/settings/shell/index.js.map +1 -1
  34. package/dist/app/settings/shell/registry.d.ts +9 -1
  35. package/dist/app/settings/shell/registry.d.ts.map +1 -1
  36. package/dist/app/settings/shell/registry.js +16 -0
  37. package/dist/app/settings/shell/registry.js.map +1 -1
  38. package/dist/app/settings/shell/routing.d.ts +5 -4
  39. package/dist/app/settings/shell/routing.d.ts.map +1 -1
  40. package/dist/app/settings/shell/routing.js +38 -10
  41. package/dist/app/settings/shell/routing.js.map +1 -1
  42. package/dist/app/shared/CommandMenu.d.ts +86 -6
  43. package/dist/app/shared/CommandMenu.d.ts.map +1 -1
  44. package/dist/app/shared/CommandMenu.js +303 -41
  45. package/dist/app/shared/CommandMenu.js.map +1 -1
  46. package/dist/app/shared/ConsentBanner.d.ts +9 -0
  47. package/dist/app/shared/ConsentBanner.d.ts.map +1 -0
  48. package/dist/app/shared/ConsentBanner.js +35 -0
  49. package/dist/app/shared/ConsentBanner.js.map +1 -0
  50. package/dist/ui/sonner.d.ts +2 -2
  51. package/dist/ui/sonner.d.ts.map +1 -1
  52. package/dist/ui/sonner.js +2 -2
  53. package/dist/ui/sonner.js.map +1 -1
  54. package/package.json +3 -3
  55. package/src/app/auth/AuthPage.interaction.spec.tsx +74 -0
  56. package/src/app/auth/AuthPage.tsx +32 -39
  57. package/src/app/onboarding/GettingStartedCard.tsx +203 -0
  58. package/src/app/onboarding/index.ts +1 -0
  59. package/src/app/providers/AppProviders.tsx +7 -1
  60. package/src/app/settings/AccountSettingsCard.spec.tsx +31 -0
  61. package/src/app/settings/AccountSettingsCard.tsx +8 -0
  62. package/src/app/settings/index.ts +1 -0
  63. package/src/app/settings/shell/SettingsShell.spec.tsx +472 -1
  64. package/src/app/settings/shell/SettingsShell.tsx +245 -31
  65. package/src/app/settings/shell/bridge.spec.ts +12 -0
  66. package/src/app/settings/shell/bridge.ts +17 -4
  67. package/src/app/settings/shell/index.ts +6 -1
  68. package/src/app/settings/shell/registry.ts +33 -1
  69. package/src/app/settings/shell/routing.spec.ts +92 -1
  70. package/src/app/settings/shell/routing.ts +52 -9
  71. package/src/app/shared/CommandMenu.spec.tsx +1662 -1
  72. package/src/app/shared/CommandMenu.tsx +763 -164
  73. package/src/app/shared/ConsentBanner.spec.tsx +141 -0
  74. package/src/app/shared/ConsentBanner.tsx +141 -0
  75. package/src/ui/sonner.spec.ts +10 -0
  76. package/src/ui/sonner.tsx +2 -2
@@ -29,7 +29,11 @@ vi.mock("@agent-native/core/client/feature-flags/use-feature-flag", () => ({
29
29
  useFeatureFlags: () => ({}),
30
30
  }));
31
31
  vi.mock("../../chat/AgentSidebar.js", () => ({
32
- AgentToggleButton: () => <button type="button">agent</button>,
32
+ AgentToggleButton: () => (
33
+ <button type="button" data-testid="shared-agent-toggle">
34
+ agent
35
+ </button>
36
+ ),
33
37
  }));
34
38
 
35
39
  const appState = vi.hoisted(() => ({
@@ -52,7 +56,10 @@ import { useSettingsPageHeader, useSettingsShell } from "./context.js";
52
56
  import { CORE_SETTINGS_PAGES } from "./core-pages.js";
53
57
  import {
54
58
  _resetSettingsPagesForTests,
59
+ getSettingsPages,
60
+ mergeAppSettingsPages,
55
61
  registerSettingsPages,
62
+ type AppSettingsPageDefinition,
56
63
  type SettingsPageProps,
57
64
  } from "./registry.js";
58
65
  import {
@@ -83,6 +90,42 @@ function StubPage({ pageId, sub }: SettingsPageProps) {
83
90
  );
84
91
  }
85
92
 
93
+ function AppSettingsPage({ pageId, sub }: SettingsPageProps) {
94
+ const { navigate } = useSettingsShell();
95
+ return (
96
+ <div data-testid="app-settings-page">
97
+ page:{pageId}
98
+ {sub ? ` sub:${sub}` : ""}
99
+ <label>
100
+ App control
101
+ <input aria-label="App control" type="checkbox" />
102
+ </label>
103
+ <button
104
+ type="button"
105
+ onClick={() => navigate(pageId, "delivery")}
106
+ data-testid="open-app-subpage"
107
+ >
108
+ Delivery
109
+ </button>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function appPage(
115
+ id: string,
116
+ label: string,
117
+ order = 25,
118
+ component: AppSettingsPageDefinition["component"] = StubPage,
119
+ ): AppSettingsPageDefinition {
120
+ return {
121
+ id,
122
+ label,
123
+ order,
124
+ icon: CORE_SETTINGS_PAGES[0]!.icon,
125
+ component,
126
+ };
127
+ }
128
+
86
129
  async function flush() {
87
130
  await act(async () => {
88
131
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -209,6 +252,395 @@ describe("SettingsShell", () => {
209
252
  expect(container.textContent).toContain("Back to Clips");
210
253
  });
211
254
 
255
+ it("adds ordered app-owned pages and renders their custom controls", async () => {
256
+ await render({
257
+ notifications: <p>Email notifications</p>,
258
+ appSettingsPages: [
259
+ appPage(
260
+ "custom-preferences",
261
+ "Custom preferences",
262
+ 25,
263
+ AppSettingsPage,
264
+ ),
265
+ appPage("custom-options", "Custom options", 27),
266
+ ],
267
+ });
268
+
269
+ expect(groupLabels().find((group) => group.id === "app")?.pages).toEqual([
270
+ "app",
271
+ "notifications",
272
+ "custom-preferences",
273
+ "custom-options",
274
+ "automations",
275
+ "channels",
276
+ "mcp",
277
+ "labs",
278
+ ]);
279
+ clickPage("custom-preferences");
280
+ await waitFor(() =>
281
+ Boolean(container.querySelector('[data-testid="app-settings-page"]')),
282
+ );
283
+ expect(
284
+ container.querySelector('[data-testid="app-settings-page"]')?.textContent,
285
+ ).toContain("page:custom-preferences");
286
+ expect(
287
+ container.querySelector('[aria-label="App control"]'),
288
+ ).not.toBeNull();
289
+ expect(
290
+ getSettingsPages().some((page) => page.id === "mail-preferences"),
291
+ ).toBe(false);
292
+ });
293
+
294
+ it("renders ordered app sections with page, link, and action entries", async () => {
295
+ const onAddWorkspace = vi.fn();
296
+ const customPage = {
297
+ ...appPage("workspace-alpha", "Workspace Alpha", 25, AppSettingsPage),
298
+ subpages: [{ id: "settings", label: "Workspace settings" }],
299
+ };
300
+
301
+ await render({
302
+ appSettingsPages: [customPage],
303
+ appNavigationSections: [
304
+ {
305
+ id: "workspaces",
306
+ label: "Workspaces",
307
+ items: [
308
+ {
309
+ id: "workspace-overview",
310
+ type: "page",
311
+ pageId: "workspace-alpha",
312
+ label: "Workspace Alpha",
313
+ },
314
+ {
315
+ id: "workspace-settings",
316
+ type: "page",
317
+ pageId: "workspace-alpha",
318
+ sub: "settings",
319
+ label: "Workspace settings",
320
+ },
321
+ {
322
+ id: "workspace-help",
323
+ type: "link",
324
+ label: "Workspace help",
325
+ href: "/help",
326
+ visibleWhen: () => false,
327
+ },
328
+ {
329
+ id: "add-workspace",
330
+ type: "action",
331
+ label: "Add workspace",
332
+ onSelect: onAddWorkspace,
333
+ },
334
+ ],
335
+ },
336
+ {
337
+ id: "administration",
338
+ label: "Administration",
339
+ visibleWhen: (context) => context.isAdmin,
340
+ items: [
341
+ {
342
+ id: "invite-member",
343
+ type: "action",
344
+ label: "Invite member",
345
+ onSelect: () => undefined,
346
+ },
347
+ ],
348
+ },
349
+ ],
350
+ });
351
+
352
+ expect(
353
+ [...rail().querySelectorAll<HTMLElement>("[data-settings-group]")].map(
354
+ (group) => group.dataset.settingsGroup,
355
+ ),
356
+ ).toEqual(["workspaces"]);
357
+ expect(
358
+ rail().querySelector('[data-settings-link="workspace-help"]'),
359
+ ).toBeNull();
360
+ expect(
361
+ rail()
362
+ .querySelector('[data-settings-nav-item="workspace-settings"]')
363
+ ?.getAttribute("href"),
364
+ ).toBe("/settings/workspace-alpha/settings");
365
+
366
+ act(() => {
367
+ rail()
368
+ .querySelector<HTMLButtonElement>(
369
+ '[data-settings-action="add-workspace"]',
370
+ )
371
+ ?.click();
372
+ });
373
+ expect(onAddWorkspace).toHaveBeenCalled();
374
+
375
+ const subpageLink = rail().querySelector<HTMLElement>(
376
+ '[data-settings-nav-item="workspace-settings"]',
377
+ );
378
+ expect(subpageLink?.getAttribute("href")).toBe(
379
+ "/settings/workspace-alpha/settings",
380
+ );
381
+ act(() => {
382
+ subpageLink?.dispatchEvent(
383
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
384
+ );
385
+ });
386
+ expect(
387
+ rail().querySelector(
388
+ '[data-settings-nav-item="workspace-overview"][aria-current="page"]',
389
+ ),
390
+ ).toBeNull();
391
+ expect(
392
+ rail().querySelector(
393
+ '[data-settings-nav-item="workspace-settings"][aria-current="page"]',
394
+ ),
395
+ ).not.toBeNull();
396
+ await waitFor(() =>
397
+ Boolean(container.querySelector('[data-testid="app-settings-page"]')),
398
+ );
399
+ });
400
+
401
+ it("keeps controlled template tabs ahead of a same-id app page", async () => {
402
+ const extraTab = {
403
+ id: "workspace-alpha",
404
+ label: "Template workspace",
405
+ content: "Template workspace content",
406
+ };
407
+ const customPage = appPage(
408
+ "workspace-alpha",
409
+ "Workspace alpha",
410
+ 5,
411
+ AppSettingsPage,
412
+ );
413
+
414
+ await render({
415
+ value: "profile",
416
+ initialValue: "profile",
417
+ extraTabs: [extraTab],
418
+ appSettingsPages: [customPage],
419
+ });
420
+ await act(async () => {
421
+ root.render(
422
+ renderWithToolkitI18n(
423
+ <SettingsShell
424
+ appName="Clips"
425
+ value="workspace-alpha"
426
+ initialValue="profile"
427
+ extraTabs={[extraTab]}
428
+ appSettingsPages={[customPage]}
429
+ />,
430
+ ),
431
+ );
432
+ });
433
+ await waitFor(() =>
434
+ window.location.pathname.endsWith("/app-workspace-alpha"),
435
+ );
436
+ });
437
+
438
+ it("mounts an app chat panel and replaces the shared toggle by default", async () => {
439
+ await render({
440
+ agentPanel: <div data-testid="app-agent-panel">Local chat</div>,
441
+ agentToggle: (
442
+ <button type="button" data-testid="app-agent-toggle">
443
+ Open chat
444
+ </button>
445
+ ),
446
+ });
447
+
448
+ const shell = container.querySelector<HTMLElement>("[data-settings-shell]");
449
+ const panel = shell?.querySelector('[data-testid="app-agent-panel"]');
450
+ expect(panel).not.toBeNull();
451
+ expect(panel?.parentElement).toBe(shell);
452
+ expect(
453
+ shell?.querySelector('[data-testid="app-agent-toggle"]'),
454
+ ).not.toBeNull();
455
+ expect(
456
+ shell?.querySelector('[data-testid="shared-agent-toggle"]'),
457
+ ).toBeNull();
458
+ });
459
+
460
+ it("renders an opted-in app page widget slot after the page content", async () => {
461
+ window.history.replaceState(null, "", "/settings/app-help");
462
+ await render({
463
+ appSettingsPages: [
464
+ {
465
+ ...appPage("app-help", "App help"),
466
+ widgetSlot: <div data-testid="settings-help-widget">Help</div>,
467
+ },
468
+ ],
469
+ initialValue: "app-help",
470
+ value: "app-help",
471
+ });
472
+
473
+ const widget = container.querySelector('[data-settings-widget-slot=""]');
474
+ expect(widget?.textContent).toBe("Help");
475
+ expect(widget?.previousElementSibling?.getAttribute("data-testid")).toBe(
476
+ "page",
477
+ );
478
+ });
479
+
480
+ it("does not render an app page widget slot when it is omitted", async () => {
481
+ window.history.replaceState(null, "", "/settings/app-help");
482
+ await render({
483
+ appSettingsPages: [appPage("app-help", "App help")],
484
+ initialValue: "app-help",
485
+ value: "app-help",
486
+ });
487
+
488
+ expect(
489
+ container.querySelector('[data-settings-widget-slot=""]'),
490
+ ).toBeNull();
491
+ });
492
+
493
+ it("keeps template tabs separate when an app page uses the same id", async () => {
494
+ await render({
495
+ appSettingsPages: [
496
+ appPage("custom-controls", "Custom controls", 25, AppSettingsPage),
497
+ ],
498
+ extraTabs: [
499
+ { id: "custom-controls", label: "Template controls", content: "tabs" },
500
+ ],
501
+ });
502
+
503
+ expect(groupLabels().find((group) => group.id === "app")?.pages).toEqual(
504
+ expect.arrayContaining(["custom-controls", "app-custom-controls"]),
505
+ );
506
+ clickPage("custom-controls");
507
+ await waitFor(() =>
508
+ Boolean(container.querySelector('[data-testid="app-settings-page"]')),
509
+ );
510
+ expect(
511
+ container.querySelector('[data-testid="app-settings-page"]')?.textContent,
512
+ ).toContain("page:custom-controls");
513
+ });
514
+
515
+ it("opens an app page when its id matches an app area", async () => {
516
+ await render({
517
+ appAreas: [{ id: "recordings", label: "Recordings", content: null }],
518
+ appSettingsPages: [
519
+ appPage("recordings", "Recordings settings", 25, AppSettingsPage),
520
+ ],
521
+ });
522
+
523
+ clickPage("recordings");
524
+ await waitFor(() =>
525
+ Boolean(container.querySelector('[data-testid="app-settings-page"]')),
526
+ );
527
+
528
+ expect(
529
+ container.querySelector('[data-testid="app-settings-page"]')?.textContent,
530
+ ).toContain("page:recordings");
531
+ expect(window.location.pathname).toBe("/settings/recordings");
532
+ });
533
+
534
+ it("rejects app page ids that would replace an existing page", () => {
535
+ expect(() =>
536
+ mergeAppSettingsPages(CORE_SETTINGS_PAGES, [
537
+ appPage("profile", "Replacement profile"),
538
+ ]),
539
+ ).toThrow(
540
+ 'App settings page id "profile" conflicts with an existing page id.',
541
+ );
542
+ expect(() =>
543
+ mergeAppSettingsPages(
544
+ [],
545
+ [
546
+ appPage("custom-preferences", "First"),
547
+ appPage("custom-preferences", "Second"),
548
+ ],
549
+ ),
550
+ ).toThrow('Duplicate app settings page id "custom-preferences".');
551
+ });
552
+
553
+ it("keeps app pages and search entries isolated between shell instances", async () => {
554
+ const firstRouter = createMemoryRouter(
555
+ [
556
+ {
557
+ path: "/settings/*",
558
+ element: (
559
+ <SettingsShell
560
+ appName="First app"
561
+ appSettingsPages={[
562
+ {
563
+ ...appPage("first-settings", "First settings", 25),
564
+ keywords: "first-only",
565
+ },
566
+ ]}
567
+ />
568
+ ),
569
+ },
570
+ ],
571
+ { initialEntries: ["/settings/first-settings"] },
572
+ );
573
+ const secondRouter = createMemoryRouter(
574
+ [
575
+ {
576
+ path: "/settings/*",
577
+ element: (
578
+ <SettingsShell
579
+ appName="Second app"
580
+ appSettingsPages={[
581
+ {
582
+ ...appPage("second-settings", "Second settings", 25),
583
+ keywords: "second-only",
584
+ },
585
+ ]}
586
+ />
587
+ ),
588
+ },
589
+ ],
590
+ { initialEntries: ["/settings/second-settings"] },
591
+ );
592
+
593
+ await act(async () => {
594
+ root.render(
595
+ renderWithToolkitI18n(
596
+ <>
597
+ <RouterProvider router={firstRouter} />
598
+ <RouterProvider router={secondRouter} />
599
+ </>,
600
+ ),
601
+ );
602
+ });
603
+ await flush();
604
+
605
+ const shells = [
606
+ ...container.querySelectorAll<HTMLElement>("[data-settings-shell]"),
607
+ ];
608
+ expect(shells).toHaveLength(2);
609
+ expect(shells[0]?.querySelector('[data-testid="page"]')?.textContent).toBe(
610
+ "page:first-settings",
611
+ );
612
+ expect(shells[1]?.querySelector('[data-testid="page"]')?.textContent).toBe(
613
+ "page:second-settings",
614
+ );
615
+ expect(
616
+ shells[0]?.querySelector('[data-settings-page="second-settings"]'),
617
+ ).toBeNull();
618
+ expect(
619
+ shells[1]?.querySelector('[data-settings-page="first-settings"]'),
620
+ ).toBeNull();
621
+
622
+ const inputs = shells.map(
623
+ (shell) => shell.querySelector<HTMLInputElement>('input[type="search"]')!,
624
+ );
625
+ const setValue = Object.getOwnPropertyDescriptor(
626
+ HTMLInputElement.prototype,
627
+ "value",
628
+ )!.set!;
629
+ act(() => {
630
+ setValue.call(inputs[0], "first-only");
631
+ inputs[0]!.dispatchEvent(new Event("input", { bubbles: true }));
632
+ setValue.call(inputs[1], "first-only");
633
+ inputs[1]!.dispatchEvent(new Event("input", { bubbles: true }));
634
+ });
635
+ expect(shells[0]?.querySelector('[role="option"]')?.textContent).toContain(
636
+ "First settings",
637
+ );
638
+ expect(shells[1]?.querySelector('[role="option"]')).toBeNull();
639
+ expect(getSettingsPages().map((page) => page.id)).not.toEqual(
640
+ expect.arrayContaining(["first-settings", "second-settings"]),
641
+ );
642
+ });
643
+
212
644
  it("shows Notifications and What's new when the app passes them", async () => {
213
645
  await render({
214
646
  notifications: <div>email</div>,
@@ -530,6 +962,45 @@ describe("SettingsShell", () => {
530
962
  );
531
963
  });
532
964
 
965
+ it("routes an app page search entry to its nested page", async () => {
966
+ const customPage: AppSettingsPageDefinition = {
967
+ ...appPage("custom-controls", "Custom controls", 25, AppSettingsPage),
968
+ subpages: [{ id: "delivery", label: "Delivery settings" }],
969
+ searchEntries: [
970
+ {
971
+ id: "delivery-rules",
972
+ label: "Delivery rules",
973
+ keywords: "exceptions",
974
+ sub: "delivery",
975
+ },
976
+ ],
977
+ };
978
+ await render({ appSettingsPages: [customPage] });
979
+
980
+ clickPage("custom-controls");
981
+ await waitFor(() =>
982
+ Boolean(container.querySelector('[data-testid="app-settings-page"]')),
983
+ );
984
+ expect(
985
+ container.querySelector('[aria-label="App control"]'),
986
+ ).not.toBeNull();
987
+
988
+ expect(search("delivery exceptions")[0]).toContain("Delivery rules");
989
+ press("Enter");
990
+ await flush();
991
+ expect(window.location.pathname).toBe(
992
+ "/settings/custom-controls/delivery",
993
+ );
994
+ expect(
995
+ container.querySelector('header nav [aria-current="page"]')
996
+ ?.textContent,
997
+ ).toBe("Delivery settings");
998
+ expect(
999
+ container.querySelector('[data-testid="app-settings-page"]')
1000
+ ?.textContent,
1001
+ ).toContain("sub:delivery");
1002
+ });
1003
+
533
1004
  it("focuses on / and clears on Escape", async () => {
534
1005
  await render();
535
1006
  act(() => {