@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
@@ -47,6 +47,7 @@ import {
47
47
  useState,
48
48
  useSyncExternalStore,
49
49
  type MouseEvent,
50
+ type ReactNode,
50
51
  } from "react";
51
52
  import { useInRouterContext, useLocation, useNavigate } from "react-router";
52
53
 
@@ -74,8 +75,10 @@ import {
74
75
  SETTINGS_PAGE_GROUPS,
75
76
  getSettingsPages,
76
77
  isSettingsPageVisible,
78
+ mergeAppSettingsPages,
77
79
  sortSettingsPages,
78
80
  subscribeSettingsPages,
81
+ type AppSettingsPageDefinition,
79
82
  type SettingsPageContext,
80
83
  type SettingsPageDefinition,
81
84
  type SettingsPageGroup,
@@ -104,6 +107,14 @@ export interface SettingsShellProps extends SettingsBridgeInput {
104
107
  appIcon?: SettingsPageIcon;
105
108
  /** Scopes the app's changelog unread dot and usage. Defaults to the template id. */
106
109
  appId?: string | null;
110
+ /** App-owned pages and controls shown in this shell's app group. */
111
+ appSettingsPages?: readonly AppSettingsPageDefinition[];
112
+ /** Replaces the default page groups with ordered app-owned sections. */
113
+ appNavigationSections?: readonly AppSettingsNavigationSection[];
114
+ /** Optional app chat surface rendered beside the settings shell. */
115
+ agentPanel?: ReactNode;
116
+ /** Replaces the shared agent toggle when an app owns its chat state. */
117
+ agentToggle?: ReactNode;
107
118
  /** Raw CHANGELOG.md; enables the What's new unread dot. */
108
119
  whatsNewMarkdown?: string;
109
120
  enableSearch?: boolean;
@@ -123,6 +134,41 @@ export interface SettingsShellProps extends SettingsBridgeInput {
123
134
  onValueChange?: (tabId: string) => void;
124
135
  }
125
136
 
137
+ interface AppSettingsNavigationItemBase {
138
+ id: string;
139
+ visibleWhen?: (context: SettingsPageContext) => boolean;
140
+ }
141
+
142
+ export type AppSettingsNavigationItem =
143
+ | (AppSettingsNavigationItemBase & {
144
+ type: "page";
145
+ pageId: string;
146
+ sub?: string;
147
+ label?: string;
148
+ icon?: SettingsPageIcon;
149
+ })
150
+ | (AppSettingsNavigationItemBase & {
151
+ type: "link";
152
+ label: string;
153
+ href: string;
154
+ icon?: SettingsPageIcon;
155
+ disabled?: boolean;
156
+ })
157
+ | (AppSettingsNavigationItemBase & {
158
+ type: "action";
159
+ label: string;
160
+ onSelect: (context: SettingsPageContext) => void;
161
+ icon?: SettingsPageIcon;
162
+ disabled?: boolean;
163
+ });
164
+
165
+ export interface AppSettingsNavigationSection {
166
+ id: string;
167
+ label: string;
168
+ items: readonly AppSettingsNavigationItem[];
169
+ visibleWhen?: (context: SettingsPageContext) => boolean;
170
+ }
171
+
126
172
  // The agent panel module is heavy and already loaded by the app layout; keep
127
173
  // it out of the shell's own chunk.
128
174
  const AgentToggleButton = lazy(() =>
@@ -339,6 +385,10 @@ function SettingsShellContent({
339
385
  className,
340
386
  navClassName,
341
387
  contentClassName,
388
+ appSettingsPages,
389
+ appNavigationSections,
390
+ agentPanel,
391
+ agentToggle,
342
392
  value,
343
393
  initialValue,
344
394
  onValueChange,
@@ -413,9 +463,15 @@ function SettingsShellContent({
413
463
  extraTabs ?? [],
414
464
  declared,
415
465
  BridgedTabSettingsPage,
466
+ appSettingsPages,
416
467
  );
417
468
  return {
418
- pages: sortSettingsPages([...declared, ...derived.pages]),
469
+ pages: sortSettingsPages(
470
+ mergeAppSettingsPages(
471
+ [...declared, ...derived.pages],
472
+ appSettingsPages,
473
+ ),
474
+ ),
419
475
  bridge: createSettingsBridge(
420
476
  {
421
477
  extraTabs,
@@ -445,6 +501,7 @@ function SettingsShellContent({
445
501
  appAreas,
446
502
  appGroupLabel,
447
503
  appLabs,
504
+ appSettingsPages,
448
505
  extraTabs,
449
506
  general,
450
507
  generalGroups,
@@ -470,12 +527,27 @@ function SettingsShellContent({
470
527
  () => bridge.appAreas.map((area) => area.id),
471
528
  [bridge.appAreas],
472
529
  );
530
+ const templateTabPageIds = useMemo(
531
+ () =>
532
+ new Map(
533
+ visiblePages.flatMap((page) => {
534
+ const tab = bridge.tabForPage(page.id);
535
+ return tab ? [[tab.id, page.id] as const] : [];
536
+ }),
537
+ ),
538
+ [bridge, visiblePages],
539
+ );
473
540
  // The template's first `value` is its default, not a choice (Mail starts
474
541
  // on "integrations"), so only a later one names a page for bare /settings.
475
542
  const tabValue = value !== undefined && value !== initialValue ? value : null;
476
543
  const resolved = useMemo(
477
- () => resolveSettingsRoute(location, pages, { appAreaIds, tabValue }),
478
- [appAreaIds, location, pages, tabValue],
544
+ () =>
545
+ resolveSettingsRoute(location, pages, {
546
+ appAreaIds,
547
+ tabValue,
548
+ templateTabPageIds,
549
+ }),
550
+ [appAreaIds, location, pages, tabValue, templateTabPageIds],
479
551
  );
480
552
  const activePage =
481
553
  visiblePages.find((page) => page.id === resolved.page) ??
@@ -605,14 +677,26 @@ function SettingsShellContent({
605
677
  seenValueRef.current = value;
606
678
  // The template echoing the tab the shell just reported.
607
679
  if (value === reportedValueRef.current) return;
608
- const target = resolveSettingsTabValue(value, visiblePages, appAreaIds);
680
+ const target = resolveSettingsTabValue(
681
+ value,
682
+ visiblePages,
683
+ appAreaIds,
684
+ templateTabPageIds,
685
+ );
609
686
  if (target.page && target.page !== routePage) {
610
687
  navigate(target.page, target.sub, {
611
688
  replace: true,
612
689
  anchor: target.anchor ?? undefined,
613
690
  });
614
691
  }
615
- }, [appAreaIds, navigate, routePage, value, visiblePages]);
692
+ }, [
693
+ appAreaIds,
694
+ navigate,
695
+ routePage,
696
+ templateTabPageIds,
697
+ value,
698
+ visiblePages,
699
+ ]);
616
700
 
617
701
  const latestChangelogId = getChangelogLatestId(
618
702
  bridge.whatsNewMarkdown ?? undefined,
@@ -726,12 +810,16 @@ function SettingsShellContent({
726
810
  pageLabel={pageLabel}
727
811
  pageIcon={pageIcon}
728
812
  appName={appGroupLabel}
813
+ allPages={pages}
729
814
  returnHref={appMountedPath(returnPath, SETTINGS_ROUTE)}
730
815
  onReturn={() => go(returnPath, false)}
731
816
  enableSearch={enableSearch}
732
817
  searchIndex={searchIndex}
733
818
  unreadPageIds={changelog.unseen ? ["whats-new"] : []}
734
819
  onNavigate={navigate}
820
+ activeSub={routeSub}
821
+ context={context}
822
+ appNavigationSections={appNavigationSections}
735
823
  />
736
824
  );
737
825
 
@@ -853,9 +941,13 @@ function SettingsShellContent({
853
941
  </div>
854
942
  </div>
855
943
  <div className="absolute end-3.5 top-3.5">
856
- <Suspense fallback={null}>
857
- <AgentToggleButton showWhenOpen />
858
- </Suspense>
944
+ {agentToggle === undefined ? (
945
+ <Suspense fallback={null}>
946
+ <AgentToggleButton showWhenOpen />
947
+ </Suspense>
948
+ ) : (
949
+ agentToggle
950
+ )}
859
951
  </div>
860
952
  </header>
861
953
  <div className="mx-auto w-full max-w-[824px] px-4 pb-28 pt-1 min-[760px]:px-8">
@@ -864,8 +956,12 @@ function SettingsShellContent({
864
956
  <Page key={activePage.id} {...pageProps} />
865
957
  </Suspense>
866
958
  ) : null}
959
+ {activePage?.group === "app" && activePage.widgetSlot != null ? (
960
+ <div data-settings-widget-slot="">{activePage.widgetSlot}</div>
961
+ ) : null}
867
962
  </div>
868
963
  </div>
964
+ {agentPanel}
869
965
  </div>
870
966
  </SettingsShellProvider>
871
967
  );
@@ -873,6 +969,7 @@ function SettingsShellContent({
873
969
 
874
970
  interface SettingsNavProps {
875
971
  pages: readonly SettingsPageDefinition[];
972
+ allPages: readonly SettingsPageDefinition[];
876
973
  activePageId: string | null;
877
974
  groupLabel: (group: SettingsPageGroup) => string;
878
975
  pageLabel: (page: SettingsPageDefinition) => string;
@@ -884,10 +981,14 @@ interface SettingsNavProps {
884
981
  searchIndex: readonly SettingsSearchResult[];
885
982
  unreadPageIds: readonly string[];
886
983
  onNavigate: SettingsShellContextValue["navigate"];
984
+ activeSub: string | null;
985
+ context: SettingsPageContext;
986
+ appNavigationSections?: readonly AppSettingsNavigationSection[];
887
987
  }
888
988
 
889
989
  function SettingsNav({
890
990
  pages,
991
+ allPages,
891
992
  activePageId,
892
993
  groupLabel,
893
994
  pageLabel,
@@ -899,6 +1000,9 @@ function SettingsNav({
899
1000
  searchIndex,
900
1001
  unreadPageIds,
901
1002
  onNavigate,
1003
+ activeSub,
1004
+ context,
1005
+ appNavigationSections,
902
1006
  }: SettingsNavProps) {
903
1007
  const t = useT();
904
1008
  const [query, setQuery] = useState("");
@@ -943,22 +1047,46 @@ function SettingsNav({
943
1047
  return byGroup;
944
1048
  }, [pages]);
945
1049
 
946
- const renderItem = (page: SettingsPageDefinition) => {
947
- const Icon = pageIcon(page);
948
- const selected = page.id === activePageId;
949
- const href = page.href
950
- ? appMountedPath(page.href, SETTINGS_ROUTE)
951
- : settingsPageHref(page.id);
1050
+ const renderItem = (
1051
+ page: SettingsPageDefinition,
1052
+ item?: Extract<AppSettingsNavigationItem, { type: "page" }>,
1053
+ ) => {
1054
+ const Icon = item?.icon ?? pageIcon(page);
1055
+ const hasVisibleSubpageEntry = Boolean(
1056
+ activeSub &&
1057
+ appNavigationSections?.some(
1058
+ (section) =>
1059
+ (section.visibleWhen?.(context) ?? true) &&
1060
+ section.items.some(
1061
+ (candidate) =>
1062
+ candidate.type === "page" &&
1063
+ candidate.pageId === page.id &&
1064
+ candidate.sub === activeSub &&
1065
+ (candidate.visibleWhen?.(context) ?? true),
1066
+ ),
1067
+ ),
1068
+ );
1069
+ const selected =
1070
+ page.id === activePageId &&
1071
+ (item?.sub !== undefined
1072
+ ? item.sub === activeSub
1073
+ : activeSub === null || !hasVisibleSubpageEntry);
1074
+ const href = item?.sub
1075
+ ? settingsPageHref(page.id, item.sub)
1076
+ : page.href
1077
+ ? appMountedPath(page.href, SETTINGS_ROUTE)
1078
+ : settingsPageHref(page.id);
952
1079
  return (
953
1080
  <a
954
- key={page.id}
1081
+ key={item?.id ?? page.id}
955
1082
  href={href}
956
1083
  data-settings-page={page.id}
1084
+ data-settings-nav-item={item?.id}
957
1085
  aria-current={selected ? "page" : undefined}
958
1086
  onClick={(event) => {
959
1087
  if (isModifiedClick(event)) return;
960
1088
  event.preventDefault();
961
- onNavigate(page.id);
1089
+ onNavigate(page.id, item?.sub ?? null);
962
1090
  }}
963
1091
  className={cn(
964
1092
  "flex h-[30px] items-center gap-2 rounded-md px-2 text-[13px] font-medium transition-colors",
@@ -973,7 +1101,7 @@ function SettingsNav({
973
1101
  selected ? "text-foreground" : "text-muted-foreground",
974
1102
  )}
975
1103
  />
976
- <span className="truncate">{pageLabel(page)}</span>
1104
+ <span className="truncate">{item?.label ?? pageLabel(page)}</span>
977
1105
  {unreadPageIds.includes(page.id) ? (
978
1106
  <span
979
1107
  role="img"
@@ -985,6 +1113,90 @@ function SettingsNav({
985
1113
  );
986
1114
  };
987
1115
 
1116
+ const renderNavigationItem = (item: AppSettingsNavigationItem) => {
1117
+ if (item.visibleWhen && !item.visibleWhen(context)) return null;
1118
+ if (item.type === "page") {
1119
+ const page = pages.find((candidate) => candidate.id === item.pageId);
1120
+ if (!page) {
1121
+ if (allPages.some((candidate) => candidate.id === item.pageId)) {
1122
+ return null;
1123
+ }
1124
+ throw new Error(
1125
+ `Settings navigation item "${item.id}" references unknown page "${item.pageId}".`,
1126
+ );
1127
+ }
1128
+ return renderItem(page, item);
1129
+ }
1130
+
1131
+ const Icon = item.icon;
1132
+ const icon = Icon ? (
1133
+ <Icon
1134
+ aria-hidden="true"
1135
+ className="size-4 shrink-0 text-muted-foreground"
1136
+ />
1137
+ ) : null;
1138
+ const className = cn(
1139
+ "flex h-[30px] w-full items-center gap-2 rounded-md px-2 text-start text-[13px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/60 hover:text-foreground",
1140
+ item.disabled && "pointer-events-none opacity-50",
1141
+ );
1142
+ if (item.type === "link") {
1143
+ return (
1144
+ <a
1145
+ key={item.id}
1146
+ href={appMountedPath(item.href, SETTINGS_ROUTE)}
1147
+ data-settings-link={item.id}
1148
+ aria-disabled={item.disabled || undefined}
1149
+ tabIndex={item.disabled ? -1 : undefined}
1150
+ onClick={(event) => {
1151
+ if (item.disabled || isModifiedClick(event)) {
1152
+ if (item.disabled) event.preventDefault();
1153
+ return;
1154
+ }
1155
+ }}
1156
+ className={className}
1157
+ >
1158
+ {icon}
1159
+ <span className="truncate">{item.label}</span>
1160
+ </a>
1161
+ );
1162
+ }
1163
+
1164
+ return (
1165
+ <button
1166
+ key={item.id}
1167
+ type="button"
1168
+ data-settings-action={item.id}
1169
+ disabled={item.disabled}
1170
+ onClick={() => item.onSelect(context)}
1171
+ className={className}
1172
+ >
1173
+ {icon}
1174
+ <span className="truncate">{item.label}</span>
1175
+ </button>
1176
+ );
1177
+ };
1178
+
1179
+ const renderSection = (section: AppSettingsNavigationSection) => {
1180
+ if (section.visibleWhen && !section.visibleWhen(context)) return null;
1181
+ const items = section.items.flatMap((item) => {
1182
+ const rendered = renderNavigationItem(item);
1183
+ return rendered ? [rendered] : [];
1184
+ });
1185
+ if (items.length === 0) return null;
1186
+ return (
1187
+ <div
1188
+ key={section.id}
1189
+ data-settings-group={section.id}
1190
+ className="mt-3.5 flex flex-col gap-px"
1191
+ >
1192
+ <div className="truncate px-2 pb-1 text-[11.5px] font-medium text-muted-foreground">
1193
+ {section.label}
1194
+ </div>
1195
+ {items}
1196
+ </div>
1197
+ );
1198
+ };
1199
+
988
1200
  const searching = enableSearch && query.trim().length > 0;
989
1201
 
990
1202
  return (
@@ -1093,20 +1305,22 @@ function SettingsNav({
1093
1305
  aria-label={t("agentChat.settingsShell.navLabel")}
1094
1306
  className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3"
1095
1307
  >
1096
- {SETTINGS_PAGE_GROUPS.filter(
1097
- (group) => (groups.get(group) ?? []).length > 0,
1098
- ).map((group) => (
1099
- <div
1100
- key={group}
1101
- data-settings-group={group}
1102
- className="mt-3.5 flex flex-col gap-px"
1103
- >
1104
- <div className="truncate px-2 pb-1 text-[11.5px] font-medium text-muted-foreground">
1105
- {groupLabel(group)}
1106
- </div>
1107
- {(groups.get(group) ?? []).map(renderItem)}
1108
- </div>
1109
- ))}
1308
+ {appNavigationSections
1309
+ ? appNavigationSections.map(renderSection)
1310
+ : SETTINGS_PAGE_GROUPS.filter(
1311
+ (group) => (groups.get(group) ?? []).length > 0,
1312
+ ).map((group) => (
1313
+ <div
1314
+ key={group}
1315
+ data-settings-group={group}
1316
+ className="mt-3.5 flex flex-col gap-px"
1317
+ >
1318
+ <div className="truncate px-2 pb-1 text-[11.5px] font-medium text-muted-foreground">
1319
+ {groupLabel(group)}
1320
+ </div>
1321
+ {(groups.get(group) ?? []).map((page) => renderItem(page))}
1322
+ </div>
1323
+ ))}
1110
1324
  </nav>
1111
1325
  )}
1112
1326
  </div>
@@ -92,6 +92,18 @@ describe("settings shell bridge", () => {
92
92
  expect(pages[0]!.legacyTabIds).toEqual(["automations"]);
93
93
  });
94
94
 
95
+ it("reserves app page ids and legacy tab ids while deriving template pages", () => {
96
+ const { pages, pageTabs } = deriveBridgedAppPages(
97
+ [tab("custom-controls"), tab("workflow-rules")],
98
+ CORE_SETTINGS_PAGES,
99
+ Stub,
100
+ [{ id: "custom-controls", legacyTabIds: ["workflow-rules"] }],
101
+ );
102
+
103
+ expect(pages.map((page) => page.id)).toEqual(["app-custom-controls"]);
104
+ expect(pageTabs.get("app-custom-controls")?.id).toBe("custom-controls");
105
+ });
106
+
95
107
  it("keeps app-area tabs off the nav", () => {
96
108
  const { pages } = deriveBridgedAppPages(
97
109
  [tab("recordings", { settingsPlacement: "app-area" })],
@@ -68,6 +68,8 @@ export interface SettingsBridge {
68
68
  tab: (...ids: string[]) => SettingsTabItem | undefined;
69
69
  /** The template tab behind a page the bridge derived from `extraTabs`. */
70
70
  tabForPage: (pageId: string) => SettingsTabItem | undefined;
71
+ /** The bridged page behind a template tab id. */
72
+ pageForTab: (tabId: string) => string | undefined;
71
73
  }
72
74
 
73
75
  export const EMPTY_SETTINGS_BRIDGE: SettingsBridge = createSettingsBridge({});
@@ -85,6 +87,9 @@ export function createSettingsBridge(
85
87
  );
86
88
  const labs = input.labs ?? [];
87
89
  const byId = new Map(tabs.map((tab) => [tab.id, tab]));
90
+ const pageByTabId = new Map(
91
+ [...pageTabs].map(([pageId, tab]) => [tab.id, pageId]),
92
+ );
88
93
  return {
89
94
  general: input.generalGroups ?? input.general ?? null,
90
95
  account: input.account ?? null,
@@ -109,6 +114,7 @@ export function createSettingsBridge(
109
114
  return undefined;
110
115
  },
111
116
  tabForPage: (pageId) => pageTabs.get(pageId),
117
+ pageForTab: (tabId) => pageByTabId.get(tabId),
112
118
  };
113
119
  }
114
120
 
@@ -162,16 +168,23 @@ export interface BridgedAppPages {
162
168
 
163
169
  /**
164
170
  * Agent tabs stay in the Agent group; other unclaimed tabs become app pages
165
- * after General. An id that collides with a core page (Mail's inbox
166
- * `automations`) gets an `app-` prefix so it never shadows the core page.
171
+ * after General. An id that collides with an existing page gets an `app-`
172
+ * prefix so it never shadows that page.
167
173
  */
168
174
  export function deriveBridgedAppPages(
169
175
  tabs: readonly SettingsTabItem[],
170
176
  corePages: readonly SettingsPageDefinition[],
171
177
  component: SettingsPageDefinition["component"],
178
+ reservedPages: readonly Pick<
179
+ SettingsPageDefinition,
180
+ "id" | "legacyTabIds"
181
+ >[] = [],
172
182
  ): BridgedAppPages {
173
- const claimed = new Set(corePages.flatMap((page) => page.legacyTabIds ?? []));
174
- const taken = new Set(corePages.map((page) => page.id));
183
+ const allReservedPages = [...corePages, ...reservedPages];
184
+ const claimed = new Set(
185
+ allReservedPages.flatMap((page) => page.legacyTabIds ?? []),
186
+ );
187
+ const taken = new Set(allReservedPages.map((page) => page.id));
175
188
  const pages: SettingsPageDefinition[] = [];
176
189
  const pageTabs = new Map<string, SettingsTabItem>();
177
190
  tabs.forEach((tab, index) => {
@@ -8,7 +8,11 @@ export const SettingsShell = lazy(() =>
8
8
  default: module.SettingsShell,
9
9
  })),
10
10
  );
11
- export type { SettingsShellProps } from "./SettingsShell.js";
11
+ export type {
12
+ AppSettingsNavigationItem,
13
+ AppSettingsNavigationSection,
14
+ SettingsShellProps,
15
+ } from "./SettingsShell.js";
12
16
  export { SettingsShellSkeleton } from "./SettingsShellSkeleton.js";
13
17
  export {
14
18
  canManageOrganizationPages,
@@ -22,6 +26,7 @@ export {
22
26
  sortSettingsPages,
23
27
  subscribeSettingsPages,
24
28
  unregisterSettingsPage,
29
+ type AppSettingsPageDefinition,
25
30
  type CoreSettingsPageId,
26
31
  type SettingsPageContext,
27
32
  type SettingsPageDefinition,
@@ -1,7 +1,7 @@
1
1
  import type { LabStates } from "@agent-native/core/client/labs/use-lab";
2
2
  import type { CoreSettingsPageId } from "@agent-native/core/navigation/settings-redirects";
3
3
  import type { OrgRole } from "@agent-native/core/org/types";
4
- import type { ComponentType } from "react";
4
+ import type { ComponentType, ReactNode } from "react";
5
5
 
6
6
  import type { SettingsBridge } from "./bridge.js";
7
7
  import type { SettingsPageIcon } from "./types.js";
@@ -83,6 +83,8 @@ export interface SettingsPageDefinition {
83
83
  icon: SettingsPageIcon;
84
84
  visible?: (context: SettingsPageContext, bridge: SettingsBridge) => boolean;
85
85
  component: ComponentType<SettingsPageProps>;
86
+ /** Optional app-owned content rendered after the page on app settings pages. */
87
+ widgetSlot?: ReactNode;
86
88
  subpages?: readonly SettingsSubpageDefinition[];
87
89
  searchEntries?: readonly SettingsPageSearchEntry[];
88
90
  /** Rendered at the right of the sticky header. */
@@ -99,6 +101,14 @@ export interface SettingsPageDefinition {
99
101
  keywords?: string;
100
102
  }
101
103
 
104
+ /** An app-owned page shown in the app group without entering the shared registry. */
105
+ export type AppSettingsPageDefinition = Omit<
106
+ SettingsPageDefinition,
107
+ "group"
108
+ > & {
109
+ group?: "app";
110
+ };
111
+
102
112
  const PAGE_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
103
113
 
104
114
  export function defineSettingsPage(
@@ -115,6 +125,28 @@ export function defineSettingsPage(
115
125
  return Object.freeze({ ...definition });
116
126
  }
117
127
 
128
+ /** Adds instance-owned pages after shared and registered pages. */
129
+ export function mergeAppSettingsPages(
130
+ pages: readonly SettingsPageDefinition[],
131
+ appPages: readonly AppSettingsPageDefinition[] = [],
132
+ ): SettingsPageDefinition[] {
133
+ const existingIds = new Set(pages.map((page) => page.id));
134
+ const appIds = new Set<string>();
135
+ const additions = appPages.map((page) => {
136
+ if (existingIds.has(page.id)) {
137
+ throw new Error(
138
+ `App settings page id "${page.id}" conflicts with an existing page id.`,
139
+ );
140
+ }
141
+ if (appIds.has(page.id)) {
142
+ throw new Error(`Duplicate app settings page id "${page.id}".`);
143
+ }
144
+ appIds.add(page.id);
145
+ return defineSettingsPage({ ...page, group: "app" });
146
+ });
147
+ return [...pages, ...additions];
148
+ }
149
+
118
150
  const registry = new Map<string, SettingsPageDefinition>();
119
151
  const listeners = new Set<() => void>();
120
152
  let snapshot: readonly SettingsPageDefinition[] = [];