@agent-native/core 0.80.5 → 0.80.7

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/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/create.ts +1 -0
  5. package/corpus/core/src/client/extensions/EmbeddedExtension.tsx +45 -0
  6. package/corpus/core/src/sharing/registry.ts +17 -0
  7. package/corpus/core/src/templates/default/pnpm-workspace.yaml +1 -0
  8. package/corpus/core/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
  9. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +42 -4
  10. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +2 -1
  11. package/corpus/templates/analytics/actions/update-dashboard.ts +23 -9
  12. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +72 -1
  13. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  14. package/corpus/templates/analytics/app/i18n-data.ts +34 -0
  15. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +88 -2
  16. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +7 -1
  17. package/corpus/templates/analytics/changelog/2026-06-29-dashboards-can-now-include-extension-panels-that-embed-a-san.md +6 -0
  18. package/corpus/templates/content/AGENTS.md +45 -44
  19. package/corpus/templates/content/README.md +16 -0
  20. package/corpus/templates/content/actions/_builder-cms-read-client.ts +125 -0
  21. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +28 -6
  22. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +238 -69
  23. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +125 -43
  24. package/corpus/templates/content/actions/_database-source-utils.ts +746 -97
  25. package/corpus/templates/content/actions/_database-utils.ts +17 -14
  26. package/corpus/templates/content/actions/_federation-join.ts +14 -2
  27. package/corpus/templates/content/actions/_join-suggestion.ts +89 -14
  28. package/corpus/templates/content/actions/_local-file-documents.ts +2 -1
  29. package/corpus/templates/content/actions/attach-content-database-source.ts +124 -5
  30. package/corpus/templates/content/actions/bind-content-database-source-field.ts +256 -0
  31. package/corpus/templates/content/actions/change-content-database-source-role.ts +420 -0
  32. package/corpus/templates/content/actions/create-document.ts +8 -0
  33. package/corpus/templates/content/actions/execute-builder-source-batch.ts +282 -0
  34. package/corpus/templates/content/actions/execute-builder-source-execution.ts +220 -8
  35. package/corpus/templates/content/actions/get-content-database.ts +10 -2
  36. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +19 -2
  37. package/corpus/templates/content/actions/prepare-builder-source-review.ts +47 -15
  38. package/corpus/templates/content/actions/refresh-content-database-source.ts +11 -4
  39. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +6 -2
  40. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +32 -12
  41. package/corpus/templates/content/actions/stage-builder-revision.ts +14 -10
  42. package/corpus/templates/content/actions/validate-builder-source-execution.ts +25 -3
  43. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1520 -553
  44. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +150 -1
  45. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +647 -66
  46. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +543 -204
  47. package/corpus/templates/content/app/hooks/use-content-database.ts +62 -0
  48. package/corpus/templates/content/app/i18n/zh-TW.ts +64 -0
  49. package/corpus/templates/content/app/i18n-data.ts +784 -0
  50. package/corpus/templates/content/changelog/2026-06-29-builder-review-checks-now-preserve-publish-and-unpublish-cho.md +6 -0
  51. package/corpus/templates/content/changelog/2026-06-29-database-sources-can-now-be-added-as-more-rows-or-matched.md +6 -0
  52. package/corpus/templates/content/changelog/2026-06-29-fixed-loading-the-final-rows-in-larger-databases-without-bre.md +6 -0
  53. package/corpus/templates/content/shared/api.ts +91 -1
  54. package/corpus/templates/content/vite.config.ts +14 -7
  55. package/dist/cli/create.d.ts.map +1 -1
  56. package/dist/cli/create.js +1 -0
  57. package/dist/cli/create.js.map +1 -1
  58. package/dist/client/extensions/EmbeddedExtension.d.ts +10 -1
  59. package/dist/client/extensions/EmbeddedExtension.d.ts.map +1 -1
  60. package/dist/client/extensions/EmbeddedExtension.js +34 -3
  61. package/dist/client/extensions/EmbeddedExtension.js.map +1 -1
  62. package/dist/collab/routes.d.ts +2 -2
  63. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  64. package/dist/notifications/routes.d.ts +1 -1
  65. package/dist/observability/routes.d.ts +7 -7
  66. package/dist/resources/handlers.d.ts +2 -2
  67. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  68. package/dist/server/transcribe-voice.d.ts +1 -1
  69. package/dist/sharing/registry.d.ts.map +1 -1
  70. package/dist/sharing/registry.js +11 -0
  71. package/dist/sharing/registry.js.map +1 -1
  72. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  73. package/dist/templates/workspace-root/pnpm-workspace.yaml +1 -0
  74. package/package.json +1 -1
  75. package/src/templates/default/pnpm-workspace.yaml +1 -0
  76. package/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
@@ -139,8 +139,10 @@ import {
139
139
  } from "@/components/ui/tooltip";
