@agent-native/core 0.80.5 → 0.80.6

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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +7 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/cli/create.ts +1 -0
  5. package/corpus/core/src/sharing/registry.ts +17 -0
  6. package/corpus/core/src/templates/default/pnpm-workspace.yaml +1 -0
  7. package/corpus/core/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
  8. package/corpus/templates/content/AGENTS.md +45 -44
  9. package/corpus/templates/content/README.md +16 -0
  10. package/corpus/templates/content/actions/_builder-cms-read-client.ts +125 -0
  11. package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +28 -6
  12. package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +238 -69
  13. package/corpus/templates/content/actions/_builder-cms-write-settings.ts +125 -43
  14. package/corpus/templates/content/actions/_database-source-utils.ts +746 -97
  15. package/corpus/templates/content/actions/_database-utils.ts +17 -14
  16. package/corpus/templates/content/actions/_federation-join.ts +14 -2
  17. package/corpus/templates/content/actions/_join-suggestion.ts +89 -14
  18. package/corpus/templates/content/actions/_local-file-documents.ts +2 -1
  19. package/corpus/templates/content/actions/attach-content-database-source.ts +124 -5
  20. package/corpus/templates/content/actions/bind-content-database-source-field.ts +256 -0
  21. package/corpus/templates/content/actions/change-content-database-source-role.ts +420 -0
  22. package/corpus/templates/content/actions/create-document.ts +8 -0
  23. package/corpus/templates/content/actions/execute-builder-source-batch.ts +282 -0
  24. package/corpus/templates/content/actions/execute-builder-source-execution.ts +220 -8
  25. package/corpus/templates/content/actions/get-content-database.ts +10 -2
  26. package/corpus/templates/content/actions/prepare-builder-source-execution.ts +19 -2
  27. package/corpus/templates/content/actions/prepare-builder-source-review.ts +47 -15
  28. package/corpus/templates/content/actions/refresh-content-database-source.ts +11 -4
  29. package/corpus/templates/content/actions/review-content-database-source-change-set.ts +6 -2
  30. package/corpus/templates/content/actions/set-content-database-source-write-mode.ts +32 -12
  31. package/corpus/templates/content/actions/stage-builder-revision.ts +14 -10
  32. package/corpus/templates/content/actions/validate-builder-source-execution.ts +25 -3
  33. package/corpus/templates/content/app/components/editor/DocumentDatabase.tsx +1520 -553
  34. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +150 -1
  35. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +647 -66
  36. package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +543 -204
  37. package/corpus/templates/content/app/hooks/use-content-database.ts +62 -0
  38. package/corpus/templates/content/app/i18n/zh-TW.ts +64 -0
  39. package/corpus/templates/content/app/i18n-data.ts +784 -0
  40. package/corpus/templates/content/changelog/2026-06-29-builder-review-checks-now-preserve-publish-and-unpublish-cho.md +6 -0
  41. package/corpus/templates/content/changelog/2026-06-29-database-sources-can-now-be-added-as-more-rows-or-matched.md +6 -0
  42. package/corpus/templates/content/changelog/2026-06-29-fixed-loading-the-final-rows-in-larger-databases-without-bre.md +6 -0
  43. package/corpus/templates/content/shared/api.ts +91 -1
  44. package/corpus/templates/content/vite.config.ts +14 -7
  45. package/dist/cli/create.d.ts.map +1 -1
  46. package/dist/cli/create.js +1 -0
  47. package/dist/cli/create.js.map +1 -1
  48. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  49. package/dist/notifications/routes.d.ts +1 -1
  50. package/dist/observability/routes.d.ts +5 -5
  51. package/dist/sharing/registry.d.ts.map +1 -1
  52. package/dist/sharing/registry.js +11 -0
  53. package/dist/sharing/registry.js.map +1 -1
  54. package/dist/templates/default/pnpm-workspace.yaml +1 -0
  55. package/dist/templates/workspace-root/pnpm-workspace.yaml +1 -0
  56. package/package.json +1 -1
  57. package/src/templates/default/pnpm-workspace.yaml +1 -0
  58. package/src/templates/workspace-root/pnpm-workspace.yaml +1 -0