140
140
  import {
141
141
  useAddDatabaseItem,
142
+ useAddContentDatabaseSourceFieldProperty,
142
143
  useAttachContentDatabaseSource,
143
144
  useBuilderCmsModels,
145
+ useChangeContentDatabaseSourceRole,
144
146
  useContentDatabase,
145
147
  useContentDatabases,
146
148
  contentDatabaseQueryKey,
@@ -166,7 +168,11 @@ import {
166
168
  import { messagesByLocale } from "@/i18n-data";
167
169
  import { cn } from "@/lib/utils";
168
170
 
169
- import { BuilderSourceReviewDialog } from "../database-sources/BuilderSourceReviewDialog";
171
+ import { resolveBuilderCmsWriteEffect } from "../../../../actions/_builder-cms-write-adapter.js";
172
+ import {
173
+ BuilderSourceReviewDialog,
174
+ type BuilderReviewPublicationTransitions,
175
+ } from "../database-sources/BuilderSourceReviewDialog";
170
176
  import { DocumentBlockFields } from "../DocumentBlockFields";
171
177
  import {
172
178
  AddProperty,
@@ -205,6 +211,7 @@ export interface DatabaseViewProps {
205
211
  }
206
212
 
207
213
  const CONTENT_DATABASE_PAGE_SIZE = 100;
214
+ const CONTENT_DATABASE_MAX_ITEM_LIMIT = 5_000;
208
215
 
209
216
  export type SortDirection = ContentDatabaseSortDirection;
210
217
  export type DatabaseSort = ContentDatabaseSort;
@@ -239,13 +246,23 @@ const DATABASE_FILTER_MODES: DatabaseFilterMode[] = ["and", "or"];
239
246
 
240
247
  type DatabaseMessageKey = keyof (typeof messagesByLocale)["en-US"]["database"];
241
248
 
242
- function dbText(key: DatabaseMessageKey): string {
249
+ function dbText(
250
+ key: DatabaseMessageKey,
251
+ values?: Record<string, string | number>,
252
+ ): string {
243
253
  const locale =
244
254
  typeof document === "undefined" ? "en-US" : document.documentElement.lang;
245
255
  const messages =
246
256
  messagesByLocale[locale as keyof typeof messagesByLocale] ??
247
257
  messagesByLocale["en-US"];
248
- return messages.database[key] ?? messagesByLocale["en-US"].database[key];
258
+ const text =
259
+ messages.database[key] ?? messagesByLocale["en-US"].database[key];
260
+ if (!values) return text;
261
+ return Object.entries(values).reduce(
262
+ (current, [name, value]) =>
263
+ current.split(`{{${name}}}`).join(String(value)),
264
+ text,
265
+ );
249
266
  }
250
267
 
251
268
  type CreateDatabaseRowHandler = (
@@ -459,6 +476,7 @@ function DatabaseTable({
459
476
  const database = useContentDatabase(document.id, databaseItemLimit);
460
477
  const addItem = useAddDatabaseItem(document.id);
461
478
  const attachSource = useAttachContentDatabaseSource(document.id);
479
+ const changeSourceRole = useChangeContentDatabaseSourceRole(document.id);
462
480
  const refreshSource = useRefreshContentDatabaseSource(document.id);
463
481
  const disconnectSource = useDisconnectContentDatabaseSource(document.id);
464
482
  const prepareBuilderReview = usePrepareBuilderSourceReview(document.id);
@@ -470,7 +488,11 @@ function DatabaseTable({
470
488
  const properties = data?.properties ?? [];
471
489
  const items = data?.items ?? [];
472
490
  const totalItemCount = data?.pagination?.totalItems ?? items.length;
473
- const hasMoreItems = data?.pagination?.hasMore === true;
491
+ const hasMoreItems =
492
+ data?.pagination?.hasMore === true &&
493
+ databaseItemLimit < CONTENT_DATABASE_MAX_ITEM_LIMIT;
494
+ const isLoadingMoreItems =
495
+ database.isFetching && data?.pagination?.limit !== databaseItemLimit;
474
496
  const databaseId = data?.database.id ?? expectedDatabaseId;
475
497
  const source = data?.source ?? null;
476
498
  const sources = data?.sources ?? (source ? [source] : []);
@@ -933,7 +955,9 @@ function DatabaseTable({
933
955
  updateActiveView((view) => ({ ...view, hideEmptyGroups }));
934
956
  }
935
957
 
936
- async function handleBuilderReviewPush() {
958
+ async function handleBuilderReviewPush(
959
+ transitions: BuilderReviewPublicationTransitions = {},
960
+ ) {
937
961
  setBuilderReviewResult(null);
938
962
  setBuilderReviewCheckedAt(null);
939
963
  try {
@@ -951,11 +975,14 @@ function DatabaseTable({
951
975
  let executedResponse: ContentDatabaseResponse | null = null;
952
976
  for (const row of executableRows) {
953
977
  if (!row.execution?.idempotencyKey) continue;
978
+ const transition = transitions[row.changeSetId];
954
979
  executedResponse = await executeBuilderExecution.mutateAsync({
955
980
  documentId: document.id,
956
981
  changeSetId: row.changeSetId,
957
982
  idempotencyKey: row.execution.idempotencyKey,
958
983
  pushModeConfirmation: nextReview.pushMode,
984
+ publicationTransition: transition?.publicationTransition,
985
+ confirmUnpublish: transition?.confirmUnpublish,
959
986
  });
960
987
  }
961
988
  const executedSource = executedResponse?.source ?? null;
@@ -1016,11 +1043,14 @@ function DatabaseTable({
1016
1043
  const nextViewConfig = normalizeClientDatabaseViewConfig(
1017
1044
  data.database.viewConfig,
1018
1045
  );
1019
- hydratedViewRef.current = databaseViewStateKey(
1020
- data.database.id,
1021
- nextViewConfig,
1046
+ const nextKey = databaseViewStateKey(data.database.id, nextViewConfig);
1047
+ if (hydratedViewRef.current === nextKey) return;
1048
+ hydratedViewRef.current = nextKey;
1049
+ setViewConfig((current) =>
1050
+ databaseViewStateKey(data.database.id, current) === nextKey
1051
+ ? current
1052
+ : nextViewConfig,
1022
1053
  );
1023
- setViewConfig(nextViewConfig);
1024
1054
  }, [data?.database.id, data?.database.viewConfig]);
1025
1055
 
1026
1056
  useEffect(() => {
@@ -1443,14 +1473,18 @@ function DatabaseTable({
1443
1473
  type="button"
1444
1474
  variant="outline"
1445
1475
  size="sm"
1446
- disabled={database.isFetching}
1476
+ disabled={isLoadingMoreItems}
1447
1477
  onClick={() =>
1448
- setDatabaseItemLimit(
1449
- (current) => current + CONTENT_DATABASE_PAGE_SIZE,
1478
+ setDatabaseItemLimit((current) =>
1479
+ Math.min(
1480
+ current + CONTENT_DATABASE_PAGE_SIZE,
1481
+ totalItemCount,
1482
+ CONTENT_DATABASE_MAX_ITEM_LIMIT,
1483
+ ),
1450
1484
  )
1451
1485
  }
1452
1486
  >
1453
- {database.isFetching
1487
+ {isLoadingMoreItems
1454
1488
  ? "Loading..."
1455
1489
  : `Load more rows (${items.length} of ${totalItemCount})`}
1456
1490
  </Button>
@@ -1494,35 +1528,53 @@ function DatabaseTable({
1494
1528
  groupIds={toolbarGroups.map((group) => group.id)}
1495
1529
  onClose={() => setSettingsOpen(false)}
1496
1530
  onPanelChange={setSettingsPanel}
1497
- onAttachBuilderSource={(model) =>
1498
- attachSource.mutate({
1531
+ onAttachBuilderSource={(model, relationshipMode) =>
1532
+ attachSource.mutateAsync({
1499
1533
  documentId: document.id,
1500
1534
  sourceType: "builder-cms",
1501
1535
  sourceName: model.displayName,
1502
1536
  sourceTable: model.name,
1537
+ relationshipMode,
1538
+ mode:
1539
+ relationshipMode === "items"
1540
+ ? "add"
1541
+ : sources.length > 0 || source
1542
+ ? undefined
1543
+ : "replace",
1503
1544
  })
1504
1545
  }
1505
1546
  onFederateSource={(candidate, join) =>
1506
- attachSource.mutate({
1547
+ attachSource.mutateAsync({
1507
1548
  documentId: document.id,
1508
1549
  sourceType: candidate.sourceType,
1509
1550
  sourceName: candidate.sourceName,
1510
1551
  sourceTable: candidate.sourceTable,
1552
+ relationshipMode: "details",
1553
+ join,
1554
+ })
1555
+ }
1556
+ onChangeSourceRole={(sourceId, relationshipMode, join) =>
1557
+ changeSourceRole.mutateAsync({
1558
+ documentId: document.id,
1559
+ sourceId,
1560
+ relationshipMode,
1511
1561
  join,
1512
1562
  })
1513
1563
  }
1514
1564
  onDisconnectSecondary={(sourceId) =>
1515
1565
  disconnectSource.mutate({ documentId: document.id, sourceId })
1516
1566
  }
1517
- onRefreshSource={() =>
1567
+ onRefreshSource={(sourceId) =>
1518
1568
  refreshSource.mutate({
1519
1569
  documentId: document.id,
1570
+ sourceId,
1520
1571
  })
1521
1572
  }
1522
- onDisconnectSource={() =>
1573
+ onDisconnectSource={(sourceId) =>
1523
1574
  disconnectSource.mutate(
1524
1575
  {
1525
1576
  documentId: document.id,
1577
+ sourceId,
1526
1578
  },
1527
1579
  {
1528
1580
  onSuccess: () => {
@@ -1581,6 +1633,7 @@ function DatabaseTable({
1581
1633
  }
1582
1634
  sourceActionPending={
1583
1635
  attachSource.isPending ||
1636
+ changeSourceRole.isPending ||
1584
1637
  refreshSource.isPending ||
1585
1638
  disconnectSource.isPending ||
1586
1639
  prepareBuilderReview.isPending ||
@@ -1613,7 +1666,7 @@ function DatabaseTable({
1613
1666
  }
1614
1667
  checkedAt={builderReviewCheckedAt}
1615
1668
  onClose={() => setBuilderReviewOpen(false)}
1616
- onValidate={() => void handleBuilderReviewPush()}
1669
+ onValidate={(transitions) => void handleBuilderReviewPush(transitions)}
1617
1670
  />
1618
1671
 
1619
1672
  {!database.isLoading ? (
@@ -3403,6 +3456,7 @@ type PendingSourceCandidate = {
3403
3456
  sourceName: string;
3404
3457
  sourceTable: string;
3405
3458
  displayName: string;
3459
+ existingSourceId?: string;
3406
3460
  };
3407
3461
 
3408
3462
  type SourceNavStep =
@@ -3411,16 +3465,18 @@ type SourceNavStep =
3411
3465
  | { kind: "model"; model: BuilderCmsModelSummary }
3412
3466
  | { kind: "addSource" }
3413
3467
  | { kind: "secondarySource"; sourceId: string; sourceName: string }
3414
- | { kind: "keyConfirm"; candidate: PendingSourceCandidate };
3468
+ | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
3469
+ | { kind: "fieldPicker"; sourceId: string; sourceName: string };
3415
3470
 
3416
3471
  function sourceNavTitle(stack: SourceNavStep[]): string {
3417
3472
  const top = stack[stack.length - 1];
3418
- if (!top) return "Sources";
3473
+ if (!top) return dbText("sources");
3419
3474
  if (top.kind === "provider") return "Builder";
3420
3475
  if (top.kind === "space") return top.spaceName;
3421
- if (top.kind === "addSource") return "Add a source";
3476
+ if (top.kind === "addSource") return dbText("addASource");
3422
3477
  if (top.kind === "secondarySource") return top.sourceName;
3423
- if (top.kind === "keyConfirm") return "Match on a key";
3478
+ if (top.kind === "keyConfirm") return dbText("matchExistingItemsToDetails");
3479
+ if (top.kind === "fieldPicker") return dbText("chooseFields");
3424
3480
  return top.model.displayName;
3425
3481
  }
3426
3482
 
@@ -3480,6 +3536,7 @@ function DatabaseSettingsPanelSheet({
3480
3536
  onPanelChange,
3481
3537
  onAttachBuilderSource,
3482
3538
  onFederateSource,
3539
+ onChangeSourceRole,
3483
3540
  onDisconnectSecondary,
3484
3541
  onRefreshSource,
3485
3542
  onDisconnectSource,
@@ -3508,14 +3565,22 @@ function DatabaseSettingsPanelSheet({
3508
3565
  groupIds: string[];
3509
3566
  onClose: () => void;
3510
3567
  onPanelChange: (panel: DatabaseSettingsPanel) => void;
3511
- onAttachBuilderSource: (model: BuilderCmsModelSummary) => void;
3568
+ onAttachBuilderSource: (
3569
+ model: BuilderCmsModelSummary,
3570
+ relationshipMode?: "items" | "details",
3571
+ ) => Promise<ContentDatabaseResponse>;
3512
3572
  onFederateSource: (
3513
3573
  candidate: PendingSourceCandidate,
3514
3574
  join: ContentDatabaseSourceJoinRequest,
3515
- ) => void;
3575
+ ) => Promise<ContentDatabaseResponse>;
3576
+ onChangeSourceRole: (
3577
+ sourceId: string,
3578
+ relationshipMode: "items" | "details",
3579
+ join?: ContentDatabaseSourceJoinRequest,
3580
+ ) => Promise<ContentDatabaseResponse>;
3516
3581
  onDisconnectSecondary: (sourceId: string) => void;
3517
- onRefreshSource: () => void;
3518
- onDisconnectSource: () => void;
3582
+ onRefreshSource: (sourceId?: string) => void;
3583
+ onDisconnectSource: (sourceId?: string) => void;
3519
3584
  onReviewBuilderUpdate: () => void;
3520
3585
  onSetBuilderLiveWrites: (enabled: boolean) => void;
3521
3586
  sourceActionPending: boolean;
@@ -3603,11 +3668,10 @@ function DatabaseSettingsPanelSheet({
3603
3668
  canEdit={canEdit}
3604
3669
  nav={sourceNavStack}
3605
3670
  onNavPush={(step) => setSourceNavStack((stack) => [...stack, step])}
3671
+ onNavReplace={setSourceNavStack}
3606
3672
  onAttachBuilderSource={onAttachBuilderSource}
3607
- onFederateSource={(candidate, join) => {
3608
- onFederateSource(candidate, join);
3609
- setSourceNavStack([]);
3610
- }}
3673
+ onFederateSource={onFederateSource}
3674
+ onChangeSourceRole={onChangeSourceRole}
3611
3675
  onDisconnectSecondary={(sourceId) => {
3612
3676
  onDisconnectSecondary(sourceId);
3613
3677
  setSourceNavStack([]);
@@ -3824,6 +3888,7 @@ export function buildClientBuilderReviewPayload(
3824
3888
  riskLevel: changeSet.riskLevel,
3825
3889
  riskReasons: changeSet.riskReasons,
3826
3890
  conflictState: changeSet.conflictState,
3891
+ effect: resolveBuilderCmsWriteEffect({ source, changeSet }),
3827
3892
  execution: latestExecution,
3828
3893
  };
3829
3894
  });
@@ -3905,8 +3970,10 @@ function DatabaseSettingsSourcePanel({
3905
3970
  canEdit,
3906
3971
  nav,
3907
3972
  onNavPush,
3973
+ onNavReplace,
3908
3974
  onAttachBuilderSource,
3909
3975
  onFederateSource,
3976
+ onChangeSourceRole,
3910
3977
  onDisconnectSecondary,
3911
3978
  onRefreshSource,
3912
3979
  onDisconnectSource,
@@ -3921,14 +3988,23 @@ function DatabaseSettingsSourcePanel({
3921
3988
  canEdit: boolean;
3922
3989
  nav: SourceNavStep[];
3923
3990
  onNavPush: (step: SourceNavStep) => void;
3924
- onAttachBuilderSource: (model: BuilderCmsModelSummary) => void;
3991
+ onNavReplace: (stack: SourceNavStep[]) => void;
3992
+ onAttachBuilderSource: (
3993
+ model: BuilderCmsModelSummary,
3994
+ relationshipMode?: "items" | "details",
3995
+ ) => Promise<ContentDatabaseResponse>;
3925
3996
  onFederateSource: (
3926
3997
  candidate: PendingSourceCandidate,
3927
3998
  join: ContentDatabaseSourceJoinRequest,
3928
- ) => void;
3999
+ ) => Promise<ContentDatabaseResponse>;
4000
+ onChangeSourceRole: (
4001
+ sourceId: string,
4002
+ relationshipMode: "items" | "details",
4003
+ join?: ContentDatabaseSourceJoinRequest,
4004
+ ) => Promise<ContentDatabaseResponse>;
3929
4005
  onDisconnectSecondary: (sourceId: string) => void;
3930
- onRefreshSource: () => void;
3931
- onDisconnectSource: () => void;
4006
+ onRefreshSource: (sourceId?: string) => void;
4007
+ onDisconnectSource: (sourceId?: string) => void;
3932
4008
  onReviewBuilderUpdate: () => void;
3933
4009
  onSetBuilderLiveWrites: (enabled: boolean) => void;
3934
4010
  sourceActionPending: boolean;
@@ -4053,6 +4129,34 @@ function DatabaseSettingsSourcePanel({
4053
4129
  source={secondary}
4054
4130
  canEdit={canEdit}
4055
4131
  pending={sourceActionPending}
4132
+ onAddDetails={() =>
4133
+ secondary
4134
+ ? onNavPush({
4135
+ kind: "keyConfirm",
4136
+ candidate: {
4137
+ sourceType: secondary.sourceType,
4138
+ sourceName: secondary.sourceName,
4139
+ sourceTable: secondary.sourceTable,
4140
+ displayName: secondary.sourceName,
4141
+ existingSourceId: secondary.id,
4142
+ },
4143
+ })
4144
+ : undefined
4145
+ }
4146
+ onAddItems={async () => {
4147
+ if (!secondary) return;
4148
+ await onChangeSourceRole(secondary.id, "items");
4149
+ onNavReplace([]);
4150
+ }}
4151
+ onChooseFields={() =>
4152
+ secondary
4153
+ ? onNavPush({
4154
+ kind: "fieldPicker",
4155
+ sourceId: secondary.id,
4156
+ sourceName: secondary.sourceName,
4157
+ })
4158
+ : undefined
4159
+ }
4056
4160
  onDisconnect={() => onDisconnectSecondary(top.sourceId)}
4057
4161
  />
4058
4162
  );
@@ -4066,7 +4170,41 @@ function DatabaseSettingsSourcePanel({
4066
4170
  candidate={top.candidate}
4067
4171
  canEdit={canEdit}
4068
4172
  pending={sourceActionPending}
4069
- onCommit={(join) => onFederateSource(top.candidate, join)}
4173
+ onCommit={async (join) => {
4174
+ const result = top.candidate.existingSourceId
4175
+ ? await onChangeSourceRole(
4176
+ top.candidate.existingSourceId,
4177
+ "details",
4178
+ join,
4179
+ )
4180
+ : await onFederateSource(top.candidate, join);
4181
+ const detailsSource = findDetailsSource(result, top.candidate);
4182
+ onNavReplace(
4183
+ detailsSource
4184
+ ? [
4185
+ {
4186
+ kind: "fieldPicker",
4187
+ sourceId: detailsSource.id,
4188
+ sourceName: detailsSource.sourceName,
4189
+ },
4190
+ ]
4191
+ : [],
4192
+ );
4193
+ }}
4194
+ />
4195
+ );
4196
+ }
4197
+
4198
+ if (top.kind === "fieldPicker") {
4199
+ const pickerSource =
4200
+ sources.find((item) => item.id === top.sourceId) ?? null;
4201
+ return (
4202
+ <SourceDetailsFieldPicker
4203
+ documentId={documentId}
4204
+ source={pickerSource}
4205
+ canEdit={canEdit}
4206
+ pending={sourceActionPending}
4207
+ onDone={() => onNavReplace([])}
4070
4208
  />
4071
4209
  );
4072
4210
  }
@@ -4165,21 +4303,53 @@ function DatabaseSettingsSourcePanel({
4165
4303
  </span>
4166
4304
  </div>
4167
4305
  </div>
4168
- <div>
4169
- <Button
4170
- type="button"
4171
- size="sm"
4172
- disabled={!canEdit || sourceActionPending}
4173
- onClick={() => onAttachBuilderSource(model)}
4174
- >
4175
- {sourceActionPending ? (
4176
- <Spinner className="mr-1.5 size-3.5" />
4177
- ) : (
4178
- <IconPlugConnected className="mr-1.5 size-3.5" />
4179
- )}
4180
- Attach
4181
- </Button>
4182
- </div>
4306
+ {sources.length > 0 || source ? (
4307
+ <SourceRelationshipChoice
4308
+ documentId={documentId}
4309
+ candidate={{
4310
+ sourceType: "builder-cms",
4311
+ sourceName: model.displayName,
4312
+ sourceTable: model.name,
4313
+ displayName: model.displayName,
4314
+ }}
4315
+ canEdit={canEdit}
4316
+ pending={sourceActionPending}
4317
+ onAddDetails={() =>
4318
+ onNavPush({
4319
+ kind: "keyConfirm",
4320
+ candidate: {
4321
+ sourceType: "builder-cms",
4322
+ sourceName: model.displayName,
4323
+ sourceTable: model.name,
4324
+ displayName: model.displayName,
4325
+ },
4326
+ })
4327
+ }
4328
+ onAddItems={async () => {
4329
+ await onAttachBuilderSource(model, "items");
4330
+ onNavReplace([]);
4331
+ }}
4332
+ />
4333
+ ) : (
4334
+ <div>
4335
+ <Button
4336
+ type="button"
4337
+ size="sm"
4338
+ disabled={!canEdit || sourceActionPending}
4339
+ onClick={async () => {
4340
+ await onAttachBuilderSource(model);
4341
+ onNavReplace([]);
4342
+ }}
4343
+ >
4344
+ {sourceActionPending ? (
4345
+ <Spinner className="mr-1.5 size-3.5" />
4346
+ ) : (
4347
+ <IconPlugConnected className="mr-1.5 size-3.5" />
4348
+ )}
4349
+ Attach
4350
+ </Button>
4351
+ </div>
4352
+ )}
4183
4353
  </div>
4184
4354
  );
4185
4355
  }
@@ -4217,7 +4387,7 @@ function DatabaseSettingsSourcePanel({
4217
4387
  type="button"
4218
4388
  className="inline-flex items-center gap-1 text-destructive hover:underline disabled:opacity-60"
4219
4389
  disabled={!canEdit || sourceActionPending}
4220
- onClick={onRefreshSource}
4390
+ onClick={() => onRefreshSource(source.id)}
4221
4391
  >
4222
4392
  <IconRefresh className="size-3" />
4223
4393
  {dbText("couldntSyncRetry")}
@@ -4307,6 +4477,38 @@ function DatabaseSettingsSourcePanel({
4307
4477
  </>
4308
4478
  ) : null}
4309
4479
 
4480
+ <SourceRoleCard
4481
+ source={source}
4482
+ canAddDetails={sources.some(
4483
+ (item) => item.id !== source.id && !sourceAddsDetails(item),
4484
+ )}
4485
+ canEdit={canEdit}
4486
+ pending={sourceActionPending}
4487
+ onAddDetails={() =>
4488
+ onNavPush({
4489
+ kind: "keyConfirm",
4490
+ candidate: {
4491
+ sourceType: source.sourceType,
4492
+ sourceName: source.sourceName,
4493
+ sourceTable: source.sourceTable,
4494
+ displayName: source.sourceName,
4495
+ existingSourceId: source.id,
4496
+ },
4497
+ })
4498
+ }
4499
+ onAddItems={async () => {
4500
+ await onChangeSourceRole(source.id, "items");
4501
+ onNavReplace([]);
4502
+ }}
4503
+ onChooseFields={() =>
4504
+ onNavPush({
4505
+ kind: "fieldPicker",
4506
+ sourceId: source.id,
4507
+ sourceName: source.sourceName,
4508
+ })
4509
+ }
4510
+ />
4511
+
4310
4512
  <div className="rounded-lg border border-border bg-background p-3">
4311
4513
  <div className="text-xs font-medium">
4312
4514
  {dbText("disconnectSource")}
@@ -4320,7 +4522,7 @@ function DatabaseSettingsSourcePanel({
4320
4522
  variant="outline"
4321
4523
  className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
4322
4524
  disabled={!canEdit || sourceActionPending}
4323
- onClick={onDisconnectSource}
4525
+ onClick={() => onDisconnectSource(source.id)}
4324
4526
  >
4325
4527
  {sourceActionPending ? (
4326
4528
  <Spinner className="mr-1 size-3.5" />
@@ -4381,13 +4583,7 @@ function SourcesListView({
4381
4583
  )
4382
4584
  }
4383
4585
  label={connected.sourceName}
4384
- value={
4385
- connected.metadata.federation?.role === "secondary"
4386
- ? "Federated"
4387
- : index === 0
4388
- ? "Primary"
4389
- : undefined
4390
- }
4586
+ value={sourceRoleLabel(connected, index)}
4391
4587
  onClick={
4392
4588
  connected.metadata.federation?.role === "secondary"
4393
4589
  ? () => onOpenSecondary(connected)
@@ -4461,7 +4657,7 @@ function CanonicalKeyConfirmView({
4461
4657
  candidate: PendingSourceCandidate;
4462
4658
  canEdit: boolean;
4463
4659
  pending: boolean;
4464
- onCommit: (join: ContentDatabaseSourceJoinRequest) => void;
4660
+ onCommit: (join: ContentDatabaseSourceJoinRequest) => void | Promise<void>;
4465
4661
  }) {
4466
4662
  const suggestionQuery = useSuggestSourceJoinKey({
4467
4663
  documentId,
@@ -4519,7 +4715,7 @@ function CanonicalKeyConfirmView({
4519
4715
  return (
4520
4716
  <div className="flex items-center gap-2 text-xs text-muted-foreground">
4521
4717
  <Spinner className="size-3.5" />
4522
- {dbText("analyzingBothSourcesForASharedKey")}
4718
+ {dbText("checkingHowTheseRecordsMatch")}
4523
4719
  </div>
4524
4720
  );
4525
4721
  }
@@ -4528,7 +4724,7 @@ function CanonicalKeyConfirmView({
4528
4724
  return (
4529
4725
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4530
4726
  {suggestionQuery.data?.message ??
4531
- "Couldnt suggest a join key automatically."}
4727
+ "Couldn't find a match field automatically."}
4532
4728
  </div>
4533
4729
  );
4534
4730
  }
@@ -4540,7 +4736,7 @@ function CanonicalKeyConfirmView({
4540
4736
  {candidate.displayName}
4541
4737
  </div>
4542
4738
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4543
- Match rows on a shared key. Suggested key:{" "}
4739
+ Match existing items to details. Suggested match:{" "}
4544
4740
  <span className="font-medium text-foreground">
4545
4741
  {suggestion.canonicalKey.label}
4546
4742
  </span>
@@ -4630,7 +4826,7 @@ function CanonicalKeyConfirmView({
4630
4826
  ) : (
4631
4827
  <IconPlugConnected className="mr-1.5 size-3.5" />
4632
4828
  )}
4633
- Confirm &amp; attach
4829
+ Add details
4634
4830
  </Button>
4635
4831
  </div>
4636
4832
  );
@@ -4705,11 +4901,17 @@ function SecondarySourceLeaf({
4705
4901
  source,
4706
4902
  canEdit,
4707
4903
  pending,
4904
+ onAddDetails,
4905
+ onAddItems,
4906
+ onChooseFields,
4708
4907
  onDisconnect,
4709
4908
  }: {
4710
4909
  source: ContentDatabaseSource | null;
4711
4910
  canEdit: boolean;
4712
4911
  pending: boolean;
4912
+ onAddDetails: () => void;
4913
+ onAddItems: () => void | Promise<void>;
4914
+ onChooseFields: () => void;
4713
4915
  onDisconnect: () => void;
4714
4916
  }) {
4715
4917
  if (!source) {
@@ -4734,12 +4936,22 @@ function SecondarySourceLeaf({
4734
4936
  </span>
4735
4937
  </div>
4736
4938
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4737
- {`Federated · ${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
4939
+ {`${sourceRoleLabel(source, 0)} · ${fieldCount} field${
4940
+ fieldCount === 1 ? "" : "s"
4941
+ }`}
4738
4942
  {federation?.canonicalKey?.label
4739
4943
  ? ` · joined on ${federation.canonicalKey.label}`
4740
4944
  : ""}
4741
4945
  </div>
4742
4946
  </div>
4947
+ <SourceRoleCard
4948
+ source={source}
4949
+ canEdit={canEdit}
4950
+ pending={pending}
4951
+ onAddDetails={onAddDetails}
4952
+ onAddItems={onAddItems}
4953
+ onChooseFields={onChooseFields}
4954
+ />
4743
4955
  {federation ? (
4744
4956
  <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3 text-xs">
4745
4957
  <div className="font-medium">{dbText("matchFormula")}</div>
@@ -4774,6 +4986,375 @@ function SecondarySourceLeaf({
4774
4986
  );
4775
4987
  }
4776
4988
 
4989
+ function sourceAddsDetails(source: ContentDatabaseSource | null | undefined) {
4990
+ return source?.metadata.federation?.role === "secondary";
4991
+ }
4992
+
4993
+ function sourceRoleLabel(
4994
+ source: ContentDatabaseSource | null | undefined,
4995
+ _index: number,
4996
+ ) {
4997
+ return sourceAddsDetails(source)
4998
+ ? dbText("addingDetails")
4999
+ : dbText("addingItems");
5000
+ }
5001
+
5002
+ function detailsReasonText(suggestion: SourceJoinSuggestion | null) {
5003
+ if (!suggestion)
5004
+ return dbText("recommendedWhenCollectionDescribesExistingRows");
5005
+ const percent = Math.round(suggestion.confidence * 100);
5006
+ return dbText("recommendedBecauseSampledRowsMatchOn", {
5007
+ field: suggestion.canonicalKey.label,
5008
+ percent,
5009
+ });
5010
+ }
5011
+
5012
+ function findDetailsSource(
5013
+ response: ContentDatabaseResponse,
5014
+ candidate: PendingSourceCandidate,
5015
+ ) {
5016
+ return (
5017
+ (response.sources ?? []).find((source) => {
5018
+ if (!sourceAddsDetails(source)) return false;
5019
+ if (candidate.existingSourceId) {
5020
+ return source.id === candidate.existingSourceId;
5021
+ }
5022
+ return (
5023
+ source.sourceType === candidate.sourceType &&
5024
+ source.sourceTable === candidate.sourceTable
5025
+ );
5026
+ }) ?? null
5027
+ );
5028
+ }
5029
+
5030
+ function SourceRelationshipChoice({
5031
+ documentId,
5032
+ candidate,
5033
+ canEdit,
5034
+ pending,
5035
+ onAddDetails,
5036
+ onAddItems,
5037
+ }: {
5038
+ documentId: string;
5039
+ candidate: PendingSourceCandidate;
5040
+ canEdit: boolean;
5041
+ pending: boolean;
5042
+ onAddDetails: () => void;
5043
+ onAddItems: () => void | Promise<void>;
5044
+ }) {
5045
+ const suggestionQuery = useSuggestSourceJoinKey({
5046
+ documentId,
5047
+ candidateSourceType: candidate.sourceType,
5048
+ candidateSourceTable: candidate.sourceTable,
5049
+ enabled: true,
5050
+ });
5051
+ const suggestion = suggestionQuery.data?.suggestion ?? null;
5052
+ const detailsRecommended = Boolean(suggestion);
5053
+ return (
5054
+ <div className="grid min-w-0 gap-2">
5055
+ <button
5056
+ type="button"
5057
+ disabled={!canEdit || pending}
5058
+ className={cn(
5059
+ "grid min-w-0 gap-1 rounded-lg border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
5060
+ detailsRecommended
5061
+ ? "border-foreground/30 bg-muted/30"
5062
+ : "border-border bg-background hover:bg-muted/50",
5063
+ )}
5064
+ onClick={onAddDetails}
5065
+ >
5066
+ <span className="grid min-w-0 gap-1">
5067
+ <span className="break-words text-sm font-medium leading-snug">
5068
+ {dbText("addDetailsToExistingItems")}
5069
+ </span>
5070
+ {detailsRecommended ? (
5071
+ <span className="w-fit rounded-full bg-foreground px-2 py-0.5 text-[10px] font-medium text-background">
5072
+ {dbText("recommended")}
5073
+ </span>
5074
+ ) : null}
5075
+ </span>
5076
+ <span className="break-words text-xs text-muted-foreground">
5077
+ {suggestionQuery.isLoading
5078
+ ? dbText("checkingForMatchingFields")
5079
+ : detailsReasonText(suggestion)}
5080
+ </span>
5081
+ </button>
5082
+ <button
5083
+ type="button"
5084
+ disabled={!canEdit || pending}
5085
+ className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60"
5086
+ onClick={onAddItems}
5087
+ >
5088
+ <span className="truncate text-sm font-medium">
5089
+ {dbText("addMoreItemsToThisList")}
5090
+ </span>
5091
+ <span className="break-words text-xs text-muted-foreground">
5092
+ {dbText("bestWhenSameKindAdditionalRows")}
5093
+ </span>
5094
+ </button>
5095
+ </div>
5096
+ );
5097
+ }
5098
+
5099
+ function SourceRoleCard({
5100
+ source,
5101
+ canAddDetails = true,
5102
+ canEdit,
5103
+ pending,
5104
+ onAddDetails,
5105
+ onAddItems,
5106
+ onChooseFields,
5107
+ }: {
5108
+ source: ContentDatabaseSource;
5109
+ canAddDetails?: boolean;
5110
+ canEdit: boolean;
5111
+ pending: boolean;
5112
+ onAddDetails: () => void;
5113
+ onAddItems: () => void | Promise<void>;
5114
+ onChooseFields: () => void;
5115
+ }) {
5116
+ const addingDetails = sourceAddsDetails(source);
5117
+ return (
5118
+ <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3">
5119
+ <div className="grid min-w-0 gap-0.5">
5120
+ <div className="text-xs font-medium">{dbText("sourceRole")}</div>
5121
+ <div className="break-words text-xs text-muted-foreground">
5122
+ {addingDetails
5123
+ ? dbText("addingDetailsMatchedOn", {
5124
+ field:
5125
+ source.metadata.federation?.canonicalKey?.label ??
5126
+ dbText("aField"),
5127
+ })
5128
+ : dbText("addingItemsAsRows")}
5129
+ </div>
5130
+ </div>
5131
+ <div className="flex min-w-0 flex-wrap gap-2">
5132
+ {addingDetails ? (
5133
+ <>
5134
+ <Button
5135
+ type="button"
5136
+ size="sm"
5137
+ variant="outline"
5138
+ className="h-8 text-xs"
5139
+ disabled={!canEdit || pending}
5140
+ onClick={onChooseFields}
5141
+ >
5142
+ {dbText("chooseFields")}
5143
+ </Button>
5144
+ <Button
5145
+ type="button"
5146
+ size="sm"
5147
+ variant="outline"
5148
+ className="h-8 text-xs"
5149
+ disabled={!canEdit || pending}
5150
+ onClick={onAddItems}
5151
+ >
5152
+ {dbText("addAsItems")}
5153
+ </Button>
5154
+ </>
5155
+ ) : canAddDetails ? (
5156
+ <Button
5157
+ type="button"
5158
+ size="sm"
5159
+ variant="outline"
5160
+ className="h-8 text-xs"
5161
+ disabled={!canEdit || pending}
5162
+ onClick={onAddDetails}
5163
+ >
5164
+ {dbText("addDetailsInstead")}
5165
+ </Button>
5166
+ ) : (
5167
+ <div className="break-words text-xs text-muted-foreground">
5168
+ {dbText("addAnotherItemSourceBeforeChangingToDetails")}
5169
+ </div>
5170
+ )}
5171
+ </div>
5172
+ </div>
5173
+ );
5174
+ }
5175
+
5176
+ const DETAIL_FIELD_NAME_HINTS = [
5177
+ "name",
5178
+ "title",
5179
+ "bio",
5180
+ "description",
5181
+ "image",
5182
+ "photo",
5183
+ "avatar",
5184
+ "url",
5185
+ "handle",
5186
+ "email",
5187
+ ];
5188
+
5189
+ function shouldPreselectDetailField(
5190
+ field: ContentDatabaseSource["fields"][number],
5191
+ ) {
5192
+ const key = `${field.sourceFieldKey} ${field.sourceFieldLabel}`.toLowerCase();
5193
+ if (
5194
+ field.propertyId ||
5195
+ field.mappingType === "system" ||
5196
+ field.mappingType === "title"
5197
+ ) {
5198
+ return false;
5199
+ }
5200
+ if (/(\b|\.|_)(id|created|updated|published|rev)(\b|\.|_)/i.test(key)) {
5201
+ return false;
5202
+ }
5203
+ return DETAIL_FIELD_NAME_HINTS.some((hint) => key.includes(hint));
5204
+ }
5205
+
5206
+ function SourceDetailsFieldPicker({
5207
+ documentId,
5208
+ source,
5209
+ canEdit,
5210
+ pending,
5211
+ onDone,
5212
+ }: {
5213
+ documentId: string;
5214
+ source: ContentDatabaseSource | null;
5215
+ canEdit: boolean;
5216
+ pending: boolean;
5217
+ onDone: () => void;
5218
+ }) {
5219
+ const addField = useAddContentDatabaseSourceFieldProperty(documentId);
5220
+ const fields = useMemo(
5221
+ () =>
5222
+ (source?.fields ?? []).filter(
5223
+ (field) =>
5224
+ !field.propertyId &&
5225
+ field.mappingType !== "system" &&
5226
+ field.mappingType !== "title",
5227
+ ),
5228
+ [source],
5229
+ );
5230
+ const defaultSelectedIds = useMemo(
5231
+ () =>
5232
+ fields
5233
+ .filter(shouldPreselectDetailField)
5234
+ .slice(0, 8)
5235
+ .map((field) => field.id),
5236
+ [fields],
5237
+ );
5238
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
5239
+
5240
+ useEffect(() => {
5241
+ setSelectedIds(defaultSelectedIds);
5242
+ }, [defaultSelectedIds]);
5243
+
5244
+ if (!source) {
5245
+ return (
5246
+ <div className="min-w-0 break-words text-xs text-muted-foreground">
5247
+ {dbText("thisSourceIsNoLongerConnected")}
5248
+ </div>
5249
+ );
5250
+ }
5251
+
5252
+ const selected = new Set(selectedIds);
5253
+ const toggleField = (fieldId: string) => {
5254
+ setSelectedIds((current) =>
5255
+ current.includes(fieldId)
5256
+ ? current.filter((id) => id !== fieldId)
5257
+ : [...current, fieldId],
5258
+ );
5259
+ };
5260
+ const addSelected = async () => {
5261
+ const sourceFieldIds = fields
5262
+ .filter((field) => selected.has(field.id))
5263
+ .map((field) => field.id);
5264
+ if (sourceFieldIds.length === 0) {
5265
+ onDone();
5266
+ return;
5267
+ }
5268
+ try {
5269
+ for (const sourceFieldId of sourceFieldIds) {
5270
+ await addField.mutateAsync({ documentId, sourceFieldId });
5271
+ }
5272
+ toast.success(dbText("detailFieldsAdded"), {
5273
+ description:
5274
+ sourceFieldIds.length === 1
5275
+ ? dbText("addedOneFieldFromSource")
5276
+ : dbText("addedFieldsFromSource", { count: sourceFieldIds.length }),
5277
+ });
5278
+ onDone();
5279
+ } catch (error) {
5280
+ toast.error(dbText("fieldsWereNotAdded"), {
5281
+ description:
5282
+ error instanceof Error ? error.message : dbText("tryAgain"),
5283
+ });
5284
+ }
5285
+ };
5286
+
5287
+ return (
5288
+ <div className="grid min-w-0 gap-3">
5289
+ <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3">
5290
+ <div className="truncate text-sm font-medium" title={source.sourceName}>
5291
+ {source.sourceName}
5292
+ </div>
5293
+ <div className="break-words text-xs text-muted-foreground">
5294
+ {dbText("pickDetailsBecomeColumns")}
5295
+ </div>
5296
+ </div>
5297
+ {fields.length === 0 ? (
5298
+ <div className="break-words text-xs text-muted-foreground">
5299
+ {dbText("allAvailableDetailFieldsAlreadyVisible")}
5300
+ </div>
5301
+ ) : (
5302
+ <div className="grid min-w-0 gap-1">
5303
+ {fields.map((field) => (
5304
+ <button
5305
+ key={field.id}
5306
+ type="button"
5307
+ className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
5308
+ onClick={() => toggleField(field.id)}
5309
+ >
5310
+ <span
5311
+ className={cn(
5312
+ "flex size-4 shrink-0 items-center justify-center rounded border",
5313
+ selected.has(field.id)
5314
+ ? "border-[#2383e2] bg-[#2383e2] text-white"
5315
+ : "border-muted-foreground/40 text-transparent",
5316
+ )}
5317
+ >
5318
+ <IconCheck className="size-3" />
5319
+ </span>
5320
+ <span className="min-w-0 flex-1 truncate">
5321
+ {field.sourceFieldLabel}
5322
+ </span>
5323
+ <span className="shrink-0 text-[11px] text-muted-foreground">
5324
+ {field.sourceFieldType}
5325
+ </span>
5326
+ </button>
5327
+ ))}
5328
+ </div>
5329
+ )}
5330
+ <div className="flex justify-end gap-2">
5331
+ <Button
5332
+ type="button"
5333
+ size="sm"
5334
+ variant="outline"
5335
+ disabled={addField.isPending}
5336
+ onClick={onDone}
5337
+ >
5338
+ Done
5339
+ </Button>
5340
+ <Button
5341
+ type="button"
5342
+ size="sm"
5343
+ disabled={!canEdit || pending || addField.isPending}
5344
+ onClick={addSelected}
5345
+ >
5346
+ {addField.isPending ? (
5347
+ <Spinner className="mr-1.5 size-3.5" />
5348
+ ) : (
5349
+ <IconPlus className="mr-1.5 size-3.5" />
5350
+ )}
5351
+ Add selected
5352
+ </Button>
5353
+ </div>
5354
+ </div>
5355
+ );
5356
+ }
5357
+
4777
5358
  // A Builder space's data models, as drill-in rows. The attached model (if any)
4778
5359
  // is marked; selecting a row opens that model's leaf.
4779
5360
  function BuilderSpaceModelsView({