@@ -9,12 +9,15 @@ import {
9
9
  import {
10
10
  BUILDER_CMS_SAFE_WRITE_MODEL,
11
11
  type BuilderCmsModelSummary,
12
+ type BuilderCmsWriteEffect,
12
13
  type ContentDatabaseItem,
13
14
  type ContentDatabaseResponse,
14
15
  type ContentDatabaseSource,
15
16
  type ContentDatabaseSourceChangeSet,
16
17
  type ContentDatabaseSourceJoinRequest,
17
18
  type ContentDatabaseSourceReviewPayload,
19
+ type ContentDatabaseSourceWriteMode,
20
+ type ExecuteBuilderSourceBatchResponse,
18
21
  type SourceJoinSuggestion,
19
22
  type ContentDatabaseView,
20
23
  type ContentDatabaseViewConfig,
@@ -134,13 +137,15 @@ import {
134
137
  import { Spinner } from "@/components/ui/spinner";
135
138
  import {
136
139
  useAddDatabaseItem,
140
+ useAddContentDatabaseSourceFieldProperty,
137
141
  useAttachContentDatabaseSource,
138
142
  useBuilderCmsModels,
143
+ useChangeContentDatabaseSourceRole,
139
144
  useContentDatabase,
140
145
  useContentDatabases,
141
146
  useDisconnectContentDatabaseSource,
142
147
  useDuplicateDatabaseItem,
143
- useExecuteBuilderSourceExecution,
148
+ useExecuteBuilderSourceBatch,
144
149
  useMoveDatabaseItem,
145
150
  usePrepareBuilderSourceReview,
146
151
  useRefreshContentDatabaseSource,
@@ -159,7 +164,9 @@ import {
159
164
  } from "@/hooks/use-documents";
160
165
  import { cn } from "@/lib/utils";
161
166
 
167
+ import { resolveBuilderCmsWriteEffect } from "../../../actions/_builder-cms-write-adapter.js";
162
168
  import { BuilderSourceReviewDialog } from "./database-sources/BuilderSourceReviewDialog";
169
+ import { type BuilderReviewPublicationTransitions } from "./database-sources/BuilderSourceReviewDialog";
163
170
  import { DocumentBlockFields } from "./DocumentBlockFields";
164
171
  import {
165
172
  AddProperty,
@@ -188,12 +195,18 @@ import {
188
195
  } from "./previewDocumentSaveRegistry";
189
196
  import { VisualEditor } from "./VisualEditor";
190
197
 
198
+ type BuilderSourceWriteSettingsInput = {
199
+ writeMode: ContentDatabaseSourceWriteMode;
200
+ allowPublicationTransitions?: boolean;
201
+ };
202
+
191
203
  interface DocumentDatabaseProps {
192
204
  document: Document;
193
205
  canEdit: boolean;
194
206
  }
195
207
 
196
208
  const CONTENT_DATABASE_PAGE_SIZE = 100;
209
+ const CONTENT_DATABASE_MAX_ITEM_LIMIT = 5_000;
197
210
 
198
211
  export type SortDirection = ContentDatabaseSortDirection;
199
212
  export type DatabaseSort = ContentDatabaseSort;
@@ -469,10 +482,11 @@ function DatabaseTable({
469
482
  const database = useContentDatabase(document.id, databaseItemLimit);
470
483
  const addItem = useAddDatabaseItem(document.id);
471
484
  const attachSource = useAttachContentDatabaseSource(document.id);
485
+ const changeSourceRole = useChangeContentDatabaseSourceRole(document.id);
472
486
  const refreshSource = useRefreshContentDatabaseSource(document.id);
473
487
  const disconnectSource = useDisconnectContentDatabaseSource(document.id);
474
488
  const prepareBuilderReview = usePrepareBuilderSourceReview(document.id);
475
- const executeBuilderExecution = useExecuteBuilderSourceExecution(document.id);
489
+ const executeBuilderBatch = useExecuteBuilderSourceBatch(document.id);
476
490
  const setSourceWriteMode = useSetContentDatabaseSourceWriteMode(document.id);
477
491
  const setProperty = useSetDocumentProperty(document.id);
478
492
  const updateView = useUpdateContentDatabaseView(document.id);
@@ -480,10 +494,43 @@ function DatabaseTable({
480
494
  const properties = data?.properties ?? [];
481
495
  const items = data?.items ?? [];
482
496
  const totalItemCount = data?.pagination?.totalItems ?? items.length;
483
- const hasMoreItems = data?.pagination?.hasMore === true;
497
+ const hasMoreItems =
498
+ data?.pagination?.hasMore === true &&
499
+ databaseItemLimit < CONTENT_DATABASE_MAX_ITEM_LIMIT;
500
+ const isLoadingMoreItems =
501
+ database.isFetching && data?.pagination?.limit !== databaseItemLimit;
484
502
  const databaseId = data?.database.id ?? null;
485
503
  const source = data?.source ?? null;
486
504
  const sources = data?.sources ?? (source ? [source] : []);
505
+ // New-row source picker (row-union): when a database has 2+ sources, the auto
506
+ // -created "Source" select tags which collection a row belongs to. Surface a
507
+ // picker on "New" so a row is created already tagged for its collection (the
508
+ // backend then routes its create_draft to that source). Mirrors the server's
509
+ // SOURCE_PROPERTY_NAME ("Source", a select).
510
+ const sourceTagPicker = useMemo(() => {
511
+ if (sources.length < 2) return null;
512
+ const property = properties.find(
513
+ (item) =>
514
+ item.definition.name === "Source" && item.definition.type === "select",
515
+ );
516
+ if (!property) return null;
517
+ // Each Source option's id IS the source id (and "local" is the Local
518
+ // sentinel), so a collection always resolves to a valid tag — never a
519
+ // missing option that would silently create an untagged row.
520
+ const optionIds = new Set(
521
+ (property.definition.options.options ?? []).map((option) => option.id),
522
+ );
523
+ const collections = sources
524
+ .filter(
525
+ (item) => item.sourceType === "builder-cms" && optionIds.has(item.id),
526
+ )
527
+ .map((item) => ({ label: item.sourceName, optionId: item.id }));
528
+ return {
529
+ propertyId: property.definition.id,
530
+ collections,
531
+ localOptionId: optionIds.has("local") ? "local" : null,
532
+ };
533
+ }, [properties, sources]);
487
534
  const [previewDocumentId, setPreviewDocumentId] = useState<string | null>(
488
535
  null,
489
536
  );
@@ -499,9 +546,17 @@ function DatabaseTable({
499
546
  const [builderReviewOpen, setBuilderReviewOpen] = useState(false);
500
547
  const [builderReviewResult, setBuilderReviewResult] =
501
548
  useState<ContentDatabaseSourceReviewPayload | null>(null);
549
+ const [builderBatchResult, setBuilderBatchResult] =
550
+ useState<ExecuteBuilderSourceBatchResponse | null>(null);
502
551
  const [builderReviewCheckedAt, setBuilderReviewCheckedAt] = useState<
503
552
  string | null
504
553
  >(null);
554
+ // Which Builder source the review dialog / push targets. null ⇒ the primary
555
+ // source (single-source behavior). Set when opening a non-primary source's
556
+ // writable leaf so review/push/write-mode scope to that collection.
557
+ const [builderReviewSourceId, setBuilderReviewSourceId] = useState<
558
+ string | null
559
+ >(null);
505
560
  const [settingsPanel, setSettingsPanel] =
506
561
  useState<DatabaseSettingsPanel>("main");
507
562
  const [viewConfig, setViewConfig] = useState<ContentDatabaseViewConfig>(
@@ -619,16 +674,30 @@ function DatabaseTable({
619
674
  () => databaseSelectedItems(visibleItems, selectedItemIds),
620
675
  [visibleItems, selectedItemIds],
621
676
  );
677
+ // The review dialog operates on the source the user opened, which may be a
678
+ // non-primary row-union collection. Fall back to the primary when no
679
+ // explicit target is set (single-source behavior).
680
+ const activeReviewSource = useMemo(
681
+ () =>
682
+ builderReviewSourceId
683
+ ? (sources.find((item) => item.id === builderReviewSourceId) ?? source)
684
+ : source,
685
+ [builderReviewSourceId, sources, source],
686
+ );
622
687
  const builderReviewChangeSets = useMemo(
623
- () => builderReviewableChangeSets(source),
624
- [source],
688
+ () => builderReviewableChangeSets(activeReviewSource),
689
+ [activeReviewSource],
625
690
  );
626
691
  const builderReviewPreview = useMemo(
627
692
  () =>
628
- source?.sourceType === "builder-cms" && builderReviewChangeSets.length > 0
629
- ? buildClientBuilderReviewPayload(source, builderReviewChangeSets)
693
+ activeReviewSource?.sourceType === "builder-cms" &&
694
+ builderReviewChangeSets.length > 0
695
+ ? buildClientBuilderReviewPayload(
696
+ activeReviewSource,
697
+ builderReviewChangeSets,
698
+ )
630
699
  : null,
631
- [builderReviewChangeSets, source],
700
+ [builderReviewChangeSets, activeReviewSource],
632
701
  );
633
702
  const activeBuilderReview = builderReviewResult ?? builderReviewPreview;
634
703
 
@@ -937,56 +1006,59 @@ function DatabaseTable({
937
1006
  updateActiveView((view) => ({ ...view, hideEmptyGroups }));
938
1007
  }
939
1008
 
940
- async function handleBuilderReviewPush() {
1009
+ async function handleBuilderReviewPush(
1010
+ transitions: BuilderReviewPublicationTransitions = {},
1011
+ ) {
941
1012
  setBuilderReviewResult(null);
1013
+ setBuilderBatchResult(null);
942
1014
  setBuilderReviewCheckedAt(null);
1015
+ // Target the SAME source the dialog renders. activeReviewSource falls back
1016
+ // to the primary if the selected source was disconnected/refetched out, so
1017
+ // the backend never receives a stale id that the UI no longer reflects.
1018
+ const targetSourceId =
1019
+ activeReviewSource && activeReviewSource.id !== source?.id
1020
+ ? activeReviewSource.id
1021
+ : undefined;
943
1022
  try {
944
1023
  const prepared = await prepareBuilderReview.mutateAsync({
945
1024
  documentId: document.id,
946
- pushModeConfirmation: "autosave",
1025
+ sourceId: targetSourceId,
947
1026
  });
948
1027
  let nextReview = prepared.review;
1028
+ let batchResult: ExecuteBuilderSourceBatchResponse | null = null;
949
1029
 
950
1030
  if (
951
1031
  nextReview.liveWritesEnabled &&
952
1032
  nextReview.result.status === "validated"
953
1033
  ) {
954
- const executableRows = builderReviewExecutableRows(nextReview);
955
- let executedResponse: ContentDatabaseResponse | null = null;
956
- for (const row of executableRows) {
957
- if (!row.execution?.idempotencyKey) continue;
958
- executedResponse = await executeBuilderExecution.mutateAsync({
959
- documentId: document.id,
960
- changeSetId: row.changeSetId,
961
- idempotencyKey: row.execution.idempotencyKey,
962
- pushModeConfirmation: nextReview.pushMode,
963
- });
964
- }
965
- const executedSource = executedResponse?.source ?? null;
966
- if (executedSource) {
967
- const reviewedIds = new Set(
968
- nextReview.rows.map((row) => row.changeSetId),
969
- );
970
- const reviewedChangeSets = executedSource.changeSets.filter(
971
- (changeSet) => reviewedIds.has(changeSet.id),
972
- );
973
- if (reviewedChangeSets.length > 0) {
974
- nextReview = buildClientBuilderReviewPayload(
975
- executedSource,
976
- reviewedChangeSets,
977
- );
978
- }
979
- }
1034
+ const changeSetIds = nextReview.rows.map((row) => row.changeSetId);
1035
+ const scopedTransitions = Object.fromEntries(
1036
+ changeSetIds.flatMap((changeSetId) =>
1037
+ transitions[changeSetId]
1038
+ ? [[changeSetId, transitions[changeSetId]]]
1039
+ : [],
1040
+ ),
1041
+ );
1042
+ batchResult = await executeBuilderBatch.mutateAsync({
1043
+ documentId: document.id,
1044
+ sourceId: targetSourceId,
1045
+ changeSetIds,
1046
+ transitions:
1047
+ Object.keys(scopedTransitions).length > 0
1048
+ ? scopedTransitions
1049
+ : undefined,
1050
+ });
1051
+ setBuilderBatchResult(batchResult);
980
1052
  }
981
1053
 
982
1054
  setBuilderReviewResult(nextReview);
983
1055
  setBuilderReviewCheckedAt(new Date().toISOString());
984
1056
  toast.success(
985
- nextReview.result.status === "succeeded"
986
- ? "Builder update pushed"
1057
+ builderBatchSucceeded(batchResult)
1058
+ ? "Builder updates pushed"
987
1059
  : "Builder update checked",
988
1060
  {
989
- description: nextReview.result.message,
1061
+ description: builderBatchToastMessage(batchResult, nextReview),
990
1062
  },
991
1063
  );
992
1064
  } catch (error) {
@@ -1021,11 +1093,14 @@ function DatabaseTable({
1021
1093
  const nextViewConfig = normalizeClientDatabaseViewConfig(
1022
1094
  data.database.viewConfig,
1023
1095
  );
1024
- hydratedViewRef.current = databaseViewStateKey(
1025
- data.database.id,
1026
- nextViewConfig,
1096
+ const nextKey = databaseViewStateKey(data.database.id, nextViewConfig);
1097
+ if (hydratedViewRef.current === nextKey) return;
1098
+ hydratedViewRef.current = nextKey;
1099
+ setViewConfig((current) =>
1100
+ databaseViewStateKey(data.database.id, current) === nextKey
1101
+ ? current
1102
+ : nextViewConfig,
1027
1103
  );
1028
- setViewConfig(nextViewConfig);
1029
1104
  }, [data?.database.id, data?.database.viewConfig]);
1030
1105
 
1031
1106
  useEffect(() => {
@@ -1194,18 +1269,76 @@ function DatabaseTable({
1194
1269
  ) : null}
1195
1270
  </Button>
1196
1271
  {canEdit ? (
1197
- <Button
1198
- type="button"
1199
- size="sm"
1200
- className="h-7 rounded-md bg-foreground px-2.5 text-xs font-medium text-background hover:bg-foreground/90"
1201
- disabled={addItem.isPending || !databaseId}
1202
- onClick={() => void createRow()}
1203
- >
1204
- {addItem.isPending ? (
1205
- <Spinner className="mr-1.5 size-3.5" />
1206
- ) : null}
1207
- New
1208
- </Button>
1272
+ sourceTagPicker ? (
1273
+ <DropdownMenu>
1274
+ <DropdownMenuTrigger asChild>
1275
+ <Button
1276
+ type="button"
1277
+ size="sm"
1278
+ className="h-7 rounded-md bg-foreground px-2.5 text-xs font-medium text-background hover:bg-foreground/90"
1279
+ disabled={addItem.isPending || !databaseId}
1280
+ >
1281
+ {addItem.isPending ? (
1282
+ <Spinner className="mr-1.5 size-3.5" />
1283
+ ) : null}
1284
+ New
1285
+ <IconChevronDown className="ml-1 size-3.5" />
1286
+ </Button>
1287
+ </DropdownMenuTrigger>
1288
+ <DropdownMenuContent align="end" className="w-52">
1289
+ <DropdownMenuLabel>{db("addARowTo")}</DropdownMenuLabel>
1290
+ {sourceTagPicker.collections.map((collection) => (
1291
+ <DropdownMenuItem
1292
+ key={collection.label}
1293
+ onClick={() =>
1294
+ void createRow(
1295
+ "",
1296
+ collection.optionId
1297
+ ? {
1298
+ [sourceTagPicker.propertyId]:
1299
+ collection.optionId,
1300
+ }
1301
+ : {},
1302
+ )
1303
+ }
1304
+ >
1305
+ <BuilderLogoMark className="mr-2 size-3.5 shrink-0" />
1306
+ <span className="truncate">{collection.label}</span>
1307
+ </DropdownMenuItem>
1308
+ ))}
1309
+ <DropdownMenuSeparator />
1310
+ <DropdownMenuItem
1311
+ onClick={() =>
1312
+ void createRow(
1313
+ "",
1314
+ sourceTagPicker.localOptionId
1315
+ ? {
1316
+ [sourceTagPicker.propertyId]:
1317
+ sourceTagPicker.localOptionId,
1318
+ }
1319
+ : {},
1320
+ )
1321
+ }
1322
+ >
1323
+ <IconLayoutGrid className="mr-2 size-3.5 shrink-0" />
1324
+ <span className="truncate">{db("localNoCollection")}</span>
1325
+ </DropdownMenuItem>
1326
+ </DropdownMenuContent>
1327
+ </DropdownMenu>
1328
+ ) : (
1329
+ <Button
1330
+ type="button"
1331
+ size="sm"
1332
+ className="h-7 rounded-md bg-foreground px-2.5 text-xs font-medium text-background hover:bg-foreground/90"
1333
+ disabled={addItem.isPending || !databaseId}
1334
+ onClick={() => void createRow()}
1335
+ >
1336
+ {addItem.isPending ? (
1337
+ <Spinner className="mr-1.5 size-3.5" />
1338
+ ) : null}
1339
+ New
1340
+ </Button>
1341
+ )
1209
1342
  ) : null}
1210
1343
  </div>
1211
1344
  </div>
@@ -1431,14 +1564,18 @@ function DatabaseTable({
1431
1564
  type="button"
1432
1565
  variant="outline"
1433
1566
  size="sm"
1434
- disabled={database.isFetching}
1567
+ disabled={isLoadingMoreItems}
1435
1568
  onClick={() =>
1436
- setDatabaseItemLimit(
1437
- (current) => current + CONTENT_DATABASE_PAGE_SIZE,
1569
+ setDatabaseItemLimit((current) =>
1570
+ Math.min(
1571
+ current + CONTENT_DATABASE_PAGE_SIZE,
1572
+ totalItemCount,
1573
+ CONTENT_DATABASE_MAX_ITEM_LIMIT,
1574
+ ),
1438
1575
  )
1439
1576
  }
1440
1577
  >
1441
- {database.isFetching
1578
+ {isLoadingMoreItems
1442
1579
  ? "Loading..."
1443
1580
  : `Load more rows (${items.length} of ${totalItemCount})`}
1444
1581
  </Button>
@@ -1482,42 +1619,71 @@ function DatabaseTable({
1482
1619
  groupIds={toolbarGroups.map((group) => group.id)}
1483
1620
  onClose={() => setSettingsOpen(false)}
1484
1621
  onPanelChange={setSettingsPanel}
1485
- onAttachBuilderSource={(model) =>
1486
- attachSource.mutate({
1622
+ onAttachBuilderSource={(model, relationshipMode) =>
1623
+ attachSource.mutateAsync({
1487
1624
  documentId: document.id,
1488
1625
  sourceType: "builder-cms",
1489
1626
  sourceName: model.displayName,
1490
1627
  sourceTable: model.name,
1628
+ relationshipMode,
1629
+ mode:
1630
+ relationshipMode === "items"
1631
+ ? "add"
1632
+ : sources.length > 0 || source
1633
+ ? undefined
1634
+ : "replace",
1491
1635
  })
1492
1636
  }
1493
1637
  onFederateSource={(candidate, join) =>
1494
- attachSource.mutate({
1638
+ attachSource.mutateAsync({
1495
1639
  documentId: document.id,
1496
1640
  sourceType: candidate.sourceType,
1497
1641
  sourceName: candidate.sourceName,
1498
1642
  sourceTable: candidate.sourceTable,
1643
+ relationshipMode: "details",
1644
+ join,
1645
+ })
1646
+ }
1647
+ onChangeSourceRole={(sourceId, relationshipMode, join) =>
1648
+ changeSourceRole.mutateAsync({
1649
+ documentId: document.id,
1650
+ sourceId,
1651
+ relationshipMode,
1499
1652
  join,
1500
1653
  })
1501
1654
  }
1502
1655
  onDisconnectSecondary={(sourceId) =>
1503
- disconnectSource.mutate({ documentId: document.id, sourceId })
1656
+ disconnectSource.mutate(
1657
+ { documentId: document.id, sourceId },
1658
+ {
1659
+ onSuccess: () => {
1660
+ setBuilderReviewSourceId((current) =>
1661
+ current === sourceId ? null : current,
1662
+ );
1663
+ },
1664
+ },
1665
+ )
1504
1666
  }
1505
- onRefreshSource={() =>
1667
+ onRefreshSource={(sourceId) =>
1506
1668
  refreshSource.mutate({
1507
1669
  documentId: document.id,
1670
+ sourceId,
1508
1671
  })
1509
1672
  }
1510
- onDisconnectSource={() =>
1673
+ onDisconnectSource={(sourceId) =>
1511
1674
  disconnectSource.mutate(
1512
1675
  {
1513
1676
  documentId: document.id,
1677
+ sourceId,
1514
1678
  },
1515
1679
  {
1516
1680
  onSuccess: () => {
1517
1681
  setSettingsPanel("source");
1518
1682
  setBuilderReviewOpen(false);
1519
1683
  setBuilderReviewResult(null);
1684
+ setBuilderBatchResult(null);
1520
1685
  setBuilderReviewCheckedAt(null);
1686
+ setBuilderReviewSourceId(null);
1521
1687
  toast.success(db("sourceDisconnected"), {
1522
1688
  description: db(
1523
1689
  "databaseRowsAndLocalPropertiesWereKeptIntact",
@@ -1533,30 +1699,34 @@ function DatabaseTable({
1533
1699
  },
1534
1700
  )
1535
1701
  }
1536
- onReviewBuilderUpdate={() => {
1702
+ onReviewBuilderUpdate={(sourceId) => {
1537
1703
  setBuilderReviewResult(null);
1704
+ setBuilderBatchResult(null);
1538
1705
  setBuilderReviewCheckedAt(null);
1706
+ setBuilderReviewSourceId(sourceId ?? null);
1539
1707
  setBuilderReviewOpen(true);
1540
1708
  }}
1541
- onSetBuilderLiveWrites={(enabled) =>
1709
+ onSetBuilderLiveWrites={(settings, sourceId) =>
1542
1710
  setSourceWriteMode.mutate(
1543
1711
  {
1544
1712
  documentId: document.id,
1545
- liveWritesEnabled: enabled,
1546
- allowedWriteModes: enabled ? ["autosave"] : [],
1713
+ sourceId,
1714
+ writeMode: settings.writeMode,
1715
+ allowPublicationTransitions:
1716
+ settings.writeMode === "publish_updates" &&
1717
+ settings.allowPublicationTransitions === true,
1547
1718
  },
1548
1719
  {
1549
1720
  onSuccess: () => {
1550
- toast.success(
1551
- enabled
1552
- ? "Builder live writes enabled"
1553
- : "Builder live writes disabled",
1554
- {
1555
- description: enabled
1556
- ? "Only autosave writes to the Agent Native test collection can run."
1557
- : "Push will return to local validation only.",
1558
- },
1559
- );
1721
+ const enabled = settings.writeMode !== "read_only";
1722
+ toast.success(db("builderWriteModeUpdated"), {
1723
+ description:
1724
+ settings.writeMode === "publish_updates"
1725
+ ? "Approved updates can write through to Builder while preserving publication state."
1726
+ : enabled
1727
+ ? "Approved updates will stage Builder autosave revisions."
1728
+ : "Builder writes are disabled for this source.",
1729
+ });
1560
1730
  },
1561
1731
  onError: (error) => {
1562
1732
  toast.error(db("builderWriteModeWasNotChanged"), {
@@ -1569,10 +1739,11 @@ function DatabaseTable({
1569
1739
  }
1570
1740
  sourceActionPending={
1571
1741
  attachSource.isPending ||
1742
+ changeSourceRole.isPending ||
1572
1743
  refreshSource.isPending ||
1573
1744
  disconnectSource.isPending ||
1574
1745
  prepareBuilderReview.isPending ||
1575
- executeBuilderExecution.isPending ||
1746
+ executeBuilderBatch.isPending ||
1576
1747
  setSourceWriteMode.isPending
1577
1748
  }
1578
1749
  onViewTypeChange={(type) =>
@@ -1594,14 +1765,15 @@ function DatabaseTable({
1594
1765
  <BuilderSourceReviewDialog
1595
1766
  open={builderReviewOpen}
1596
1767
  review={activeBuilderReview}
1597
- source={source}
1768
+ source={activeReviewSource}
1598
1769
  canEdit={canEdit}
1599
1770
  pending={
1600
- prepareBuilderReview.isPending || executeBuilderExecution.isPending
1771
+ prepareBuilderReview.isPending || executeBuilderBatch.isPending
1601
1772
  }
1773
+ batchResult={builderBatchResult}
1602
1774
  checkedAt={builderReviewCheckedAt}
1603
1775
  onClose={() => setBuilderReviewOpen(false)}
1604
- onValidate={() => void handleBuilderReviewPush()}
1776
+ onValidate={(transitions) => void handleBuilderReviewPush(transitions)}
1605
1777
  />
1606
1778
 
1607
1779
  {!database.isLoading ? (
@@ -3109,6 +3281,7 @@ function DatabaseTableView({
3109
3281
  property={property}
3110
3282
  documentId={databaseDocumentId}
3111
3283
  source={source}
3284
+ sources={sources}
3112
3285
  canEdit={canEdit}
3113
3286
  isDragging={draggedPropertyId === property.definition.id}
3114
3287
  dropSide={
@@ -3396,6 +3569,7 @@ type PendingSourceCandidate = {
3396
3569
  sourceName: string;
3397
3570
  sourceTable: string;
3398
3571
  displayName: string;
3572
+ existingSourceId?: string;
3399
3573
  };
3400
3574
 
3401
3575
  type SourceNavStep =
@@ -3404,16 +3578,18 @@ type SourceNavStep =
3404
3578
  | { kind: "model"; model: BuilderCmsModelSummary }
3405
3579
  | { kind: "addSource" }
3406
3580
  | { kind: "secondarySource"; sourceId: string; sourceName: string }
3407
- | { kind: "keyConfirm"; candidate: PendingSourceCandidate };
3581
+ | { kind: "keyConfirm"; candidate: PendingSourceCandidate }
3582
+ | { kind: "fieldPicker"; sourceId: string; sourceName: string };
3408
3583
 
3409
- function sourceNavTitle(stack: SourceNavStep[]): string {
3584
+ function sourceNavTitle(stack: SourceNavStep[], db: DatabaseT): string {
3410
3585
  const top = stack[stack.length - 1];
3411
- if (!top) return "Sources";
3586
+ if (!top) return db("sources");
3412
3587
  if (top.kind === "provider") return "Builder";
3413
3588
  if (top.kind === "space") return top.spaceName;
3414
- if (top.kind === "addSource") return "Add a source";
3589
+ if (top.kind === "addSource") return db("addASource");
3415
3590
  if (top.kind === "secondarySource") return top.sourceName;
3416
- if (top.kind === "keyConfirm") return "Match on a key";
3591
+ if (top.kind === "keyConfirm") return db("matchExistingItemsToDetails");
3592
+ if (top.kind === "fieldPicker") return db("chooseFields");
3417
3593
  return top.model.displayName;
3418
3594
  }
3419
3595
 
@@ -3473,6 +3649,7 @@ function DatabaseSettingsPanelSheet({
3473
3649
  onPanelChange,
3474
3650
  onAttachBuilderSource,
3475
3651
  onFederateSource,
3652
+ onChangeSourceRole,
3476
3653
  onDisconnectSecondary,
3477
3654
  onRefreshSource,
3478
3655
  onDisconnectSource,
@@ -3501,16 +3678,27 @@ function DatabaseSettingsPanelSheet({
3501
3678
  groupIds: string[];
3502
3679
  onClose: () => void;
3503
3680
  onPanelChange: (panel: DatabaseSettingsPanel) => void;
3504
- onAttachBuilderSource: (model: BuilderCmsModelSummary) => void;
3681
+ onAttachBuilderSource: (
3682
+ model: BuilderCmsModelSummary,
3683
+ relationshipMode?: "items" | "details",
3684
+ ) => Promise<ContentDatabaseResponse>;
3505
3685
  onFederateSource: (
3506
3686
  candidate: PendingSourceCandidate,
3507
3687
  join: ContentDatabaseSourceJoinRequest,
3508
- ) => void;
3688
+ ) => Promise<ContentDatabaseResponse>;
3689
+ onChangeSourceRole: (
3690
+ sourceId: string,
3691
+ relationshipMode: "items" | "details",
3692
+ join?: ContentDatabaseSourceJoinRequest,
3693
+ ) => Promise<ContentDatabaseResponse>;
3509
3694
  onDisconnectSecondary: (sourceId: string) => void;
3510
- onRefreshSource: () => void;
3511
- onDisconnectSource: () => void;
3512
- onReviewBuilderUpdate: () => void;
3513
- onSetBuilderLiveWrites: (enabled: boolean) => void;
3695
+ onRefreshSource: (sourceId?: string) => void;
3696
+ onDisconnectSource: (sourceId?: string) => void;
3697
+ onReviewBuilderUpdate: (sourceId?: string) => void;
3698
+ onSetBuilderLiveWrites: (
3699
+ settings: BuilderSourceWriteSettingsInput,
3700
+ sourceId?: string,
3701
+ ) => void;
3514
3702
  sourceActionPending: boolean;
3515
3703
  onViewTypeChange: (type: ContentDatabaseViewType) => void;
3516
3704
  onWrapCellsChange: (wrapCells: boolean) => void;
@@ -3538,7 +3726,7 @@ function DatabaseSettingsPanelSheet({
3538
3726
  panel === "main"
3539
3727
  ? "Database settings"
3540
3728
  : panel === "source"
3541
- ? sourceNavTitle(sourceNavStack)
3729
+ ? sourceNavTitle(sourceNavStack, db)
3542
3730
  : databaseSettingsPanelTitle(panel);
3543
3731
 
3544
3732
  const handleBack = () => {
@@ -3583,6 +3771,7 @@ function DatabaseSettingsPanelSheet({
3583
3771
  <DatabaseSettingsMainPanel
3584
3772
  activeView={activeView}
3585
3773
  source={source}
3774
+ sources={sources}
3586
3775
  sourceCount={sources.length || (source ? 1 : 0)}
3587
3776
  propertyCount={properties.length}
3588
3777
  hiddenCount={hiddenCount}
@@ -3597,11 +3786,10 @@ function DatabaseSettingsPanelSheet({
3597
3786
  canEdit={canEdit}
3598
3787
  nav={sourceNavStack}
3599
3788
  onNavPush={(step) => setSourceNavStack((stack) => [...stack, step])}
3789
+ onNavReplace={setSourceNavStack}
3600
3790
  onAttachBuilderSource={onAttachBuilderSource}
3601
- onFederateSource={(candidate, join) => {
3602
- onFederateSource(candidate, join);
3603
- setSourceNavStack([]);
3604
- }}
3791
+ onFederateSource={onFederateSource}
3792
+ onChangeSourceRole={onChangeSourceRole}
3605
3793
  onDisconnectSecondary={(sourceId) => {
3606
3794
  onDisconnectSecondary(sourceId);
3607
3795
  setSourceNavStack([]);
@@ -3657,6 +3845,7 @@ function databaseSettingsPanelTitle(panel: DatabaseSettingsPanel) {
3657
3845
  function DatabaseSettingsMainPanel({
3658
3846
  activeView,
3659
3847
  source,
3848
+ sources,
3660
3849
  sourceCount,
3661
3850
  propertyCount,
3662
3851
  hiddenCount,
@@ -3664,6 +3853,7 @@ function DatabaseSettingsMainPanel({
3664
3853
  }: {
3665
3854
  activeView: ContentDatabaseView;
3666
3855
  source: ContentDatabaseSource | null;
3856
+ sources: ContentDatabaseSource[];
3667
3857
  sourceCount: number;
3668
3858
  propertyCount: number;
3669
3859
  hiddenCount: number;
@@ -3671,7 +3861,14 @@ function DatabaseSettingsMainPanel({
3671
3861
  }) {
3672
3862
  const db = useDatabaseT();
3673
3863
  const groupLabel = activeView.groupByPropertyId ? "On" : "";
3674
- const sourceBadgeCount = builderReviewableChangeSets(source).length;
3864
+ // Sum reviewable changes across EVERY source (row-union) so the collapsed
3865
+ // Sources row badges pending pushes even when they're only on a secondary.
3866
+ const sourceBadgeCount = (
3867
+ sources.length > 0 ? sources : source ? [source] : []
3868
+ ).reduce(
3869
+ (total, item) => total + builderReviewableChangeSets(item).length,
3870
+ 0,
3871
+ );
3675
3872
  return (
3676
3873
  <div className="grid gap-3">
3677
3874
  <div className="flex h-9 items-center gap-2 rounded-md border border-border bg-background px-2">
@@ -3758,28 +3955,94 @@ export function builderReviewExecutableRows(
3758
3955
  );
3759
3956
  }
3760
3957
 
3958
+ function builderBatchSucceeded(
3959
+ result: ExecuteBuilderSourceBatchResponse | null,
3960
+ ) {
3961
+ return (
3962
+ !!result &&
3963
+ result.summary.total > 0 &&
3964
+ result.summary.blocked === 0 &&
3965
+ result.summary.failed === 0
3966
+ );
3967
+ }
3968
+
3969
+ function builderBatchToastMessage(
3970
+ result: ExecuteBuilderSourceBatchResponse | null,
3971
+ review: ContentDatabaseSourceReviewPayload,
3972
+ ) {
3973
+ if (!result) return review.result.message;
3974
+ const { succeeded, blocked, failed } = result.summary;
3975
+ return `${succeeded} succeeded, ${blocked} blocked, ${failed} failed.`;
3976
+ }
3977
+
3761
3978
  export function builderSourceLiveWriteControlState(
3762
3979
  source: ContentDatabaseSource | null,
3763
3980
  ) {
3764
3981
  const isBuilderSource = source?.sourceType === "builder-cms";
3765
3982
  const safeTarget =
3766
3983
  isBuilderSource && source?.sourceTable === BUILDER_CMS_SAFE_WRITE_MODEL;
3767
- const enabled = source?.capabilities.liveWritesEnabled === true;
3984
+ const legacyAllowedWriteModes = source?.metadata.allowedWriteModes ?? [];
3985
+ const writeMode =
3986
+ source?.metadata.writeMode ??
3987
+ (source?.capabilities.liveWritesEnabled === true
3988
+ ? legacyAllowedWriteModes.some((mode) => mode !== "autosave")
3989
+ ? "publish_updates"
3990
+ : "stage_only"
3991
+ : "read_only");
3992
+ const enabled = writeMode !== "read_only";
3768
3993
  return {
3769
3994
  safeTarget,
3770
3995
  enabled,
3996
+ writeMode,
3997
+ allowPublicationTransitions:
3998
+ writeMode === "publish_updates" &&
3999
+ source?.metadata.allowPublicationTransitions === true,
3771
4000
  showAction: safeTarget,
3772
4001
  actionLabel: enabled ? "Disable" : "Enable",
3773
4002
  description: enabled
3774
- ? "Enabled for autosave writes to the Agent Native test collection."
4003
+ ? writeMode === "publish_updates"
4004
+ ? "Approved updates can write through to Builder while preserving publication state."
4005
+ : "Enabled for autosave writes to the Agent Native test collection."
3775
4006
  : safeTarget
3776
- ? "Off by default. Enable only when you are ready to send autosave writes to the Agent Native test collection."
4007
+ ? "Off by default. Choose a write tier only when this source should send guarded Builder writes."
3777
4008
  : isBuilderSource
3778
4009
  ? "Unavailable here; live writes are locked to the Agent Native test collection."
3779
4010
  : "Live writes are not available for this source.",
3780
4011
  };
3781
4012
  }
3782
4013
 
4014
+ const BUILDER_WRITE_MODE_OPTIONS: Array<{
4015
+ mode: ContentDatabaseSourceWriteMode;
4016
+ labelKey: string;
4017
+ descriptionKey: string;
4018
+ }> = [
4019
+ {
4020
+ mode: "read_only",
4021
+ labelKey: "readOnly",
4022
+ descriptionKey: "noBuilderWrites",
4023
+ },
4024
+ {
4025
+ mode: "stage_only",
4026
+ labelKey: "stageOnly",
4027
+ descriptionKey: "savesDraftsNeverPublishes",
4028
+ },
4029
+ {
4030
+ mode: "publish_updates",
4031
+ labelKey: "publishUpdates",
4032
+ descriptionKey: "writesUpdatesToLiveEntries",
4033
+ },
4034
+ ];
4035
+
4036
+ function builderWriteModeSummary(
4037
+ mode: ContentDatabaseSourceWriteMode,
4038
+ db: DatabaseT,
4039
+ ) {
4040
+ const option = BUILDER_WRITE_MODE_OPTIONS.find(
4041
+ (candidate) => candidate.mode === mode,
4042
+ );
4043
+ return db(option?.descriptionKey ?? "noBuilderWrites");
4044
+ }
4045
+
3783
4046
  export function buildClientBuilderReviewPayload(
3784
4047
  source: ContentDatabaseSource,
3785
4048
  changeSets: ContentDatabaseSourceChangeSet[],
@@ -3819,6 +4082,7 @@ export function buildClientBuilderReviewPayload(
3819
4082
  riskLevel: changeSet.riskLevel,
3820
4083
  riskReasons: changeSet.riskReasons,
3821
4084
  conflictState: changeSet.conflictState,
4085
+ effect: resolveBuilderCmsWriteEffect({ source, changeSet }),
3822
4086
  execution: latestExecution,
3823
4087
  };
3824
4088
  });
@@ -3900,8 +4164,10 @@ function DatabaseSettingsSourcePanel({
3900
4164
  canEdit,
3901
4165
  nav,
3902
4166
  onNavPush,
4167
+ onNavReplace,
3903
4168
  onAttachBuilderSource,
3904
4169
  onFederateSource,
4170
+ onChangeSourceRole,
3905
4171
  onDisconnectSecondary,
3906
4172
  onRefreshSource,
3907
4173
  onDisconnectSource,
@@ -3916,33 +4182,31 @@ function DatabaseSettingsSourcePanel({
3916
4182
  canEdit: boolean;
3917
4183
  nav: SourceNavStep[];
3918
4184
  onNavPush: (step: SourceNavStep) => void;
3919
- onAttachBuilderSource: (model: BuilderCmsModelSummary) => void;
4185
+ onNavReplace: (stack: SourceNavStep[]) => void;
4186
+ onAttachBuilderSource: (
4187
+ model: BuilderCmsModelSummary,
4188
+ relationshipMode?: "items" | "details",
4189
+ ) => Promise<ContentDatabaseResponse>;
3920
4190
  onFederateSource: (
3921
4191
  candidate: PendingSourceCandidate,
3922
4192
  join: ContentDatabaseSourceJoinRequest,
3923
- ) => void;
4193
+ ) => Promise<ContentDatabaseResponse>;
4194
+ onChangeSourceRole: (
4195
+ sourceId: string,
4196
+ relationshipMode: "items" | "details",
4197
+ join?: ContentDatabaseSourceJoinRequest,
4198
+ ) => Promise<ContentDatabaseResponse>;
3924
4199
  onDisconnectSecondary: (sourceId: string) => void;
3925
- onRefreshSource: () => void;
3926
- onDisconnectSource: () => void;
3927
- onReviewBuilderUpdate: () => void;
3928
- onSetBuilderLiveWrites: (enabled: boolean) => void;
4200
+ onRefreshSource: (sourceId?: string) => void;
4201
+ onDisconnectSource: (sourceId?: string) => void;
4202
+ onReviewBuilderUpdate: (sourceId?: string) => void;
4203
+ onSetBuilderLiveWrites: (
4204
+ settings: BuilderSourceWriteSettingsInput,
4205
+ sourceId?: string,
4206
+ ) => void;
3929
4207
  sourceActionPending: boolean;
3930
4208
  }) {
3931
4209
  const db = useDatabaseT();
3932
- const outboundChangeSets =
3933
- source?.changeSets.filter(
3934
- (changeSet) => changeSet.direction === "outbound",
3935
- ) ?? [];
3936
- const reviewableBuilderChangeSets = outboundChangeSets.filter(
3937
- (changeSet) =>
3938
- changeSet.state === "pending_push" ||
3939
- changeSet.state === "staged_revision" ||
3940
- changeSet.state === "approved",
3941
- );
3942
- const conflictChangeSets =
3943
- source?.changeSets.filter(
3944
- (changeSet) => changeSet.conflictState === "source_changed",
3945
- ) ?? [];
3946
4210
  const { isCodeMode } = useCodeMode();
3947
4211
  const isBuilderSource = source?.sourceType === "builder-cms";
3948
4212
  const builderStatus = useBuilderStatus();
@@ -3963,33 +4227,42 @@ function DatabaseSettingsSourcePanel({
3963
4227
  void builderStatus.refetch();
3964
4228
  },
3965
4229
  });
3966
- const builderSyncFailed =
3967
- isBuilderSource &&
3968
- (source?.syncState === "error" || Boolean(source?.lastError));
3969
-
3970
4230
  // Auto-sync: the manual Refresh button is gone, so pull the read-only
3971
4231
  // snapshot when the panel opens and whenever the window regains focus.
3972
4232
  // Throttled so rapid focus changes don't hammer Builder; the refresh
3973
4233
  // mutation is silent (no toast), so this stays quiet in the background.
3974
4234
  const refreshSourceRef = useRef(onRefreshSource);
3975
4235
  refreshSourceRef.current = onRefreshSource;
3976
- const lastAutoSyncRef = useRef(0);
4236
+ const lastAutoSyncRef = useRef<{ at: number; sourceId?: string }>({ at: 0 });
3977
4237
  const autoSyncEnabled = Boolean(source) && isBuilderSource && canEdit;
4238
+ const top = nav[nav.length - 1];
4239
+ // When a non-primary Builder source's leaf is open, auto-sync should refresh
4240
+ // THAT source, not the primary. At the root/list (no model leaf) we fall back
4241
+ // to the primary (undefined ⇒ primary in the refresh action).
4242
+ const autoSyncSourceId =
4243
+ top?.kind === "model"
4244
+ ? sources.find(
4245
+ (item) =>
4246
+ item.sourceType === "builder-cms" &&
4247
+ item.sourceTable === top.model.name,
4248
+ )?.id
4249
+ : undefined;
3978
4250
  useEffect(() => {
3979
4251
  if (!autoSyncEnabled) return;
3980
4252
  const maybeSync = () => {
3981
4253
  const now = Date.now();
3982
- if (now - lastAutoSyncRef.current < 15_000) return;
3983
- lastAutoSyncRef.current = now;
3984
- refreshSourceRef.current();
4254
+ const last = lastAutoSyncRef.current;
4255
+ // Throttle repeated syncs of the SAME source, but always sync when the
4256
+ // viewed source changes (so opening a secondary leaf refreshes it).
4257
+ if (last.sourceId === autoSyncSourceId && now - last.at < 15_000) return;
4258
+ lastAutoSyncRef.current = { at: now, sourceId: autoSyncSourceId };
4259
+ refreshSourceRef.current(autoSyncSourceId);
3985
4260
  };
3986
4261
  maybeSync();
3987
4262
  const onFocus = () => maybeSync();
3988
4263
  window.addEventListener("focus", onFocus);
3989
4264
  return () => window.removeEventListener("focus", onFocus);
3990
- }, [autoSyncEnabled]);
3991
-
3992
- const top = nav[nav.length - 1];
4265
+ }, [autoSyncEnabled, autoSyncSourceId]);
3993
4266
 
3994
4267
  // ── Sources list (root) ───────────────────────────────────────────────
3995
4268
  if (!top) {
@@ -3997,11 +4270,11 @@ function DatabaseSettingsSourcePanel({
3997
4270
  <SourcesListView
3998
4271
  source={source}
3999
4272
  sources={sources}
4000
- builderConfigured={builderConfigured}
4001
- builderSpaceLabel={builderSpaceLabel}
4002
- reviewableCount={reviewableBuilderChangeSets.length}
4003
- onOpenBuilder={() =>
4004
- onNavPush({ kind: "provider", providerId: "builder" })
4273
+ onOpenConnectedBuilder={(connected) =>
4274
+ onNavPush({
4275
+ kind: "model",
4276
+ model: builderModelSummaryFromSource(connected),
4277
+ })
4005
4278
  }
4006
4279
  onOpenSecondary={(secondary) =>
4007
4280
  onNavPush({
@@ -4026,6 +4299,11 @@ function DatabaseSettingsSourcePanel({
4026
4299
  .map((item) => item.sourceTable),
4027
4300
  ]}
4028
4301
  canEdit={canEdit}
4302
+ builderConfigured={builderConfigured}
4303
+ builderSpaceLabel={builderSpaceLabel}
4304
+ onOpenBuilder={() =>
4305
+ onNavPush({ kind: "provider", providerId: "builder" })
4306
+ }
4029
4307
  onPickLocalTable={(table) =>
4030
4308
  onNavPush({
4031
4309
  kind: "keyConfirm",
@@ -4049,6 +4327,34 @@ function DatabaseSettingsSourcePanel({
4049
4327
  source={secondary}
4050
4328
  canEdit={canEdit}
4051
4329
  pending={sourceActionPending}
4330
+ onAddDetails={() =>
4331
+ secondary
4332
+ ? onNavPush({
4333
+ kind: "keyConfirm",
4334
+ candidate: {
4335
+ sourceType: secondary.sourceType,
4336
+ sourceName: secondary.sourceName,
4337
+ sourceTable: secondary.sourceTable,
4338
+ displayName: secondary.sourceName,
4339
+ existingSourceId: secondary.id,
4340
+ },
4341
+ })
4342
+ : undefined
4343
+ }
4344
+ onAddItems={async () => {
4345
+ if (!secondary) return;
4346
+ await onChangeSourceRole(secondary.id, "items");
4347
+ onNavReplace([]);
4348
+ }}
4349
+ onChooseFields={() =>
4350
+ secondary
4351
+ ? onNavPush({
4352
+ kind: "fieldPicker",
4353
+ sourceId: secondary.id,
4354
+ sourceName: secondary.sourceName,
4355
+ })
4356
+ : undefined
4357
+ }
4052
4358
  onDisconnect={() => onDisconnectSecondary(top.sourceId)}
4053
4359
  />
4054
4360
  );
@@ -4062,7 +4368,41 @@ function DatabaseSettingsSourcePanel({
4062
4368
  candidate={top.candidate}
4063
4369
  canEdit={canEdit}
4064
4370
  pending={sourceActionPending}
4065
- onCommit={(join) => onFederateSource(top.candidate, join)}
4371
+ onCommit={async (join) => {
4372
+ const result = top.candidate.existingSourceId
4373
+ ? await onChangeSourceRole(
4374
+ top.candidate.existingSourceId,
4375
+ "details",
4376
+ join,
4377
+ )
4378
+ : await onFederateSource(top.candidate, join);
4379
+ const detailsSource = findDetailsSource(result, top.candidate);
4380
+ onNavReplace(
4381
+ detailsSource
4382
+ ? [
4383
+ {
4384
+ kind: "fieldPicker",
4385
+ sourceId: detailsSource.id,
4386
+ sourceName: detailsSource.sourceName,
4387
+ },
4388
+ ]
4389
+ : [],
4390
+ );
4391
+ }}
4392
+ />
4393
+ );
4394
+ }
4395
+
4396
+ if (top.kind === "fieldPicker") {
4397
+ const pickerSource =
4398
+ sources.find((item) => item.id === top.sourceId) ?? null;
4399
+ return (
4400
+ <SourceDetailsFieldPicker
4401
+ documentId={documentId}
4402
+ source={pickerSource}
4403
+ canEdit={canEdit}
4404
+ pending={sourceActionPending}
4405
+ onDone={() => onNavReplace([])}
4066
4406
  />
4067
4407
  );
4068
4408
  }
@@ -4127,9 +4467,9 @@ function DatabaseSettingsSourcePanel({
4127
4467
  if (top.kind === "space") {
4128
4468
  return (
4129
4469
  <BuilderSpaceModelsView
4130
- attachedModelName={
4131
- isBuilderSource ? (source?.sourceTable ?? null) : null
4132
- }
4470
+ attachedModelNames={sources
4471
+ .filter((item) => item.sourceType === "builder-cms")
4472
+ .map((item) => item.sourceTable)}
4133
4473
  onOpenModel={(model) => onNavPush({ kind: "model", model })}
4134
4474
  />
4135
4475
  );
@@ -4137,12 +4477,21 @@ function DatabaseSettingsSourcePanel({
4137
4477
 
4138
4478
  // ── Model leaf ────────────────────────────────────────────────────────
4139
4479
  const model = top.model;
4140
- const isAttachedModel =
4141
- Boolean(source) && isBuilderSource && source?.sourceTable === model.name;
4480
+ // Resolve the source this leaf operates on by the model being viewed, NOT by
4481
+ // assuming the primary a row-union database has multiple writable Builder
4482
+ // sources, and opening any of them must land on its own writable leaf.
4483
+ // Collection names are unique per database (duplicate-attach is guarded), so
4484
+ // matching on sourceTable is unambiguous.
4485
+ const leafSource =
4486
+ sources.find(
4487
+ (item) =>
4488
+ item.sourceType === "builder-cms" && item.sourceTable === model.name,
4489
+ ) ?? null;
4490
+ const isAttachedModel = Boolean(leafSource);
4142
4491
 
4143
4492
  // Unattached model → the attach affordance (the model is already chosen by
4144
4493
  // drilling in, so there's no model picker here).
4145
- if (!isAttachedModel || !source) {
4494
+ if (!isAttachedModel || !leafSource) {
4146
4495
  return (
4147
4496
  <div className="grid min-w-0 gap-3">
4148
4497
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
@@ -4161,148 +4510,318 @@ function DatabaseSettingsSourcePanel({
4161
4510
  </span>
4162
4511
  </div>
4163
4512
  </div>
4164
- <div>
4165
- <Button
4166
- type="button"
4167
- size="sm"
4168
- disabled={!canEdit || sourceActionPending}
4169
- onClick={() => onAttachBuilderSource(model)}
4170
- >
4171
- {sourceActionPending ? (
4172
- <Spinner className="mr-1.5 size-3.5" />
4173
- ) : (
4174
- <IconPlugConnected className="mr-1.5 size-3.5" />
4175
- )}
4176
- Attach
4177
- </Button>
4178
- </div>
4513
+ {sources.length > 0 || source ? (
4514
+ <SourceRelationshipChoice
4515
+ documentId={documentId}
4516
+ candidate={{
4517
+ sourceType: "builder-cms",
4518
+ sourceName: model.displayName,
4519
+ sourceTable: model.name,
4520
+ displayName: model.displayName,
4521
+ }}
4522
+ canEdit={canEdit}
4523
+ pending={sourceActionPending}
4524
+ onAddDetails={() =>
4525
+ onNavPush({
4526
+ kind: "keyConfirm",
4527
+ candidate: {
4528
+ sourceType: "builder-cms",
4529
+ sourceName: model.displayName,
4530
+ sourceTable: model.name,
4531
+ displayName: model.displayName,
4532
+ },
4533
+ })
4534
+ }
4535
+ onAddItems={async () => {
4536
+ await onAttachBuilderSource(model, "items");
4537
+ onNavReplace([]);
4538
+ }}
4539
+ />
4540
+ ) : (
4541
+ <div>
4542
+ <Button
4543
+ type="button"
4544
+ size="sm"
4545
+ disabled={!canEdit || sourceActionPending}
4546
+ onClick={async () => {
4547
+ await onAttachBuilderSource(model);
4548
+ onNavReplace([]);
4549
+ }}
4550
+ >
4551
+ {sourceActionPending ? (
4552
+ <Spinner className="mr-1.5 size-3.5" />
4553
+ ) : (
4554
+ <IconPlugConnected className="mr-1.5 size-3.5" />
4555
+ )}
4556
+ Attach
4557
+ </Button>
4558
+ </div>
4559
+ )}
4179
4560
  </div>
4180
4561
  );
4181
4562
  }
4182
4563
 
4183
- // Attached model → the minimal read-only leaf panel.
4564
+ // Attached model → the writable leaf panel for THIS source (primary or a
4565
+ // row-union secondary). All derived state below is scoped to `leafSource`,
4566
+ // never the component-level primary `source`.
4567
+ const liveWriteControl = builderSourceLiveWriteControlState(leafSource);
4568
+ const builderLiveWritesDisabled = !canEdit || sourceActionPending;
4569
+ const builderWriteMode = liveWriteControl.writeMode;
4570
+ const leafOutboundChangeSets = leafSource.changeSets.filter(
4571
+ (changeSet) => changeSet.direction === "outbound",
4572
+ );
4573
+ const leafReviewableChangeSets = leafOutboundChangeSets.filter(
4574
+ (changeSet) =>
4575
+ changeSet.state === "pending_push" ||
4576
+ changeSet.state === "staged_revision" ||
4577
+ changeSet.state === "approved",
4578
+ );
4579
+ const leafPendingChangeSets = leafOutboundChangeSets.filter(
4580
+ (changeSet) => changeSet.state !== "applied",
4581
+ );
4582
+ const leafAppliedChangeSets = leafOutboundChangeSets.filter(
4583
+ (changeSet) => changeSet.state === "applied",
4584
+ );
4585
+ const leafSyncFailed =
4586
+ leafSource.syncState === "error" || Boolean(leafSource.lastError);
4587
+
4184
4588
  return (
4185
4589
  <div className="grid min-w-0 gap-4">
4186
4590
  <>
4187
4591
  <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
4188
4592
  <div className="flex min-w-0 items-center justify-between gap-2">
4189
- <span className="truncate font-medium" title={source.sourceName}>
4190
- {source.sourceName}
4593
+ <span
4594
+ className="truncate font-medium"
4595
+ title={leafSource.sourceName}
4596
+ >
4597
+ {leafSource.sourceName}
4191
4598
  </span>
4192
- {isBuilderSource ? (
4193
- source.capabilities.liveWritesEnabled ? (
4194
- <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-foreground">
4195
- <IconPencil className="size-3" />
4196
- <DatabaseText k="liveWritesOn" />
4197
- </span>
4198
- ) : (
4199
- <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
4200
- <IconLock className="size-3" />
4201
- <DatabaseText k="readOnly" />
4202
- </span>
4203
- )
4204
- ) : (
4205
- <span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-[11px] uppercase tracking-wide text-muted-foreground">
4206
- {source.syncState}
4599
+ {!liveWriteControl.safeTarget ? (
4600
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
4601
+ <IconLock className="size-3" />
4602
+ <DatabaseText k="readOnly" />
4207
4603
  </span>
4208
- )}
4604
+ ) : null}
4209
4605
  </div>
4606
+ {!liveWriteControl.safeTarget ? (
4607
+ <div className="text-[11px] text-muted-foreground">
4608
+ {db("liveWritesTestCollectionOnly")}
4609
+ </div>
4610
+ ) : null}
4210
4611
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4211
- {builderSyncFailed ? (
4612
+ {leafSyncFailed ? (
4212
4613
  <button
4213
4614
  type="button"
4214
4615
  className="inline-flex items-center gap-1 text-destructive hover:underline disabled:opacity-60"
4215
4616
  disabled={!canEdit || sourceActionPending}
4216
- onClick={onRefreshSource}
4617
+ onClick={() => onRefreshSource(leafSource.id)}
4217
4618
  >
4218
4619
  <IconRefresh className="size-3" />
4219
4620
  <DatabaseText k="couldntSyncRetry" />
4220
4621
  </button>
4221
- ) : isBuilderSource ? (
4622
+ ) : (
4222
4623
  [
4223
4624
  builderConfigured ? (builderSpaceLabel ?? "Connected") : null,
4224
- source.lastRefreshedAt
4625
+ leafSource.lastRefreshedAt
4225
4626
  ? `synced ${
4226
- formatRelativeSyncTime(source.lastRefreshedAt) ??
4227
- source.freshness
4627
+ formatRelativeSyncTime(leafSource.lastRefreshedAt) ??
4628
+ leafSource.freshness
4228
4629
  }`
4229
- : source.freshness,
4630
+ : leafSource.freshness,
4230
4631
  ]
4231
4632
  .filter(Boolean)
4232
4633
  .join(" · ")
4233
- ) : (
4234
- `Local snapshot · ${source.freshness}`
4235
4634
  )}
4236
4635
  </div>
4237
- </div>
4238
-
4239
- {reviewableBuilderChangeSets.length > 0 ||
4240
- conflictChangeSets.length > 0 ? (
4241
- <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-muted/30 p-3">
4242
- <div className="flex items-center justify-between gap-3">
4243
- <div className="min-w-0">
4244
- <div className="text-sm font-medium">
4245
- {conflictChangeSets.length > 0
4246
- ? `${conflictChangeSets.length} change${
4247
- conflictChangeSets.length === 1 ? "" : "s"
4248
- } need review`
4249
- : `${reviewableBuilderChangeSets.length} change${
4250
- reviewableBuilderChangeSets.length === 1 ? "" : "s"
4251
- } ready to push`}
4252
- </div>
4253
- <div className="mt-0.5 break-words text-xs text-muted-foreground">
4254
- <DatabaseText k="reviewBeforeTheyReachBuilder" />
4255
- </div>
4636
+ {liveWriteControl.safeTarget ? (
4637
+ <div className="grid min-w-0 gap-2 text-xs text-muted-foreground">
4638
+ <div className="flex items-center justify-between gap-3">
4639
+ <span className="font-medium text-foreground">
4640
+ {db("builderWriteMode")}
4641
+ </span>
4642
+ <span>{builderWriteModeSummary(builderWriteMode, db)}</span>
4256
4643
  </div>
4257
- <Button
4258
- type="button"
4259
- size="sm"
4260
- className="shrink-0"
4261
- disabled={!canEdit || sourceActionPending}
4262
- onClick={onReviewBuilderUpdate}
4644
+ <div
4645
+ className="grid grid-cols-3 gap-0.5 rounded-md border border-border bg-muted/35 p-0.5"
4646
+ aria-label={db("builderWriteMode")}
4263
4647
  >
4264
- <IconCheck className="mr-1.5 size-3.5" />
4265
- <DatabaseText k="reviewDiff" />
4266
- </Button>
4648
+ {BUILDER_WRITE_MODE_OPTIONS.map((option) => {
4649
+ const selected = builderWriteMode === option.mode;
4650
+ return (
4651
+ <button
4652
+ key={option.mode}
4653
+ type="button"
4654
+ aria-pressed={selected}
4655
+ title={db(option.descriptionKey)}
4656
+ disabled={builderLiveWritesDisabled}
4657
+ className={cn(
4658
+ "min-w-0 rounded px-2 py-1.5 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60",
4659
+ selected
4660
+ ? "bg-[#2383e2] text-white shadow-sm"
4661
+ : "text-muted-foreground hover:bg-background hover:text-foreground",
4662
+ )}
4663
+ onClick={() =>
4664
+ onSetBuilderLiveWrites(
4665
+ {
4666
+ writeMode: option.mode,
4667
+ allowPublicationTransitions:
4668
+ option.mode === "publish_updates"
4669
+ ? liveWriteControl.allowPublicationTransitions
4670
+ : false,
4671
+ },
4672
+ leafSource.id,
4673
+ )
4674
+ }
4675
+ >
4676
+ <span className="block truncate">
4677
+ {db(option.labelKey)}
4678
+ </span>
4679
+ </button>
4680
+ );
4681
+ })}
4682
+ </div>
4683
+ {builderWriteMode === "publish_updates" ? (
4684
+ <button
4685
+ type="button"
4686
+ role="checkbox"
4687
+ aria-checked={liveWriteControl.allowPublicationTransitions}
4688
+ disabled={builderLiveWritesDisabled}
4689
+ className="flex min-w-0 items-start gap-2 rounded px-1 py-1 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent"
4690
+ onClick={() =>
4691
+ onSetBuilderLiveWrites(
4692
+ {
4693
+ writeMode: "publish_updates",
4694
+ allowPublicationTransitions:
4695
+ !liveWriteControl.allowPublicationTransitions,
4696
+ },
4697
+ leafSource.id,
4698
+ )
4699
+ }
4700
+ >
4701
+ <span
4702
+ className={cn(
4703
+ "mt-0.5 inline-flex size-4 shrink-0 items-center justify-center rounded border",
4704
+ liveWriteControl.allowPublicationTransitions
4705
+ ? "border-[#2383e2] bg-[#2383e2] text-white"
4706
+ : "border-muted-foreground/40 bg-background text-transparent",
4707
+ )}
4708
+ >
4709
+ {liveWriteControl.allowPublicationTransitions ? (
4710
+ <IconCheck className="size-3" />
4711
+ ) : null}
4712
+ </span>
4713
+ <span className="min-w-0">
4714
+ <span className="block text-foreground">
4715
+ {db("allowPublishUnpublishPerItem")}
4716
+ </span>
4717
+ <span className="block break-words">
4718
+ Requires explicit item intent; unpublish still needs
4719
+ confirmation.
4720
+ </span>
4721
+ </span>
4722
+ </button>
4723
+ ) : null}
4267
4724
  </div>
4268
- </div>
4269
- ) : null}
4725
+ ) : null}
4726
+ </div>
4270
4727
 
4271
- {isCodeMode ? (
4272
- <>
4273
- <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3 text-sm">
4274
- <div className="font-medium">
4275
- {source.sourceType === "builder-cms"
4276
- ? "Local Builder changes"
4277
- : "Local outbound changes"}
4278
- </div>
4279
- <div className="text-xs text-muted-foreground">
4280
- {source.sourceType === "builder-cms"
4281
- ? source.capabilities.liveWritesEnabled
4282
- ? "Local edits can be reviewed and sent through the guarded Builder autosave path."
4283
- : "Local edits can be staged as a Builder save revision/autosave record. Live Builder writes are disabled."
4284
- : "No local outbound push lane is active for this mock source."}
4285
- </div>
4728
+ {leafPendingChangeSets.length > 0 ||
4729
+ leafAppliedChangeSets.length > 0 ||
4730
+ isCodeMode ? (
4731
+ <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3 text-sm">
4732
+ <div className="font-medium">{db("builderChanges")}</div>
4733
+ <div className="text-xs text-muted-foreground">
4734
+ {leafSource.capabilities.liveWritesEnabled
4735
+ ? db("reviewLocalEditsBeforeBuilder")
4736
+ : db("liveWritesOffStagedForReview")}
4737
+ </div>
4738
+
4739
+ {leafPendingChangeSets.length > 0 ? (
4286
4740
  <div className="grid min-w-0 gap-2">
4287
- {outboundChangeSets.slice(0, 6).map((changeSet) => (
4741
+ {leafPendingChangeSets.slice(0, 6).map((changeSet) => (
4288
4742
  <SourceChangeSetReviewCard
4289
4743
  key={changeSet.id}
4290
4744
  changeSet={changeSet}
4291
- source={source}
4745
+ source={leafSource}
4746
+ showDetails={isCodeMode}
4292
4747
  />
4293
4748
  ))}
4294
- {outboundChangeSets.length === 0 ? (
4295
- <div className="text-xs text-muted-foreground">
4296
- {source.sourceType === "builder-cms"
4297
- ? "No pending local Builder changes yet. Rename a source-backed row to see a local outbound diff."
4298
- : "No local outbound changes yet."}
4299
- </div>
4300
- ) : null}
4301
4749
  </div>
4302
- </div>
4303
- </>
4750
+ ) : isCodeMode ? (
4751
+ <div className="text-xs text-muted-foreground">
4752
+ {db("noPendingChangesEditASourceBackedRow")}
4753
+ </div>
4754
+ ) : null}
4755
+
4756
+ {leafReviewableChangeSets.length > 0 ? (
4757
+ <div className="flex justify-end">
4758
+ <Button
4759
+ type="button"
4760
+ size="sm"
4761
+ disabled={!canEdit || sourceActionPending}
4762
+ onClick={() => onReviewBuilderUpdate(leafSource.id)}
4763
+ >
4764
+ <IconCheck className="mr-1.5 size-3.5" />
4765
+ {db("reviewChanges")}
4766
+ </Button>
4767
+ </div>
4768
+ ) : null}
4769
+
4770
+ {leafAppliedChangeSets.length > 0 ? (
4771
+ <div className="mt-1 grid min-w-0 gap-1.5 border-t border-border/60 pt-2">
4772
+ <div className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4773
+ {db("recentlyPushed")}
4774
+ </div>
4775
+ {leafAppliedChangeSets.slice(0, 3).map((changeSet) => (
4776
+ <SourceChangeSetReviewCard
4777
+ key={changeSet.id}
4778
+ changeSet={changeSet}
4779
+ source={leafSource}
4780
+ showDetails={isCodeMode}
4781
+ />
4782
+ ))}
4783
+ {leafAppliedChangeSets.length > 3 ? (
4784
+ <div className="text-[11px] text-muted-foreground">
4785
+ +{leafAppliedChangeSets.length - 3} more
4786
+ </div>
4787
+ ) : null}
4788
+ </div>
4789
+ ) : null}
4790
+ </div>
4304
4791
  ) : null}
4305
4792
 
4793
+ <SourceRoleCard
4794
+ source={leafSource}
4795
+ canAddDetails={sources.some(
4796
+ (item) => item.id !== leafSource.id && !sourceAddsDetails(item),
4797
+ )}
4798
+ canEdit={canEdit}
4799
+ pending={sourceActionPending}
4800
+ onAddDetails={() =>
4801
+ onNavPush({
4802
+ kind: "keyConfirm",
4803
+ candidate: {
4804
+ sourceType: leafSource.sourceType,
4805
+ sourceName: leafSource.sourceName,
4806
+ sourceTable: leafSource.sourceTable,
4807
+ displayName: leafSource.sourceName,
4808
+ existingSourceId: leafSource.id,
4809
+ },
4810
+ })
4811
+ }
4812
+ onAddItems={async () => {
4813
+ await onChangeSourceRole(leafSource.id, "items");
4814
+ onNavReplace([]);
4815
+ }}
4816
+ onChooseFields={() =>
4817
+ onNavPush({
4818
+ kind: "fieldPicker",
4819
+ sourceId: leafSource.id,
4820
+ sourceName: leafSource.sourceName,
4821
+ })
4822
+ }
4823
+ />
4824
+
4306
4825
  <div className="rounded-lg border border-border bg-background p-3">
4307
4826
  <div className="text-xs font-medium">
4308
4827
  <DatabaseText k="disconnectSource" />
@@ -4316,7 +4835,7 @@ function DatabaseSettingsSourcePanel({
4316
4835
  variant="outline"
4317
4836
  className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
4318
4837
  disabled={!canEdit || sourceActionPending}
4319
- onClick={onDisconnectSource}
4838
+ onClick={() => onDisconnectSource(leafSource.id)}
4320
4839
  >
4321
4840
  {sourceActionPending ? (
4322
4841
  <Spinner className="mr-1 size-3.5" />
@@ -4333,113 +4852,103 @@ function DatabaseSettingsSourcePanel({
4333
4852
 
4334
4853
  // Root of the Sources drill-down: third-party integrations + Agent-Native apps,
4335
4854
  // each provider a row. Builder is live; the rest are disabled "coming soon".
4855
+ function builderModelSummaryFromSource(
4856
+ source: ContentDatabaseSource,
4857
+ ): BuilderCmsModelSummary {
4858
+ return {
4859
+ id: source.sourceTable,
4860
+ name: source.sourceTable,
4861
+ displayName: source.sourceName,
4862
+ kind: "data",
4863
+ fields: [],
4864
+ };
4865
+ }
4866
+
4867
+ function reviewableCountForSource(source: ContentDatabaseSource): number {
4868
+ return source.changeSets.filter(
4869
+ (changeSet) =>
4870
+ changeSet.direction === "outbound" &&
4871
+ (changeSet.state === "pending_push" ||
4872
+ changeSet.state === "staged_revision" ||
4873
+ changeSet.state === "approved"),
4874
+ ).length;
4875
+ }
4876
+
4336
4877
  function SourcesListView({
4337
4878
  source,
4338
4879
  sources,
4339
- builderConfigured,
4340
- builderSpaceLabel,
4341
- reviewableCount,
4342
- onOpenBuilder,
4880
+ onOpenConnectedBuilder,
4343
4881
  onOpenSecondary,
4344
4882
  onAddSource,
4345
4883
  }: {
4346
4884
  source: ContentDatabaseSource | null;
4347
4885
  sources: ContentDatabaseSource[];
4348
- builderConfigured: boolean;
4349
- builderSpaceLabel: string | null;
4350
- reviewableCount: number;
4351
- onOpenBuilder: () => void;
4886
+ onOpenConnectedBuilder: (source: ContentDatabaseSource) => void;
4352
4887
  onOpenSecondary: (source: ContentDatabaseSource) => void;
4353
4888
  onAddSource: () => void;
4354
4889
  }) {
4355
4890
  const db = useDatabaseT();
4356
- const isBuilderSource = source?.sourceType === "builder-cms";
4357
4891
  const connectedSources =
4358
4892
  sources.length > 0 ? sources : source ? [source] : [];
4359
- return (
4360
- <div className="grid min-w-0 gap-4">
4361
- {connectedSources.length === 0 ? (
4362
- <div className="min-w-0 break-words text-xs text-muted-foreground">
4363
- <DatabaseText k="thisDatabaseIsLocalConnectASourceToMapIts" />
4364
- </div>
4365
- ) : (
4366
- <div className="grid min-w-0 gap-1.5">
4367
- <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4368
- <DatabaseText k="connectedSources" />
4369
- </div>
4370
- {connectedSources.map((connected, index) => (
4371
- <DatabaseSettingsRow
4372
- key={connected.id}
4373
- icon={
4374
- connected.sourceType === "builder-cms" ? (
4375
- <BuilderLogoMark className="size-4" />
4376
- ) : (
4377
- <IconLayoutGrid className="size-4" />
4378
- )
4379
- }
4380
- label={connected.sourceName}
4381
- value={
4382
- connected.metadata.federation?.role === "secondary"
4383
- ? "Federated"
4384
- : index === 0
4385
- ? "Primary"
4386
- : undefined
4387
- }
4388
- onClick={
4389
- connected.metadata.federation?.role === "secondary"
4390
- ? () => onOpenSecondary(connected)
4391
- : connected.sourceType === "builder-cms"
4392
- ? onOpenBuilder
4393
- : undefined
4394
- }
4395
- disabled={
4396
- connected.metadata.federation?.role !== "secondary" &&
4397
- connected.sourceType !== "builder-cms"
4398
- }
4399
- />
4400
- ))}
4401
- <DatabaseSettingsRow
4402
- icon={<IconPlus className="size-4" />}
4403
- label={db("addAnotherSource")}
4404
- onClick={onAddSource}
4405
- />
4406
- </div>
4407
- )}
4408
- <div className="grid min-w-0 gap-1.5">
4409
- <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4410
- <DatabaseText k="integrations" />
4893
+
4894
+ // A brand-new database has no sources — the only action is to add one.
4895
+ // Integrations (Builder, Notion, …) live inside the "Add a source" flow.
4896
+ if (connectedSources.length === 0) {
4897
+ return (
4898
+ <div className="grid min-w-0 gap-2">
4899
+ <div className="min-w-0 break-words px-2 text-xs text-muted-foreground">
4900
+ {db("connectABuilderCollectionToMapRows")}
4411
4901
  </div>
4412
4902
  <DatabaseSettingsRow
4413
- icon={<BuilderLogoMark className="size-4" />}
4414
- label="Builder"
4415
- value={
4416
- isBuilderSource
4417
- ? (builderSpaceLabel ?? "Connected")
4418
- : builderConfigured
4419
- ? "Connected"
4420
- : undefined
4421
- }
4422
- badgeCount={reviewableCount}
4423
- onClick={onOpenBuilder}
4424
- />
4425
- <DatabaseSettingsRow
4426
- icon={<NotionLogoMark className="size-4" />}
4427
- label="Notion"
4428
- value="Coming soon"
4429
- disabled
4903
+ icon={<IconPlus className="size-4" />}
4904
+ label={db("addASource")}
4905
+ onClick={onAddSource}
4430
4906
  />
4431
4907
  </div>
4432
- <div className="grid min-w-0 gap-1.5">
4433
- <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4434
- <DatabaseText k="agentNativeApps" />
4435
- </div>
4908
+ );
4909
+ }
4910
+
4911
+ return (
4912
+ <div className="grid min-w-0 gap-1.5">
4913
+ <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4914
+ {db("connectedSources")}
4915
+ </div>
4916
+ {connectedSources.map((connected, index) => (
4436
4917
  <DatabaseSettingsRow
4437
- icon={<IconTimeline className="size-4" />}
4438
- label="Analytics"
4439
- value="Coming soon"
4440
- disabled
4918
+ key={connected.id}
4919
+ icon={
4920
+ connected.sourceType === "builder-cms" ? (
4921
+ <BuilderLogoMark className="size-4" />
4922
+ ) : (
4923
+ <IconLayoutGrid className="size-4" />
4924
+ )
4925
+ }
4926
+ label={connected.sourceName}
4927
+ value={sourceRoleLabel(db, connected, index)}
4928
+ badgeCount={
4929
+ connected.metadata.federation?.role !== "secondary" &&
4930
+ connected.sourceType === "builder-cms"
4931
+ ? reviewableCountForSource(connected)
4932
+ : 0
4933
+ }
4934
+ onClick={
4935
+ connected.metadata.federation?.role === "secondary"
4936
+ ? () => onOpenSecondary(connected)
4937
+ : connected.sourceType === "builder-cms"
4938
+ ? () => onOpenConnectedBuilder(connected)
4939
+ : undefined
4940
+ }
4941
+ disabled={
4942
+ connected.metadata.federation?.role !== "secondary" &&
4943
+ connected.sourceType !== "builder-cms"
4944
+ }
4441
4945
  />
4442
- </div>
4946
+ ))}
4947
+ <DatabaseSettingsRow
4948
+ icon={<IconPlus className="size-4" />}
4949
+ label={db("addAnotherSource")}
4950
+ onClick={onAddSource}
4951
+ />
4443
4952
  </div>
4444
4953
  );
4445
4954
  }
@@ -4458,7 +4967,7 @@ function CanonicalKeyConfirmView({
4458
4967
  candidate: PendingSourceCandidate;
4459
4968
  canEdit: boolean;
4460
4969
  pending: boolean;
4461
- onCommit: (join: ContentDatabaseSourceJoinRequest) => void;
4970
+ onCommit: (join: ContentDatabaseSourceJoinRequest) => void | Promise<void>;
4462
4971
  }) {
4463
4972
  const db = useDatabaseT();
4464
4973
  const suggestionQuery = useSuggestSourceJoinKey({
@@ -4517,7 +5026,7 @@ function CanonicalKeyConfirmView({
4517
5026
  return (
4518
5027
  <div className="flex items-center gap-2 text-xs text-muted-foreground">
4519
5028
  <Spinner className="size-3.5" />
4520
- <DatabaseText k="analyzingBothSourcesForASharedKey" />
5029
+ <DatabaseText k="checkingHowTheseRecordsMatch" />
4521
5030
  </div>
4522
5031
  );
4523
5032
  }
@@ -4526,7 +5035,7 @@ function CanonicalKeyConfirmView({
4526
5035
  return (
4527
5036
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4528
5037
  {suggestionQuery.data?.message ??
4529
- "Couldnt suggest a join key automatically."}
5038
+ "Couldn't find a match field automatically."}
4530
5039
  </div>
4531
5040
  );
4532
5041
  }
@@ -4538,7 +5047,7 @@ function CanonicalKeyConfirmView({
4538
5047
  {candidate.displayName}
4539
5048
  </div>
4540
5049
  <div className="min-w-0 break-words text-xs text-muted-foreground">
4541
- Match rows on a shared key. Suggested key:{" "}
5050
+ Match existing items to details. Suggested match:{" "}
4542
5051
  <span className="font-medium text-foreground">
4543
5052
  {suggestion.canonicalKey.label}
4544
5053
  </span>
@@ -4630,7 +5139,7 @@ function CanonicalKeyConfirmView({
4630
5139
  ) : (
4631
5140
  <IconPlugConnected className="mr-1.5 size-3.5" />
4632
5141
  )}
4633
- Confirm &amp; attach
5142
+ Add details
4634
5143
  </Button>
4635
5144
  </div>
4636
5145
  );
@@ -4641,79 +5150,448 @@ function CanonicalKeyConfirmView({
4641
5150
  function AddSourceView({
4642
5151
  excludeDatabaseIds,
4643
5152
  canEdit,
4644
- onPickLocalTable,
5153
+ builderConfigured,
5154
+ builderSpaceLabel,
5155
+ onOpenBuilder,
5156
+ onPickLocalTable,
5157
+ }: {
5158
+ excludeDatabaseIds: string[];
5159
+ canEdit: boolean;
5160
+ builderConfigured: boolean;
5161
+ builderSpaceLabel: string | null;
5162
+ onOpenBuilder: () => void;
5163
+ onPickLocalTable: (table: {
5164
+ databaseId: string;
5165
+ documentId: string;
5166
+ title: string;
5167
+ }) => void;
5168
+ }) {
5169
+ const db = useDatabaseT();
5170
+ const query = useContentDatabases({ enabled: true });
5171
+ // Exclude this database (no self-reference) and any table already federated
5172
+ // onto it — those live in the "Connected sources" group above.
5173
+ const excluded = new Set(excludeDatabaseIds);
5174
+ const tables = (query.data?.databases ?? []).filter(
5175
+ (table) => !excluded.has(table.databaseId),
5176
+ );
5177
+ return (
5178
+ <div className="grid min-w-0 gap-4">
5179
+ <div className="grid min-w-0 gap-1.5">
5180
+ <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
5181
+ <DatabaseText k="localTables" />
5182
+ </div>
5183
+ {query.isLoading ? (
5184
+ <div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
5185
+ <Spinner className="size-3.5" />
5186
+ <DatabaseText k="loadingTables" />
5187
+ </div>
5188
+ ) : tables.length === 0 ? (
5189
+ <div className="min-w-0 break-words px-2 text-xs text-muted-foreground">
5190
+ <DatabaseText k="noOtherDatabasesAvailableToAdd" />
5191
+ </div>
5192
+ ) : (
5193
+ tables.map((table) => (
5194
+ <DatabaseSettingsRow
5195
+ key={table.databaseId}
5196
+ icon={<IconLayoutGrid className="size-4" />}
5197
+ label={table.title}
5198
+ onClick={canEdit ? () => onPickLocalTable(table) : undefined}
5199
+ disabled={!canEdit}
5200
+ />
5201
+ ))
5202
+ )}
5203
+ </div>
5204
+ <div className="grid min-w-0 gap-1.5">
5205
+ <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
5206
+ <DatabaseText k="integrations" />
5207
+ </div>
5208
+ <DatabaseSettingsRow
5209
+ icon={<BuilderLogoMark className="size-4" />}
5210
+ label="Builder"
5211
+ value={
5212
+ builderSpaceLabel ?? (builderConfigured ? "Connected" : undefined)
5213
+ }
5214
+ onClick={canEdit ? onOpenBuilder : undefined}
5215
+ disabled={!canEdit}
5216
+ />
5217
+ <DatabaseSettingsRow
5218
+ icon={<NotionLogoMark className="size-4" />}
5219
+ label="Notion"
5220
+ value="Coming soon"
5221
+ disabled
5222
+ />
5223
+ </div>
5224
+ <div className="grid min-w-0 gap-1.5">
5225
+ <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
5226
+ <DatabaseText k="agentNativeApps" />
5227
+ </div>
5228
+ <DatabaseSettingsRow
5229
+ icon={<IconTimeline className="size-4" />}
5230
+ label="Analytics"
5231
+ value="Coming soon"
5232
+ disabled
5233
+ />
5234
+ </div>
5235
+ </div>
5236
+ );
5237
+ }
5238
+
5239
+ // A connected federated (secondary) source: read-only details + remove.
5240
+ function SecondarySourceLeaf({
5241
+ source,
5242
+ canEdit,
5243
+ pending,
5244
+ onAddDetails,
5245
+ onAddItems,
5246
+ onChooseFields,
5247
+ onDisconnect,
5248
+ }: {
5249
+ source: ContentDatabaseSource | null;
5250
+ canEdit: boolean;
5251
+ pending: boolean;
5252
+ onAddDetails: () => void;
5253
+ onAddItems: () => void | Promise<void>;
5254
+ onChooseFields: () => void;
5255
+ onDisconnect: () => void;
5256
+ }) {
5257
+ const db = useDatabaseT();
5258
+ if (!source) {
5259
+ return (
5260
+ <div className="min-w-0 break-words text-xs text-muted-foreground">
5261
+ <DatabaseText k="thisSourceIsNoLongerConnected" />
5262
+ </div>
5263
+ );
5264
+ }
5265
+ const federation = source.metadata.federation;
5266
+ const fieldCount = source.fields.length;
5267
+ return (
5268
+ <div className="grid min-w-0 gap-4">
5269
+ <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
5270
+ <div className="flex min-w-0 items-center justify-between gap-2">
5271
+ <span className="truncate font-medium" title={source.sourceName}>
5272
+ {source.sourceName}
5273
+ </span>
5274
+ <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
5275
+ <IconLock className="size-3" />
5276
+ <DatabaseText k="readOnly" />
5277
+ </span>
5278
+ </div>
5279
+ <div className="min-w-0 break-words text-xs text-muted-foreground">
5280
+ {`${sourceRoleLabel(db, source, 0)} · ${fieldCount} field${
5281
+ fieldCount === 1 ? "" : "s"
5282
+ }`}
5283
+ {federation?.canonicalKey?.label
5284
+ ? ` · joined on ${federation.canonicalKey.label}`
5285
+ : ""}
5286
+ </div>
5287
+ </div>
5288
+ <SourceRoleCard
5289
+ source={source}
5290
+ canEdit={canEdit}
5291
+ pending={pending}
5292
+ onAddDetails={onAddDetails}
5293
+ onAddItems={onAddItems}
5294
+ onChooseFields={onChooseFields}
5295
+ />
5296
+ {federation ? (
5297
+ <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3 text-xs">
5298
+ <div className="font-medium">
5299
+ <DatabaseText k="matchFormula" />
5300
+ </div>
5301
+ <code className="block min-w-0 break-words rounded bg-muted px-1.5 py-1 font-mono text-[11px]">
5302
+ {federation.normalizationFormula}
5303
+ </code>
5304
+ </div>
5305
+ ) : null}
5306
+ <div className="rounded-lg border border-border bg-background p-3">
5307
+ <div className="text-xs font-medium">
5308
+ <DatabaseText k="removeThisSource" />
5309
+ </div>
5310
+ <div className="mt-0.5 break-words text-xs text-muted-foreground">
5311
+ Removes the federated columns&rsquo; link to this source. Your local
5312
+ rows and columns stay.
5313
+ </div>
5314
+ <Button
5315
+ type="button"
5316
+ size="sm"
5317
+ variant="outline"
5318
+ className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
5319
+ disabled={!canEdit || pending}
5320
+ onClick={onDisconnect}
5321
+ >
5322
+ {pending ? (
5323
+ <Spinner className="mr-1 size-3.5" />
5324
+ ) : (
5325
+ <IconX className="mr-1 size-3.5" />
5326
+ )}
5327
+ Remove
5328
+ </Button>
5329
+ </div>
5330
+ </div>
5331
+ );
5332
+ }
5333
+
5334
+ function sourceAddsDetails(source: ContentDatabaseSource | null | undefined) {
5335
+ return source?.metadata.federation?.role === "secondary";
5336
+ }
5337
+
5338
+ function sourceRoleLabel(
5339
+ db: DatabaseT,
5340
+ source: ContentDatabaseSource | null | undefined,
5341
+ index: number,
5342
+ ) {
5343
+ if (sourceAddsDetails(source)) return db("addingDetails");
5344
+ return index === 0 ? db("addingItems") : db("addingItems");
5345
+ }
5346
+
5347
+ function detailsReasonText(
5348
+ db: DatabaseT,
5349
+ suggestion: SourceJoinSuggestion | null,
5350
+ ) {
5351
+ if (!suggestion) return db("recommendedWhenCollectionDescribesExistingRows");
5352
+ const percent = Math.round(suggestion.confidence * 100);
5353
+ return db("recommendedBecauseSampledRowsMatchOn", {
5354
+ field: suggestion.canonicalKey.label,
5355
+ percent,
5356
+ });
5357
+ }
5358
+
5359
+ function findDetailsSource(
5360
+ response: ContentDatabaseResponse,
5361
+ candidate: PendingSourceCandidate,
5362
+ ) {
5363
+ return (
5364
+ (response.sources ?? []).find((source) => {
5365
+ if (!sourceAddsDetails(source)) return false;
5366
+ if (candidate.existingSourceId)
5367
+ return source.id === candidate.existingSourceId;
5368
+ return (
5369
+ source.sourceType === candidate.sourceType &&
5370
+ source.sourceTable === candidate.sourceTable
5371
+ );
5372
+ }) ?? null
5373
+ );
5374
+ }
5375
+
5376
+ function SourceRelationshipChoice({
5377
+ documentId,
5378
+ candidate,
5379
+ canEdit,
5380
+ pending,
5381
+ onAddDetails,
5382
+ onAddItems,
5383
+ }: {
5384
+ documentId: string;
5385
+ candidate: PendingSourceCandidate;
5386
+ canEdit: boolean;
5387
+ pending: boolean;
5388
+ onAddDetails: () => void;
5389
+ onAddItems: () => void | Promise<void>;
5390
+ }) {
5391
+ const db = useDatabaseT();
5392
+ const suggestionQuery = useSuggestSourceJoinKey({
5393
+ documentId,
5394
+ candidateSourceType: candidate.sourceType,
5395
+ candidateSourceTable: candidate.sourceTable,
5396
+ enabled: true,
5397
+ });
5398
+ const suggestion = suggestionQuery.data?.suggestion ?? null;
5399
+ const detailsRecommended = Boolean(suggestion);
5400
+ return (
5401
+ <div className="grid min-w-0 gap-2">
5402
+ <button
5403
+ type="button"
5404
+ disabled={!canEdit || pending}
5405
+ className={cn(
5406
+ "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",
5407
+ detailsRecommended
5408
+ ? "border-foreground/30 bg-muted/30"
5409
+ : "border-border bg-background hover:bg-muted/50",
5410
+ )}
5411
+ onClick={onAddDetails}
5412
+ >
5413
+ <span className="grid min-w-0 gap-1">
5414
+ <span className="break-words text-sm font-medium leading-snug">
5415
+ <DatabaseText k="addDetailsToExistingItems" />
5416
+ </span>
5417
+ {detailsRecommended ? (
5418
+ <span className="w-fit rounded-full bg-foreground px-2 py-0.5 text-[10px] font-medium text-background">
5419
+ <DatabaseText k="recommended" />
5420
+ </span>
5421
+ ) : null}
5422
+ </span>
5423
+ <span className="break-words text-xs text-muted-foreground">
5424
+ {suggestionQuery.isLoading
5425
+ ? db("checkingForMatchingFields")
5426
+ : detailsReasonText(db, suggestion)}
5427
+ </span>
5428
+ </button>
5429
+ <button
5430
+ type="button"
5431
+ disabled={!canEdit || pending}
5432
+ 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"
5433
+ onClick={onAddItems}
5434
+ >
5435
+ <span className="truncate text-sm font-medium">
5436
+ <DatabaseText k="addMoreItemsToThisList" />
5437
+ </span>
5438
+ <span className="break-words text-xs text-muted-foreground">
5439
+ <DatabaseText k="bestWhenSameKindAdditionalRows" />
5440
+ </span>
5441
+ </button>
5442
+ </div>
5443
+ );
5444
+ }
5445
+
5446
+ function SourceRoleCard({
5447
+ source,
5448
+ canAddDetails = true,
5449
+ canEdit,
5450
+ pending,
5451
+ onAddDetails,
5452
+ onAddItems,
5453
+ onChooseFields,
4645
5454
  }: {
4646
- excludeDatabaseIds: string[];
5455
+ source: ContentDatabaseSource;
5456
+ canAddDetails?: boolean;
4647
5457
  canEdit: boolean;
4648
- onPickLocalTable: (table: {
4649
- databaseId: string;
4650
- documentId: string;
4651
- title: string;
4652
- }) => void;
5458
+ pending: boolean;
5459
+ onAddDetails: () => void;
5460
+ onAddItems: () => void | Promise<void>;
5461
+ onChooseFields: () => void;
4653
5462
  }) {
4654
5463
  const db = useDatabaseT();
4655
- const query = useContentDatabases({ enabled: true });
4656
- // Exclude this database (no self-reference) and any table already federated
4657
- // onto it — those live in the "Connected sources" group above.
4658
- const excluded = new Set(excludeDatabaseIds);
4659
- const tables = (query.data?.databases ?? []).filter(
4660
- (table) => !excluded.has(table.databaseId),
4661
- );
5464
+ const addingDetails = sourceAddsDetails(source);
4662
5465
  return (
4663
- <div className="grid min-w-0 gap-4">
4664
- <div className="grid min-w-0 gap-1.5">
4665
- <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4666
- <DatabaseText k="localTables" />
5466
+ <div className="grid min-w-0 gap-2 rounded-lg border border-border bg-background p-3">
5467
+ <div className="grid min-w-0 gap-0.5">
5468
+ <div className="text-xs font-medium">
5469
+ <DatabaseText k="sourceRole" />
4667
5470
  </div>
4668
- {query.isLoading ? (
4669
- <div className="flex items-center gap-2 px-2 text-xs text-muted-foreground">
4670
- <Spinner className="size-3.5" />
4671
- <DatabaseText k="loadingTables" />
4672
- </div>
4673
- ) : tables.length === 0 ? (
4674
- <div className="min-w-0 break-words px-2 text-xs text-muted-foreground">
4675
- <DatabaseText k="noOtherDatabasesAvailableToAdd" />
4676
- </div>
5471
+ <div className="break-words text-xs text-muted-foreground">
5472
+ {addingDetails
5473
+ ? db("addingDetailsMatchedOn", {
5474
+ field:
5475
+ source.metadata.federation?.canonicalKey?.label ??
5476
+ db("aField"),
5477
+ })
5478
+ : db("addingItemsAsRows")}
5479
+ </div>
5480
+ </div>
5481
+ <div className="flex min-w-0 flex-wrap gap-2">
5482
+ {addingDetails ? (
5483
+ <>
5484
+ <Button
5485
+ type="button"
5486
+ size="sm"
5487
+ variant="outline"
5488
+ className="h-8 text-xs"
5489
+ disabled={!canEdit || pending}
5490
+ onClick={onChooseFields}
5491
+ >
5492
+ <DatabaseText k="chooseFields" />
5493
+ </Button>
5494
+ <Button
5495
+ type="button"
5496
+ size="sm"
5497
+ variant="outline"
5498
+ className="h-8 text-xs"
5499
+ disabled={!canEdit || pending}
5500
+ onClick={onAddItems}
5501
+ >
5502
+ <DatabaseText k="addAsItems" />
5503
+ </Button>
5504
+ </>
5505
+ ) : canAddDetails ? (
5506
+ <Button
5507
+ type="button"
5508
+ size="sm"
5509
+ variant="outline"
5510
+ className="h-8 text-xs"
5511
+ disabled={!canEdit || pending}
5512
+ onClick={onAddDetails}
5513
+ >
5514
+ <DatabaseText k="addDetailsInstead" />
5515
+ </Button>
4677
5516
  ) : (
4678
- tables.map((table) => (
4679
- <DatabaseSettingsRow
4680
- key={table.databaseId}
4681
- icon={<IconLayoutGrid className="size-4" />}
4682
- label={table.title}
4683
- onClick={canEdit ? () => onPickLocalTable(table) : undefined}
4684
- disabled={!canEdit}
4685
- />
4686
- ))
5517
+ <div className="break-words text-xs text-muted-foreground">
5518
+ <DatabaseText k="addAnotherItemSourceBeforeChangingToDetails" />
5519
+ </div>
4687
5520
  )}
4688
5521
  </div>
4689
- <div className="grid min-w-0 gap-1.5">
4690
- <div className="px-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
4691
- <DatabaseText k="integrations" />
4692
- </div>
4693
- <DatabaseSettingsRow
4694
- icon={<NotionLogoMark className="size-4" />}
4695
- label="Notion"
4696
- value="Coming soon"
4697
- disabled
4698
- />
4699
- </div>
4700
5522
  </div>
4701
5523
  );
4702
5524
  }
4703
5525
 
4704
- // A connected federated (secondary) source: read-only details + remove.
4705
- function SecondarySourceLeaf({
5526
+ const DETAIL_FIELD_NAME_HINTS = [
5527
+ "name",
5528
+ "title",
5529
+ "bio",
5530
+ "description",
5531
+ "image",
5532
+ "photo",
5533
+ "avatar",
5534
+ "url",
5535
+ "handle",
5536
+ "email",
5537
+ ];
5538
+
5539
+ function shouldPreselectDetailField(
5540
+ field: ContentDatabaseSource["fields"][number],
5541
+ ) {
5542
+ const key = `${field.sourceFieldKey} ${field.sourceFieldLabel}`.toLowerCase();
5543
+ if (
5544
+ field.propertyId ||
5545
+ field.mappingType === "system" ||
5546
+ field.mappingType === "title"
5547
+ ) {
5548
+ return false;
5549
+ }
5550
+ if (/(\b|\.|_)(id|created|updated|published|rev)(\b|\.|_)/i.test(key)) {
5551
+ return false;
5552
+ }
5553
+ return DETAIL_FIELD_NAME_HINTS.some((hint) => key.includes(hint));
5554
+ }
5555
+
5556
+ function SourceDetailsFieldPicker({
5557
+ documentId,
4706
5558
  source,
4707
5559
  canEdit,
4708
5560
  pending,
4709
- onDisconnect,
5561
+ onDone,
4710
5562
  }: {
5563
+ documentId: string;
4711
5564
  source: ContentDatabaseSource | null;
4712
5565
  canEdit: boolean;
4713
5566
  pending: boolean;
4714
- onDisconnect: () => void;
5567
+ onDone: () => void;
4715
5568
  }) {
4716
5569
  const db = useDatabaseT();
5570
+ const addField = useAddContentDatabaseSourceFieldProperty(documentId);
5571
+ const fields = useMemo(
5572
+ () =>
5573
+ (source?.fields ?? []).filter(
5574
+ (field) =>
5575
+ !field.propertyId &&
5576
+ field.mappingType !== "system" &&
5577
+ field.mappingType !== "title",
5578
+ ),
5579
+ [source],
5580
+ );
5581
+ const defaultSelectedIds = useMemo(
5582
+ () =>
5583
+ fields
5584
+ .filter(shouldPreselectDetailField)
5585
+ .slice(0, 8)
5586
+ .map((field) => field.id),
5587
+ [fields],
5588
+ );
5589
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
5590
+
5591
+ useEffect(() => {
5592
+ setSelectedIds(defaultSelectedIds);
5593
+ }, [defaultSelectedIds]);
5594
+
4717
5595
  if (!source) {
4718
5596
  return (
4719
5597
  <div className="min-w-0 break-words text-xs text-muted-foreground">
@@ -4721,59 +5599,106 @@ function SecondarySourceLeaf({
4721
5599
  </div>
4722
5600
  );
4723
5601
  }
4724
- const federation = source.metadata.federation;
4725
- const fieldCount = source.fields.length;
5602
+
5603
+ const selected = new Set(selectedIds);
5604
+ const toggleField = (fieldId: string) => {
5605
+ setSelectedIds((current) =>
5606
+ current.includes(fieldId)
5607
+ ? current.filter((id) => id !== fieldId)
5608
+ : [...current, fieldId],
5609
+ );
5610
+ };
5611
+ const addSelected = async () => {
5612
+ const sourceFieldIds = fields
5613
+ .filter((field) => selected.has(field.id))
5614
+ .map((field) => field.id);
5615
+ if (sourceFieldIds.length === 0) {
5616
+ onDone();
5617
+ return;
5618
+ }
5619
+ try {
5620
+ for (const sourceFieldId of sourceFieldIds) {
5621
+ await addField.mutateAsync({ documentId, sourceFieldId });
5622
+ }
5623
+ toast.success(db("detailFieldsAdded"), {
5624
+ description:
5625
+ sourceFieldIds.length === 1
5626
+ ? db("addedOneFieldFromSource")
5627
+ : db("addedFieldsFromSource", { count: sourceFieldIds.length }),
5628
+ });
5629
+ onDone();
5630
+ } catch (error) {
5631
+ toast.error(db("fieldsWereNotAdded"), {
5632
+ description: error instanceof Error ? error.message : db("tryAgain"),
5633
+ });
5634
+ }
5635
+ };
5636
+
4726
5637
  return (
4727
- <div className="grid min-w-0 gap-4">
4728
- <div className="grid min-w-0 gap-1.5 rounded-lg border border-border bg-background p-3 text-sm">
4729
- <div className="flex min-w-0 items-center justify-between gap-2">
4730
- <span className="truncate font-medium" title={source.sourceName}>
4731
- {source.sourceName}
4732
- </span>
4733
- <span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-border px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
4734
- <IconLock className="size-3" />
4735
- <DatabaseText k="readOnly" />
4736
- </span>
5638
+ <div className="grid min-w-0 gap-3">
5639
+ <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3">
5640
+ <div className="truncate text-sm font-medium" title={source.sourceName}>
5641
+ {source.sourceName}
4737
5642
  </div>
4738
- <div className="min-w-0 break-words text-xs text-muted-foreground">
4739
- {`Federated · ${fieldCount} field${fieldCount === 1 ? "" : "s"}`}
4740
- {federation?.canonicalKey?.label
4741
- ? ` · joined on ${federation.canonicalKey.label}`
4742
- : ""}
5643
+ <div className="break-words text-xs text-muted-foreground">
5644
+ <DatabaseText k="pickDetailsBecomeColumns" />
4743
5645
  </div>
4744
5646
  </div>
4745
- {federation ? (
4746
- <div className="grid min-w-0 gap-1 rounded-lg border border-border bg-background p-3 text-xs">
4747
- <div className="font-medium">
4748
- <DatabaseText k="matchFormula" />
4749
- </div>
4750
- <code className="block min-w-0 break-words rounded bg-muted px-1.5 py-1 font-mono text-[11px]">
4751
- {federation.normalizationFormula}
4752
- </code>
4753
- </div>
4754
- ) : null}
4755
- <div className="rounded-lg border border-border bg-background p-3">
4756
- <div className="text-xs font-medium">
4757
- <DatabaseText k="removeThisSource" />
5647
+ {fields.length === 0 ? (
5648
+ <div className="break-words text-xs text-muted-foreground">
5649
+ <DatabaseText k="allAvailableDetailFieldsAlreadyVisible" />
4758
5650
  </div>
4759
- <div className="mt-0.5 break-words text-xs text-muted-foreground">
4760
- Removes the federated columns&rsquo; link to this source. Your local
4761
- rows and columns stay.
5651
+ ) : (
5652
+ <div className="grid min-w-0 gap-1">
5653
+ {fields.map((field) => (
5654
+ <button
5655
+ key={field.id}
5656
+ type="button"
5657
+ 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"
5658
+ onClick={() => toggleField(field.id)}
5659
+ >
5660
+ <span
5661
+ className={cn(
5662
+ "flex size-4 shrink-0 items-center justify-center rounded border",
5663
+ selected.has(field.id)
5664
+ ? "border-[#2383e2] bg-[#2383e2] text-white"
5665
+ : "border-muted-foreground/40 text-transparent",
5666
+ )}
5667
+ >
5668
+ <IconCheck className="size-3" />
5669
+ </span>
5670
+ <span className="min-w-0 flex-1 truncate">
5671
+ {field.sourceFieldLabel}
5672
+ </span>
5673
+ <span className="shrink-0 text-[11px] text-muted-foreground">
5674
+ {field.sourceFieldType}
5675
+ </span>
5676
+ </button>
5677
+ ))}
4762
5678
  </div>
5679
+ )}
5680
+ <div className="flex justify-end gap-2">
4763
5681
  <Button
4764
5682
  type="button"
4765
5683
  size="sm"
4766
5684
  variant="outline"
4767
- className="mt-2 h-8 text-xs text-destructive hover:text-destructive"
4768
- disabled={!canEdit || pending}
4769
- onClick={onDisconnect}
5685
+ disabled={addField.isPending}
5686
+ onClick={onDone}
4770
5687
  >
4771
- {pending ? (
4772
- <Spinner className="mr-1 size-3.5" />
5688
+ Done
5689
+ </Button>
5690
+ <Button
5691
+ type="button"
5692
+ size="sm"
5693
+ disabled={!canEdit || pending || addField.isPending}
5694
+ onClick={addSelected}
5695
+ >
5696
+ {addField.isPending ? (
5697
+ <Spinner className="mr-1.5 size-3.5" />
4773
5698
  ) : (
4774
- <IconX className="mr-1 size-3.5" />
5699
+ <IconPlus className="mr-1.5 size-3.5" />
4775
5700
  )}
4776
- Remove
5701
+ Add selected
4777
5702
  </Button>
4778
5703
  </div>
4779
5704
  </div>
@@ -4783,12 +5708,13 @@ function SecondarySourceLeaf({
4783
5708
  // A Builder space's data models, as drill-in rows. The attached model (if any)
4784
5709
  // is marked; selecting a row opens that model's leaf.
4785
5710
  function BuilderSpaceModelsView({
4786
- attachedModelName,
5711
+ attachedModelNames,
4787
5712
  onOpenModel,
4788
5713
  }: {
4789
- attachedModelName: string | null;
5714
+ attachedModelNames: string[];
4790
5715
  onOpenModel: (model: BuilderCmsModelSummary) => void;
4791
5716
  }) {
5717
+ const attachedModelNameSet = new Set(attachedModelNames);
4792
5718
  const db = useDatabaseT();
4793
5719
  const modelsQuery = useBuilderCmsModels(true);
4794
5720
  const models = modelsQuery.data?.models ?? [];
@@ -4860,15 +5786,15 @@ function BuilderSpaceModelsView({
4860
5786
  model.displayName.toLowerCase().includes(normalizedQuery) ||
4861
5787
  model.name.toLowerCase().includes(normalizedQuery);
4862
5788
  const filtered = models.filter(matchesQuery);
4863
- const attachedModels = filtered.filter(
4864
- (model) => attachedModelName === model.name,
5789
+ const attachedModels = filtered.filter((model) =>
5790
+ attachedModelNameSet.has(model.name),
4865
5791
  );
4866
5792
  const otherModels = filtered.filter(
4867
- (model) => attachedModelName !== model.name,
5793
+ (model) => !attachedModelNameSet.has(model.name),
4868
5794
  );
4869
5795
 
4870
5796
  const renderRow = (model: BuilderCmsModelSummary) => {
4871
- const isAttached = attachedModelName === model.name;
5797
+ const isAttached = attachedModelNameSet.has(model.name);
4872
5798
  return (
4873
5799
  <button
4874
5800
  key={model.id}
@@ -4933,12 +5859,38 @@ function BuilderSpaceModelsView({
4933
5859
  );
4934
5860
  }
4935
5861
 
5862
+ const BUILDER_DEV_EFFECT_LABEL: Record<
5863
+ BuilderCmsWriteEffect,
5864
+ { pending: string; done: string }
5865
+ > = {
5866
+ create_draft: { pending: "Create draft", done: "Created draft" },
5867
+ update_in_place: { pending: "Update entry", done: "Updated entry" },
5868
+ autosave: { pending: "Save draft", done: "Saved draft" },
5869
+ publish: { pending: "Publish", done: "Published" },
5870
+ unpublish: { pending: "Unpublish", done: "Unpublished" },
5871
+ };
5872
+
5873
+ function changeSetDisplayTitle(changeSet: ContentDatabaseSourceChangeSet) {
5874
+ const titleChange = changeSet.fieldChanges.find(
5875
+ (field) => field.localFieldKey === "title",
5876
+ );
5877
+ if (
5878
+ typeof titleChange?.proposedValue === "string" &&
5879
+ titleChange.proposedValue.trim()
5880
+ ) {
5881
+ return titleChange.proposedValue.trim();
5882
+ }
5883
+ return changeSet.summary;
5884
+ }
5885
+
4936
5886
  function SourceChangeSetReviewCard({
4937
5887
  changeSet,
4938
5888
  source,
5889
+ showDetails = false,
4939
5890
  }: {
4940
5891
  changeSet: ContentDatabaseSourceChangeSet;
4941
5892
  source: ContentDatabaseSource;
5893
+ showDetails?: boolean;
4942
5894
  }) {
4943
5895
  const db = useDatabaseT();
4944
5896
  const latestReview =
@@ -4948,6 +5900,49 @@ function SourceChangeSetReviewCard({
4948
5900
  const dryRunStatus = latestExecution
4949
5901
  ? builderExecutionDryRunStatus(latestExecution.payload)
4950
5902
  : null;
5903
+ const effect = resolveBuilderCmsWriteEffect({ source, changeSet });
5904
+ const effectLabel = BUILDER_DEV_EFFECT_LABEL[effect];
5905
+ const title = changeSetDisplayTitle(changeSet);
5906
+
5907
+ // A change that already pushed is done — collapse it to a one-line
5908
+ // confirmation instead of repeating the full diff + gate ceremony.
5909
+ if (changeSet.state === "applied") {
5910
+ // Report the SUCCEEDED execution (a later blocked/stale gate row could
5911
+ // otherwise overwrite the pushed result). Use the effect that actually ran
5912
+ // (recorded in the payload) — the live resolver would now report
5913
+ // update_in_place because the row is matched to its new entry, mislabeling
5914
+ // a completed create as an update.
5915
+ const succeededExecution =
5916
+ [...changeSet.executions]
5917
+ .reverse()
5918
+ .find((execution) => execution.state === "succeeded") ??
5919
+ latestExecution;
5920
+ const appliedEffect =
5921
+ (succeededExecution &&
5922
+ builderExecutionEffect(succeededExecution.payload)) ??
5923
+ effect;
5924
+ const when =
5925
+ formatRelativeSyncTime(
5926
+ succeededExecution?.updatedAt ?? changeSet.updatedAt,
5927
+ ) ?? formatSourceTimestamp(changeSet.updatedAt);
5928
+ return (
5929
+ <div className="flex min-w-0 items-start gap-2 rounded-md border border-border/70 px-2 py-1.5 text-xs">
5930
+ <IconCheck className="mt-0.5 size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
5931
+ <div className="min-w-0">
5932
+ <div className="min-w-0 break-words font-medium leading-snug">
5933
+ {title}
5934
+ </div>
5935
+ <div className="text-muted-foreground">
5936
+ {BUILDER_DEV_EFFECT_LABEL[appliedEffect].done} · pushed {when}
5937
+ </div>
5938
+ </div>
5939
+ </div>
5940
+ );
5941
+ }
5942
+
5943
+ const blockers = latestExecution
5944
+ ? builderExecutionBlockers(latestExecution.payload)
5945
+ : [];
4951
5946
 
4952
5947
  return (
4953
5948
  <div className="min-w-0 rounded-md border border-border/70 px-2 py-1.5">
@@ -4956,128 +5951,93 @@ function SourceChangeSetReviewCard({
4956
5951
  className="min-w-0 break-words font-medium leading-snug"
4957
5952
  title={changeSet.summary}
4958
5953
  >
4959
- {changeSet.summary}
4960
- </span>
4961
- <span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
4962
- {changeSet.state.replace(/_/g, " ")}
4963
- </span>
4964
- </div>
4965
-
4966
- <div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
4967
- <span className={sourceRiskClass(changeSet.riskLevel)}>
4968
- {changeSet.riskLevel} risk
4969
- </span>
4970
- <span className="rounded border border-border px-1.5 py-0.5 text-muted-foreground">
4971
- {changeSet.conflictState === "source_changed"
4972
- ? "source changed"
4973
- : "no conflict"}
4974
- </span>
4975
- <span className="rounded border border-border px-1.5 py-0.5 text-muted-foreground">
4976
- {sourcePushModeLabel(changeSet.pushMode)}
5954
+ {title}
4977
5955
  </span>
4978
- <span className="rounded border border-border px-1.5 py-0.5 text-muted-foreground">
4979
- {changeSet.localOnly ? "local-only" : "external write"}
5956
+ <span className="shrink-0 rounded border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] text-muted-foreground">
5957
+ {effectLabel.pending}
4980
5958
  </span>
4981
5959
  </div>
4982
5960
 
4983
- <div className="mt-2 grid min-w-0 gap-1.5">
4984
- {changeSet.fieldChanges.slice(0, 3).map((field) => (
4985
- <div
4986
- key={`${changeSet.id}-${field.localFieldKey}`}
4987
- className="min-w-0 rounded border border-border/60 bg-muted/20 p-1.5 text-xs"
4988
- >
4989
- <div className="font-medium">
4990
- {field.propertyName ?? field.sourceFieldKey}
4991
- </div>
4992
- <div className="mt-1 grid min-w-0 gap-1 text-muted-foreground">
4993
- <div className="min-w-0 break-words">
4994
- Current: {sourceValueText(field.currentValue)}
4995
- </div>
4996
- <div className="min-w-0 break-words">
4997
- Proposed: {sourceValueText(field.proposedValue)}
4998
- </div>
4999
- </div>
5000
- </div>
5001
- ))}
5002
- {changeSet.fieldChanges.length > 3 ? (
5003
- <div className="text-xs text-muted-foreground">
5004
- +{changeSet.fieldChanges.length - 3} more field changes
5005
- </div>
5006
- ) : null}
5007
- {changeSet.bodyChange ? (
5008
- <div className="rounded border border-border/60 bg-muted/20 p-1.5 text-xs">
5009
- <div className="font-medium">{changeSet.bodyChange.summary}</div>
5010
- <div className="mt-1 text-muted-foreground">
5011
- <DatabaseText k="bodyDiff" />
5012
- </div>
5013
- </div>
5014
- ) : null}
5015
- </div>
5961
+ {changeSet.riskLevel !== "low" ||
5962
+ changeSet.conflictState === "source_changed" ||
5963
+ !changeSet.localOnly ? (
5964
+ <div className="mt-2 flex flex-wrap gap-1.5 text-[11px]">
5965
+ {changeSet.riskLevel !== "low" ? (
5966
+ <span className={sourceRiskClass(changeSet.riskLevel)}>
5967
+ {changeSet.riskLevel} risk
5968
+ </span>
5969
+ ) : null}
5970
+ {changeSet.conflictState === "source_changed" ? (
5971
+ <span className="rounded border border-amber-300 bg-amber-50 px-1.5 py-0.5 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
5972
+ source changed
5973
+ </span>
5974
+ ) : null}
5975
+ {!changeSet.localOnly ? (
5976
+ <span className="rounded border border-border px-1.5 py-0.5 text-muted-foreground">
5977
+ external write
5978
+ </span>
5979
+ ) : null}
5980
+ </div>
5981
+ ) : null}
5016
5982
 
5017
- <div className="mt-2 break-words text-xs text-muted-foreground">
5018
- {changeSet.riskReasons.join(", ")}
5019
- {" "}
5020
- {source.capabilities.liveWritesEnabled
5021
- ? "live writes enabled"
5022
- : "live writes disabled"}
5023
- {" • "}
5024
- {formatSourceTimestamp(changeSet.updatedAt)}
5983
+ <div className="mt-1.5 text-xs text-muted-foreground">
5984
+ {changeSet.fieldChanges.length} field change
5985
+ {changeSet.fieldChanges.length === 1 ? "" : "s"}
5986
+ {changeSet.bodyChange ? " · body diff" : ""} · “Review changes” to see
5987
+ the diff
5025
5988
  </div>
5026
5989
 
5027
- {latestReview ? (
5028
- <div className="mt-2 rounded border border-border/60 bg-muted/20 p-1.5 text-xs text-muted-foreground">
5029
- {latestReview.decision} by {latestReview.reviewerEmail}
5030
- {" "}
5031
- {formatSourceTimestamp(latestReview.createdAt)}
5990
+ {blockers.length > 0 ? (
5991
+ <div className="mt-2 grid gap-1 text-xs text-amber-700 dark:text-amber-300">
5992
+ {blockers.slice(0, 2).map((blocker) => (
5993
+ <div key={blocker} className="break-words">
5994
+ Blocked: {blocker}
5995
+ </div>
5996
+ ))}
5997
+ </div>
5998
+ ) : null}
5999
+ {latestExecution?.lastError ? (
6000
+ <div className="mt-2 break-words text-xs text-destructive">
6001
+ {latestExecution.lastError}
5032
6002
  </div>
5033
6003
  ) : null}
5034
6004
 
5035
- {latestExecution ? (
5036
- <div className="mt-2 rounded border border-border/60 bg-muted/20 p-1.5 text-xs">
5037
- <div className="flex min-w-0 items-center justify-between gap-2">
5038
- <span className="font-medium">
5039
- <DatabaseText k="executionGate" />
5040
- </span>
5041
- <span className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground">
5042
- {latestExecution.state.replace(/_/g, " ")}
5043
- </span>
5044
- </div>
5045
- <div className="mt-1 break-words text-muted-foreground">
5046
- {latestExecution.summary}
5047
- </div>
5048
- {builderExecutionRequestLine(latestExecution.payload) ? (
5049
- <div className="mt-1 break-words text-muted-foreground">
5050
- Would call {builderExecutionRequestLine(latestExecution.payload)}
6005
+ {showDetails ? (
6006
+ <details className="mt-2 text-xs text-muted-foreground">
6007
+ <summary className="cursor-pointer text-[11px] hover:text-foreground">
6008
+ Details
6009
+ </summary>
6010
+ <div className="mt-1 grid gap-1">
6011
+ <div className="break-words">
6012
+ {changeSet.riskReasons.join(", ")}
6013
+ {" • "}
6014
+ {formatSourceTimestamp(changeSet.updatedAt)}
5051
6015
  </div>
5052
- ) : null}
5053
- {builderExecutionBlockers(latestExecution.payload).length > 0 ? (
5054
- <div className="mt-1 grid gap-1 text-muted-foreground">
5055
- {builderExecutionBlockers(latestExecution.payload)
5056
- .slice(0, 2)
5057
- .map((blocker) => (
5058
- <div key={blocker} className="break-words">
5059
- Blocked: {blocker}
6016
+ {latestReview ? (
6017
+ <div className="break-words">
6018
+ {latestReview.decision} by {latestReview.reviewerEmail}
6019
+ {" • "}
6020
+ {formatSourceTimestamp(latestReview.createdAt)}
6021
+ </div>
6022
+ ) : null}
6023
+ {latestExecution ? (
6024
+ <>
6025
+ <div className="break-words">
6026
+ Execution gate: {latestExecution.state.replace(/_/g, " ")}
6027
+ {dryRunStatus ? ` · dry run ${dryRunStatus.status}` : ""}
6028
+ </div>
6029
+ {builderExecutionRequestLine(latestExecution.payload) ? (
6030
+ <div className="break-words">
6031
+ {builderExecutionRequestLine(latestExecution.payload)}
5060
6032
  </div>
5061
- ))}
5062
- </div>
5063
- ) : null}
5064
- {dryRunStatus ? (
5065
- <div className="mt-1 break-words text-muted-foreground">
5066
- Dry run {dryRunStatus.status}
5067
- {dryRunStatus.validatedAt
5068
- ? ` • ${formatSourceTimestamp(dryRunStatus.validatedAt)}`
5069
- : ""}
5070
- </div>
5071
- ) : null}
5072
- {latestExecution.lastError ? (
5073
- <div className="mt-1 break-words text-destructive">
5074
- {latestExecution.lastError}
5075
- </div>
5076
- ) : null}
5077
- <div className="mt-1 break-all text-muted-foreground">
5078
- {latestExecution.idempotencyKey}
6033
+ ) : null}
6034
+ <div className="break-all">
6035
+ {latestExecution.idempotencyKey}
6036
+ </div>
6037
+ </>
6038
+ ) : null}
5079
6039
  </div>
5080
- </div>
6040
+ </details>
5081
6041
  ) : null}
5082
6042
  </div>
5083
6043
  );
@@ -5142,6 +6102,19 @@ function builderExecutionRequestLine(payload: Record<string, unknown>) {
5142
6102
  return `${method} ${path}${queryText ? `?${queryText}` : ""}`;
5143
6103
  }
5144
6104
 
6105
+ function builderExecutionEffect(
6106
+ payload: Record<string, unknown>,
6107
+ ): BuilderCmsWriteEffect | null {
6108
+ const effect = payload.effect;
6109
+ return effect === "create_draft" ||
6110
+ effect === "update_in_place" ||
6111
+ effect === "autosave" ||
6112
+ effect === "publish" ||
6113
+ effect === "unpublish"
6114
+ ? effect
6115
+ : null;
6116
+ }
6117
+
5145
6118
  function builderExecutionBlockers(payload: Record<string, unknown>) {
5146
6119
  const safety =
5147
6120
  payload.safety &&
@@ -5204,15 +6177,6 @@ function sourceBuilderReadModeSummary(source: ContentDatabaseSource) {
5204
6177
  return "Local fixture";
5205
6178
  }
5206
6179
 
5207
- function sourcePushModeLabel(
5208
- mode: ContentDatabaseSource["metadata"]["pushMode"] | null | undefined,
5209
- ) {
5210
- if (mode === "autosave") return "Save revision / autosave";
5211
- if (mode === "draft") return "Draft";
5212
- if (mode === "publish") return "Publish";
5213
- return "No push";
5214
- }
5215
-
5216
6180
  function sourceFieldMappingForColumn(
5217
6181
  source: ContentDatabaseSource | null,
5218
6182
  columnKey: ColumnKey,
@@ -11370,6 +12334,7 @@ function DatabasePropertyHeader({
11370
12334
  property,
11371
12335
  documentId,
11372
12336
  source,
12337
+ sources,
11373
12338
  canEdit,
11374
12339
  isDragging,
11375
12340
  dropSide,
@@ -11381,6 +12346,7 @@ function DatabasePropertyHeader({
11381
12346
  property: DocumentProperty;
11382
12347
  documentId: string;
11383
12348
  source: ContentDatabaseSource | null;
12349
+ sources: ContentDatabaseSource[];
11384
12350
  canEdit: boolean;
11385
12351
  isDragging: boolean;
11386
12352
  dropSide: DatabaseDropSide | null;
@@ -11427,6 +12393,7 @@ function DatabasePropertyHeader({
11427
12393
  property.definition.id,
11428
12394
  )}
11429
12395
  sourceAttached={!!source}
12396
+ sources={sources}
11430
12397
  />
11431
12398
  ) : (
11432
12399
  <div className="flex h-7 min-w-0 flex-1 items-center gap-2 px-1">