@growthub/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
  2. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
  3. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
  4. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
  5. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
  17. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
  18. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
  19. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
  20. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
  21. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
  22. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
  23. package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
  24. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
  25. package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
  26. package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
  27. package/dist/index.js +1416 -2627
  28. package/package.json +1 -1
@@ -43,12 +43,16 @@ import {
43
43
  Zap,
44
44
  } from "lucide-react";
45
45
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
46
+ import { HelperSidecar } from "./HelperSidecar.jsx";
47
+ import { WorkspaceRail } from "../../workspace-rail.jsx";
48
+ import { useRouter, useSearchParams } from "next/navigation";
46
49
  import {
47
50
  OBJECT_TYPE_PRESETS,
48
51
  addTableField,
49
52
  addTableRow,
50
53
  appendRowsToTable,
51
54
  createTypedBusinessObject,
55
+ deleteTableRow,
52
56
  describeBindingLane,
53
57
  effectiveRelations,
54
58
  exportTableAsCsv,
@@ -301,8 +305,8 @@ function ObjectViewPicker({ tables, selectedTable, saving, onSelectSource, onSav
301
305
  {favoriteObjects.length > 0 && (
302
306
  <div className="dm-picker-section">
303
307
  <p>Favorites</p>
304
- {favoriteObjects.map((table) => (
305
- <button key={`favorite-${table.source}`} type="button" className="dm-picker-row" onClick={() => onSelectSource(table.source)}>
308
+ {favoriteObjects.map((table, favIdx) => (
309
+ <button key={`favorite-${table.id || table.source}-${favIdx}`} type="button" className="dm-picker-row" onClick={() => onSelectSource(table.source)}>
306
310
  <Pin size={14} />
307
311
  <span>{table.label}</span>
308
312
  </button>
@@ -310,9 +314,13 @@ function ObjectViewPicker({ tables, selectedTable, saving, onSelectSource, onSav
310
314
  </div>
311
315
  )}
312
316
  <div className="dm-picker-tabs">
313
- {["all", "objects", "views"].map((item) => (
314
- <button key={item} type="button" className={mode === item ? "active" : ""} onClick={() => setMode(item)}>
315
- {item}
317
+ {[
318
+ { id: "all", label: "All" },
319
+ { id: "objects", label: "Objects" },
320
+ { id: "views", label: "Views" },
321
+ ].map((item) => (
322
+ <button key={item.id} type="button" className={mode === item.id ? "active" : ""} onClick={() => setMode(item.id)}>
323
+ {item.label}
316
324
  </button>
317
325
  ))}
318
326
  </div>
@@ -320,8 +328,8 @@ function ObjectViewPicker({ tables, selectedTable, saving, onSelectSource, onSav
320
328
  <div className="dm-picker-section">
321
329
  <p>Objects</p>
322
330
  <div className="dm-picker-scroll">
323
- {objects.map((table) => (
324
- <div key={table.source} className={`dm-picker-item${selectedTable?.source === table.source ? " active" : ""}`}>
331
+ {objects.map((table, objIdx) => (
332
+ <div key={`${table.id || table.source}:${objIdx}`} className={`dm-picker-item${selectedTable?.source === table.source ? " active" : ""}`}>
325
333
  <button type="button" className="dm-picker-row" onClick={() => {
326
334
  onSelectSource(table.source);
327
335
  setOpen(false);
@@ -413,36 +421,9 @@ function SaveToast({ saving, message }) {
413
421
  return <span className={`dm-toast ${message.startsWith("Error") ? "error" : "ok"}`}>{message}</span>;
414
422
  }
415
423
 
416
- function NavRail({ authority, workspaceConfig }) {
417
- const branding = workspaceConfig?.branding || {};
418
- const workspaceName = branding.name || workspaceConfig?.name || "Growthub Workspace";
419
- return (
420
- <aside className="workspace-rail" aria-label="Workspace navigation">
421
- <div className="workspace-brand">
422
- <span
423
- className="workspace-mark"
424
- style={{
425
- background: branding.logoUrl ? undefined : branding.accent || undefined,
426
- color: branding.logoUrl ? undefined : textColorForAccent(branding.accent),
427
- }}
428
- >
429
- {branding.logoUrl ? <img src={branding.logoUrl} alt="" /> : workspaceName.slice(0, 1).toUpperCase()}
430
- </span>
431
- <span>{workspaceName}</span>
432
- </div>
433
- <nav className="workspace-nav">
434
- <Link href="/">Dashboards</Link>
435
- <Link className="active" href="/data-model">Data Model</Link>
436
- <span className="workspace-nav-static">Management</span>
437
- <Link className="workspace-nav-bottom" href="/settings/general">Workspace Settings</Link>
438
- </nav>
439
- <div className="workspace-rail-status">
440
- <span className="status-dot" />
441
- {authority || "local-catalog"}
442
- </div>
443
- </aside>
444
- );
445
- }
424
+ // NavRail extracted to `app/workspace-rail.jsx` (shared across all
425
+ // governed-workspace pages). The legacy local definition has been
426
+ // removed every surface now renders <WorkspaceRail />.
446
427
 
447
428
  // ─── Object list (sidebar lives in ./ObjectSidebar.jsx) ───────────────────────
448
429
 
@@ -1388,7 +1369,7 @@ function DataModelRecordDrawer({ table, tables, workspaceConfig, rowIndex, row,
1388
1369
  );
1389
1370
  }
1390
1371
 
1391
- function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave }) {
1372
+ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave, onOpenThread }) {
1392
1373
  const [selectedRow, setSelectedRow] = useState(null);
1393
1374
  const [fieldName, setFieldName] = useState("");
1394
1375
  const [fieldType, setFieldType] = useState("text");
@@ -1399,10 +1380,23 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1399
1380
  const [filterDraft, setFilterDraft] = useState({ fieldId: "", operator: "eq", value: "" });
1400
1381
  const [filterTarget, setFilterTarget] = useState("");
1401
1382
  const [menuColumn, setMenuColumn] = useState("");
1383
+ const [selectedRows, setSelectedRows] = useState(() => new Set());
1384
+ const [confirmDeleteSelection, setConfirmDeleteSelection] = useState(false);
1385
+ const [lastSelectedRowIndex, setLastSelectedRowIndex] = useState(null);
1386
+ const [selectMenuOpen, setSelectMenuOpen] = useState(false);
1387
+ const [pageSize, setPageSize] = useState(15);
1388
+ const [pageIndex, setPageIndex] = useState(0);
1402
1389
  const fieldInputRef = useRef(null);
1403
1390
 
1404
1391
  useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
1405
- useEffect(() => { setSelectedRow(null); }, [table.id]);
1392
+ useEffect(() => {
1393
+ setSelectedRow(null);
1394
+ setSelectedRows(new Set());
1395
+ setConfirmDeleteSelection(false);
1396
+ setLastSelectedRowIndex(null);
1397
+ setSelectMenuOpen(false);
1398
+ setPageIndex(0);
1399
+ }, [table.id]);
1406
1400
  useEffect(() => {
1407
1401
  setFieldName("");
1408
1402
  setFieldType("text");
@@ -1430,6 +1424,25 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1430
1424
  () => (settings.views || []).find((view) => view.id === settings.activeViewId) || null,
1431
1425
  [settings.views, settings.activeViewId]
1432
1426
  );
1427
+ const selectedRowCount = selectedRows.size;
1428
+ const pageCount = Math.max(1, Math.ceil(rowEntries.length / pageSize));
1429
+ const safePageIndex = Math.min(pageIndex, pageCount - 1);
1430
+ const pageStart = safePageIndex * pageSize;
1431
+ const pageEntries = rowEntries.slice(pageStart, pageStart + pageSize);
1432
+ const pageEnd = Math.min(pageStart + pageSize, rowEntries.length);
1433
+ const pageSelectedCount = pageEntries.filter((entry) => selectedRows.has(entry.originalIndex)).length;
1434
+ const allPageSelected = pageEntries.length > 0 && pageSelectedCount === pageEntries.length;
1435
+
1436
+ useEffect(() => {
1437
+ setPageIndex((current) => Math.min(current, pageCount - 1));
1438
+ }, [pageCount]);
1439
+
1440
+ useEffect(() => {
1441
+ setPageIndex(0);
1442
+ setSelectedRow(null);
1443
+ setLastSelectedRowIndex(null);
1444
+ setSelectMenuOpen(false);
1445
+ }, [settings.filter, settings.sort, pageSize]);
1433
1446
 
1434
1447
  function commitField() {
1435
1448
  const name = fieldName.trim();
@@ -1540,6 +1553,78 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1540
1553
  }));
1541
1554
  }
1542
1555
 
1556
+ function toggleRowSelection(originalIndex, visibleIndex, event) {
1557
+ setConfirmDeleteSelection(false);
1558
+ setSelectMenuOpen(false);
1559
+ setSelectedRows((current) => {
1560
+ const next = new Set(current);
1561
+ if (event?.shiftKey && lastSelectedRowIndex !== null) {
1562
+ const start = Math.min(lastSelectedRowIndex, visibleIndex);
1563
+ const end = Math.max(lastSelectedRowIndex, visibleIndex);
1564
+ rowEntries.slice(start, end + 1).forEach((entry) => next.add(entry.originalIndex));
1565
+ } else if (next.has(originalIndex)) {
1566
+ next.delete(originalIndex);
1567
+ } else {
1568
+ next.add(originalIndex);
1569
+ }
1570
+ return next;
1571
+ });
1572
+ setLastSelectedRowIndex(visibleIndex);
1573
+ }
1574
+
1575
+ function clearRowSelection() {
1576
+ setSelectedRows(new Set());
1577
+ setConfirmDeleteSelection(false);
1578
+ setLastSelectedRowIndex(null);
1579
+ setSelectMenuOpen(false);
1580
+ }
1581
+
1582
+ function selectCurrentPage() {
1583
+ setConfirmDeleteSelection(false);
1584
+ setSelectedRows((current) => {
1585
+ const next = new Set(current);
1586
+ pageEntries.forEach((entry) => next.add(entry.originalIndex));
1587
+ return next;
1588
+ });
1589
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1590
+ setSelectMenuOpen(false);
1591
+ }
1592
+
1593
+ function toggleCurrentPageSelection() {
1594
+ setConfirmDeleteSelection(false);
1595
+ setSelectedRows((current) => {
1596
+ const next = new Set(current);
1597
+ if (allPageSelected) pageEntries.forEach((entry) => next.delete(entry.originalIndex));
1598
+ else pageEntries.forEach((entry) => next.add(entry.originalIndex));
1599
+ return next;
1600
+ });
1601
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1602
+ setSelectMenuOpen(false);
1603
+ }
1604
+
1605
+ function selectAllFilteredRows() {
1606
+ setConfirmDeleteSelection(false);
1607
+ setSelectedRows((current) => {
1608
+ const next = new Set(current);
1609
+ rowEntries.forEach((entry) => next.add(entry.originalIndex));
1610
+ return next;
1611
+ });
1612
+ setLastSelectedRowIndex(rowEntries.length ? 0 : null);
1613
+ setSelectMenuOpen(false);
1614
+ }
1615
+
1616
+ function deleteSelectedRows() {
1617
+ if (!selectedRows.size) return;
1618
+ if (!confirmDeleteSelection) {
1619
+ setConfirmDeleteSelection(true);
1620
+ return;
1621
+ }
1622
+ const rowIndexes = Array.from(selectedRows).sort((a, b) => b - a);
1623
+ onSave((config) => rowIndexes.reduce((nextConfig, rowIndex) => deleteTableRow(nextConfig, table, rowIndex), config));
1624
+ setSelectedRow(null);
1625
+ clearRowSelection();
1626
+ }
1627
+
1543
1628
  const selectedEntry = selectedRow === null ? null : rowEntries[selectedRow];
1544
1629
  const selectedRecord = selectedEntry?.row || null;
1545
1630
 
@@ -1553,6 +1638,11 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1553
1638
  )}
1554
1639
  <div className="dm-db-toolbar">
1555
1640
  <div className="dm-filter-chip-row">
1641
+ {selectedRowCount > 0 && (
1642
+ <span className="dm-filter-chip dm-selection-count">
1643
+ {pluralize(selectedRowCount, "record")} selected
1644
+ </span>
1645
+ )}
1556
1646
  {settings.filter?.clauses?.map((clause) => (
1557
1647
  <button key={`${clause.fieldId}:${clause.operator}`} type="button" className="dm-filter-chip" onClick={() => removeFilter(clause.fieldId)}>
1558
1648
  <LucideIcon name={FIELD_TYPE_ICON_NAMES[settings.types?.[clause.fieldId] || inferFieldType(clause.fieldId)] || "Type"} size={12} />
@@ -1562,9 +1652,24 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1562
1652
  ))}
1563
1653
  </div>
1564
1654
  <div className="dm-records-actions">
1565
- <button type="button" className="dm-btn-ghost" onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
1566
- <Filter size={13} />Filter
1567
- </button>
1655
+ <span className="dm-filter-anchor">
1656
+ <button type="button" className="dm-btn-ghost" onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
1657
+ <Filter size={13} />Filter
1658
+ </button>
1659
+ {filterTarget === "toolbar" && (
1660
+ <div className="dm-filter-popover dm-filter-popover-toolbar">
1661
+ <StaticSelect value={filterDraft.fieldId} options={visibleColumns.map((column) => ({ value: column, label: column }))} onChange={(next) => setFilterDraft((current) => ({ ...current, fieldId: next }))} />
1662
+ <StaticSelect value={filterDraft.operator} options={FILTER_OPERATOR_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} onChange={(next) => setFilterDraft((current) => ({ ...current, operator: next }))} />
1663
+ {!["isEmpty", "isNotEmpty"].includes(filterDraft.operator) && (
1664
+ <input value={filterDraft.value} placeholder="Value" onChange={(event) => setFilterDraft((current) => ({ ...current, value: event.target.value }))} />
1665
+ )}
1666
+ <div className="dm-filter-popover-actions">
1667
+ <button type="button" className="dm-btn-outline" onClick={() => setFilterTarget("")}>Cancel</button>
1668
+ <button type="button" className="dm-btn-primary-sm" onClick={applyFilter}>Apply</button>
1669
+ </div>
1670
+ </div>
1671
+ )}
1672
+ </span>
1568
1673
  {activeView ? (
1569
1674
  <button type="button" className="dm-btn-ghost" onClick={updateCurrentView}>
1570
1675
  Update view
@@ -1589,21 +1694,16 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1589
1694
  <Plus size={13} />Add record
1590
1695
  </button>
1591
1696
  )}
1592
- </div>
1593
- </div>
1594
- {filterTarget === "toolbar" && (
1595
- <div className="dm-filter-popover dm-filter-popover-toolbar">
1596
- <StaticSelect value={filterDraft.fieldId} options={visibleColumns.map((column) => ({ value: column, label: column }))} onChange={(next) => setFilterDraft((current) => ({ ...current, fieldId: next }))} />
1597
- <StaticSelect value={filterDraft.operator} options={FILTER_OPERATOR_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} onChange={(next) => setFilterDraft((current) => ({ ...current, operator: next }))} />
1598
- {!["isEmpty", "isNotEmpty"].includes(filterDraft.operator) && (
1599
- <input value={filterDraft.value} placeholder="Value" onChange={(event) => setFilterDraft((current) => ({ ...current, value: event.target.value }))} />
1697
+ {table.mutable && selectedRowCount > 0 && (
1698
+ <>
1699
+ <button type="button" className="dm-btn-ghost" disabled={saving} onClick={clearRowSelection}>Cancel selection</button>
1700
+ <button type="button" className="dm-btn-danger-sm" disabled={saving} onClick={deleteSelectedRows}>
1701
+ {confirmDeleteSelection ? `Confirm delete ${selectedRowCount}` : "Delete"}
1702
+ </button>
1703
+ </>
1600
1704
  )}
1601
- <div className="dm-filter-popover-actions">
1602
- <button type="button" className="dm-btn-outline" onClick={() => setFilterTarget("")}>Cancel</button>
1603
- <button type="button" className="dm-btn-primary-sm" onClick={applyFilter}>Apply</button>
1604
- </div>
1605
1705
  </div>
1606
- )}
1706
+ </div>
1607
1707
  {csvOpen && (
1608
1708
  <div className="dm-csv-panel">
1609
1709
  <textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
@@ -1615,10 +1715,30 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1615
1715
  </div>
1616
1716
  )}
1617
1717
  <div className="dm-db-grid-wrap">
1718
+ <div className="dm-db-grid-scroll">
1618
1719
  <table className="dm-db-grid">
1619
1720
  <thead>
1620
1721
  <tr>
1621
- <th className="dm-db-rownum">#</th>
1722
+ <th className="dm-db-rownum dm-db-rownum-head">
1723
+ {table.mutable ? (
1724
+ <div className="dm-row-select-head-wrap">
1725
+ <button type="button" className="dm-row-select dm-row-select-all" aria-label={allPageSelected ? "Clear page selection" : "Select current page"} aria-pressed={allPageSelected} onClick={(event) => { event.stopPropagation(); toggleCurrentPageSelection(); }}>
1726
+ <span className="dm-row-select-box" />
1727
+ <span className="dm-row-number">#</span>
1728
+ </button>
1729
+ <button type="button" className="dm-row-select-menu-btn" aria-label="Selection options" aria-expanded={selectMenuOpen} onClick={(event) => { event.stopPropagation(); setSelectMenuOpen((open) => !open); }}>
1730
+ <ChevronDown size={11} />
1731
+ </button>
1732
+ {selectMenuOpen && (
1733
+ <div className="dm-row-select-menu">
1734
+ <button type="button" onClick={selectCurrentPage}>Select page</button>
1735
+ <button type="button" onClick={selectAllFilteredRows}>Select all filtered</button>
1736
+ <button type="button" disabled={!selectedRowCount} onClick={clearRowSelection}>Clear selection</button>
1737
+ </div>
1738
+ )}
1739
+ </div>
1740
+ ) : "#"}
1741
+ </th>
1622
1742
  {visibleColumns.map((column) => (
1623
1743
  <th key={column}>
1624
1744
  <button type="button" className="dm-db-head-btn" onClick={() => setMenuColumn((current) => current === column ? "" : column)}>
@@ -1694,14 +1814,42 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1694
1814
  </tr>
1695
1815
  </thead>
1696
1816
  <tbody>
1697
- {rowEntries.map(({ row, originalIndex }, rowIndex) => (
1698
- <tr key={rowIndex} className={selectedRow === rowIndex ? "selected" : ""} onClick={() => setSelectedRow(rowIndex)}>
1699
- <td className="dm-db-rownum">{rowIndex + 1}</td>
1817
+ {pageEntries.map(({ row, originalIndex }, rowIndex) => {
1818
+ const visibleIndex = pageStart + rowIndex;
1819
+ const displayIndex = visibleIndex + 1;
1820
+ return (
1821
+ <tr key={`${originalIndex}:${visibleIndex}`} className={`${selectedRow === visibleIndex ? "selected" : ""}${selectedRows.has(originalIndex) ? " multi-selected" : ""}`} onClick={() => setSelectedRow(visibleIndex)}>
1822
+ <td className="dm-db-rownum">
1823
+ {table.mutable ? (
1824
+ <button type="button" className="dm-row-select" aria-label={selectedRows.has(originalIndex) ? `Deselect row ${displayIndex}` : `Select row ${displayIndex}`} aria-pressed={selectedRows.has(originalIndex)} onClick={(event) => { event.stopPropagation(); toggleRowSelection(originalIndex, visibleIndex, event); }}>
1825
+ <span className="dm-row-select-box" />
1826
+ <span className="dm-row-number">{displayIndex}</span>
1827
+ </button>
1828
+ ) : displayIndex}
1829
+ </td>
1700
1830
  {visibleColumns.map((column) => {
1701
1831
  const relation = relationForColumn(table, column);
1832
+ // The Helper Threads object is a normal custom-typed
1833
+ // governed object. We opt the "open" column into a
1834
+ // Reopen link based on the stable well-known object id
1835
+ // so we don't need a dedicated object type.
1836
+ const isHelperThreadOpenCol = table.objectId === "helper-threads" && column === "open";
1702
1837
  return (
1703
1838
  <td key={column}>
1704
- {relation ? (
1839
+ {isHelperThreadOpenCol ? (
1840
+ <button
1841
+ type="button"
1842
+ className="dm-thread-open-link"
1843
+ data-helper-thread-open=""
1844
+ data-thread-id={row?.id || ""}
1845
+ onClick={(event) => {
1846
+ event.stopPropagation();
1847
+ if (typeof onOpenThread === "function") onOpenThread(row);
1848
+ }}
1849
+ >
1850
+ <Zap size={11} />Reopen
1851
+ </button>
1852
+ ) : relation ? (
1705
1853
  <RelationPickerOrSelect
1706
1854
  table={table}
1707
1855
  tables={tables}
@@ -1721,7 +1869,7 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1721
1869
  );})}
1722
1870
  {table.mutable && <td className="dm-db-empty-cell" />}
1723
1871
  </tr>
1724
- ))}
1872
+ );})}
1725
1873
  {table.mutable && (
1726
1874
  <tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
1727
1875
  <td className="dm-db-rownum">+</td>
@@ -1730,6 +1878,24 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1730
1878
  )}
1731
1879
  </tbody>
1732
1880
  </table>
1881
+ </div>
1882
+ <div className="dm-pagination-bar">
1883
+ <span className="dm-pagination-summary">Showing {rowEntries.length ? pageStart + 1 : 0}-{pageEnd} of {rowEntries.length}</span>
1884
+ <div className="dm-pagination-controls">
1885
+ <label className="dm-page-size-control">
1886
+ <span>Rows</span>
1887
+ <select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPageIndex(0); }}>
1888
+ <option value={15}>15</option>
1889
+ <option value={25}>25</option>
1890
+ <option value={50}>50</option>
1891
+ <option value={100}>100</option>
1892
+ </select>
1893
+ </label>
1894
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex === 0} onClick={() => setPageIndex((current) => Math.max(0, current - 1))}>Previous</button>
1895
+ <span className="dm-pagination-page">{safePageIndex + 1} / {pageCount}</span>
1896
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex >= pageCount - 1} onClick={() => setPageIndex((current) => Math.min(pageCount - 1, current + 1))}>Next</button>
1897
+ </div>
1898
+ </div>
1733
1899
  </div>
1734
1900
  <DataModelRecordDrawer
1735
1901
  table={table}
@@ -1909,8 +2075,101 @@ function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
1909
2075
  );
1910
2076
  }
1911
2077
 
2078
+
2079
+ // ─── Command Palette ──────────────────────────────────────────────────────────
2080
+
2081
+ function DataModelCommandPalette({ commands, onClose }) {
2082
+ const [query, setQuery] = useState("");
2083
+ const [highlight, setHighlight] = useState(0);
2084
+ const inputRef = useRef(null);
2085
+ useEffect(() => { inputRef.current?.focus(); }, []);
2086
+ const filtered = useMemo(() => {
2087
+ const q = query.trim().toLowerCase();
2088
+ if (!q) return commands;
2089
+ return commands.filter((c) =>
2090
+ `${c.label} ${c.group || ""} ${(c.aliases || []).join(" ")}`.toLowerCase().includes(q)
2091
+ );
2092
+ }, [commands, query]);
2093
+ useEffect(() => {
2094
+ setHighlight((v) => Math.min(v, Math.max(0, filtered.length - 1)));
2095
+ }, [filtered.length]);
2096
+ const handleKey = (e) => {
2097
+ if (e.key === "ArrowDown") { e.preventDefault(); setHighlight((v) => Math.min(filtered.length - 1, v + 1)); }
2098
+ else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight((v) => Math.max(0, v - 1)); }
2099
+ else if (e.key === "Enter") {
2100
+ e.preventDefault();
2101
+ const cmd = filtered[highlight];
2102
+ if (cmd && !cmd.disabled) { cmd.run(); onClose(); }
2103
+ } else if (e.key === "Escape") { e.preventDefault(); onClose(); }
2104
+ };
2105
+ const groups = useMemo(() => {
2106
+ const map = new Map();
2107
+ filtered.forEach((c) => {
2108
+ const key = c.group || "General";
2109
+ if (!map.has(key)) map.set(key, []);
2110
+ map.get(key).push(c);
2111
+ });
2112
+ return Array.from(map.entries());
2113
+ }, [filtered]);
2114
+ return (
2115
+ <div className="workspace-command-palette" role="dialog" aria-modal="true" aria-label="Command palette" data-palette="">
2116
+ <div className="workspace-overlay-backdrop" onClick={onClose} aria-hidden="true" />
2117
+ <section className="workspace-command-palette-panel" onKeyDown={handleKey}>
2118
+ <header className="workspace-command-palette-input">
2119
+ <span aria-hidden="true">⌘</span>
2120
+ <input
2121
+ ref={inputRef}
2122
+ value={query}
2123
+ onChange={(e) => setQuery(e.target.value)}
2124
+ placeholder="Type a command or ask helper…"
2125
+ aria-label="Command palette search"
2126
+ />
2127
+ <kbd>esc</kbd>
2128
+ </header>
2129
+ <div className="workspace-command-palette-list" role="listbox">
2130
+ {filtered.length === 0 ? <p className="workspace-panel-hint">No matching commands.</p> : null}
2131
+ {groups.map(([group, items]) => (
2132
+ <div key={group} className="workspace-command-palette-group">
2133
+ <p className="workspace-panel-label">{group}</p>
2134
+ {items.map((cmd) => {
2135
+ const gi = filtered.indexOf(cmd);
2136
+ const isHL = gi === highlight;
2137
+ return (
2138
+ <button
2139
+ key={cmd.id}
2140
+ type="button"
2141
+ role="option"
2142
+ aria-selected={isHL}
2143
+ className={"workspace-command-palette-item" + (isHL ? " active" : "") + (cmd.disabled ? " disabled" : "")}
2144
+ disabled={cmd.disabled}
2145
+ onMouseEnter={() => setHighlight(gi)}
2146
+ onClick={() => { if (!cmd.disabled) { cmd.run(); onClose(); } }}
2147
+ >
2148
+ <span aria-hidden="true"><Zap size={14} /></span>
2149
+ <span className="workspace-command-palette-label">{cmd.label}</span>
2150
+ {cmd.shortcut ? <kbd>{cmd.shortcut}</kbd> : null}
2151
+ </button>
2152
+ );
2153
+ })}
2154
+ </div>
2155
+ ))}
2156
+ </div>
2157
+ <footer className="workspace-command-palette-footer">
2158
+ <span>↑ ↓ navigate</span><span>↵ run</span><span>esc close</span>
2159
+ </footer>
2160
+ </section>
2161
+ </div>
2162
+ );
2163
+ }
2164
+
1912
2165
  // ─── Page ─────────────────────────────────────────────────────────────────────
1913
2166
 
2167
+ // Auto-save tempo: hold local edits in memory + localStorage, only PATCH the
2168
+ // server after this idle window. Keeps growthub.config.json from rewriting on
2169
+ // every keystroke and lets the UI stay snappy on slow disks.
2170
+ const SAVE_DEBOUNCE_MS = 20000;
2171
+ const LOCAL_CACHE_KEY = "growthub.workspace.dataModel.localDraft.v1";
2172
+
1914
2173
  export default function DataModelShell() {
1915
2174
  const [workspaceConfig, setWorkspaceConfig] = useState(null);
1916
2175
  const [authority, setAuthority] = useState(null);
@@ -1920,6 +2179,43 @@ export default function DataModelShell() {
1920
2179
  const [message, setMessage] = useState("");
1921
2180
  const [selectedSource, setSelectedSource] = useState("");
1922
2181
  const [addOpen, setAddOpen] = useState(false);
2182
+ const [helperOpen, setHelperOpen] = useState(false);
2183
+ const [helperIntent, setHelperIntent] = useState("create_object");
2184
+ const [helperInitialPrompt, setHelperInitialPrompt] = useState("");
2185
+ const [helperInitialThread, setHelperInitialThread] = useState(null);
2186
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
2187
+ const pendingPatchRef = useRef({});
2188
+ const saveTimerRef = useRef(null);
2189
+
2190
+ // Cross-page rail entrypoints. Settings / integrations pages render
2191
+ // <WorkspaceRail> without an in-process helper handler — clicking the
2192
+ // pill or a chat thread there navigates to `/data-model?helper=open`
2193
+ // or `/data-model?thread=<id>`. We consume those query params here
2194
+ // exactly once per change and strip them so refreshes are idempotent.
2195
+ const router = useRouter();
2196
+ const searchParams = useSearchParams();
2197
+ useEffect(() => {
2198
+ if (!workspaceConfig) return;
2199
+ const helperParam = searchParams?.get("helper");
2200
+ const threadParam = searchParams?.get("thread");
2201
+ if (!helperParam && !threadParam) return;
2202
+ if (threadParam) {
2203
+ const ht = (workspaceConfig?.dataModel?.objects || []).find((o) => o?.id === "helper-threads");
2204
+ const row = (ht?.rows || []).find((r) => r?.id === threadParam);
2205
+ if (row) {
2206
+ setHelperInitialThread(row);
2207
+ setHelperOpen(true);
2208
+ }
2209
+ } else if (helperParam === "open") {
2210
+ setHelperInitialThread(null);
2211
+ setHelperOpen(true);
2212
+ }
2213
+ const next = new URLSearchParams(searchParams.toString());
2214
+ next.delete("helper");
2215
+ next.delete("thread");
2216
+ const query = next.toString();
2217
+ router.replace(query ? `/data-model?${query}` : "/data-model", { scroll: false });
2218
+ }, [workspaceConfig, searchParams, router]);
1923
2219
 
1924
2220
  const load = useCallback(async () => {
1925
2221
  setLoading(true);
@@ -1939,6 +2235,40 @@ export default function DataModelShell() {
1939
2235
 
1940
2236
  useEffect(() => { load(); }, [load]);
1941
2237
 
2238
+ // Cmd+K opens command palette. Slash opens it too, but only when no
2239
+ // editable element is focused — matches the dashboard builder.
2240
+ useEffect(() => {
2241
+ const handler = (e) => {
2242
+ if ((e.metaKey || e.ctrlKey) && (e.key === "k" || e.key === "K")) {
2243
+ e.preventDefault();
2244
+ setCommandPaletteOpen((v) => !v);
2245
+ return;
2246
+ }
2247
+ if (e.key === "/" && !commandPaletteOpen && !addOpen && !helperOpen) {
2248
+ const t = e.target;
2249
+ const editable = t instanceof HTMLElement && (
2250
+ t.tagName === "INPUT" ||
2251
+ t.tagName === "TEXTAREA" ||
2252
+ t.tagName === "SELECT" ||
2253
+ t.isContentEditable
2254
+ );
2255
+ if (!editable) {
2256
+ e.preventDefault();
2257
+ setCommandPaletteOpen(true);
2258
+ return;
2259
+ }
2260
+ }
2261
+ if (e.key === "Escape" && commandPaletteOpen) setCommandPaletteOpen(false);
2262
+ };
2263
+ const railOpen = () => setCommandPaletteOpen(true);
2264
+ window.addEventListener("keydown", handler);
2265
+ window.addEventListener("growthub:open-command-palette", railOpen);
2266
+ return () => {
2267
+ window.removeEventListener("keydown", handler);
2268
+ window.removeEventListener("growthub:open-command-palette", railOpen);
2269
+ };
2270
+ }, [commandPaletteOpen, addOpen, helperOpen]);
2271
+
1942
2272
  const tables = useMemo(
1943
2273
  () => (workspaceConfig ? listWorkspaceDataModelTables(workspaceConfig) : []),
1944
2274
  [workspaceConfig],
@@ -1950,16 +2280,19 @@ export default function DataModelShell() {
1950
2280
  if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
1951
2281
  }, [selectedSource, tables]);
1952
2282
 
1953
- const save = useCallback(async (mutate) => {
1954
- if (!workspaceConfig) return;
2283
+ // Flush any accumulated patch keys to the server. Called by the debounce
2284
+ // timer and on visibilitychange/beforeunload so no local edit is lost.
2285
+ const flushPendingPatch = useCallback(async () => {
2286
+ const patch = pendingPatchRef.current;
2287
+ pendingPatchRef.current = {};
2288
+ if (saveTimerRef.current) {
2289
+ clearTimeout(saveTimerRef.current);
2290
+ saveTimerRef.current = null;
2291
+ }
2292
+ if (Object.keys(patch).length === 0) return;
1955
2293
  setSaving(true);
1956
2294
  setMessage("");
1957
- const next = mutate(workspaceConfig);
1958
2295
  try {
1959
- const patch = {};
1960
- for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
1961
- if (next[key] !== workspaceConfig[key]) patch[key] = next[key];
1962
- }
1963
2296
  const res = await fetch("/api/workspace", {
1964
2297
  method: "PATCH",
1965
2298
  headers: { "content-type": "application/json" },
@@ -1969,12 +2302,58 @@ export default function DataModelShell() {
1969
2302
  if (!res.ok) throw new Error(payload.error || "Save failed");
1970
2303
  setWorkspaceConfig(payload.workspaceConfig);
1971
2304
  setMessage("Saved");
2305
+ try { window.localStorage.removeItem(LOCAL_CACHE_KEY); } catch {}
1972
2306
  } catch (err) {
1973
2307
  setMessage(`Error: ${err.message || "Save failed"}`);
1974
2308
  } finally {
1975
2309
  setSaving(false);
1976
2310
  }
1977
- }, [workspaceConfig]);
2311
+ }, []);
2312
+
2313
+ // Mutate-in-memory immediately so the UI feels instant, persist a draft to
2314
+ // localStorage every change, and only PATCH the server after SAVE_DEBOUNCE_MS
2315
+ // of idleness. Sandbox-environment objects' lastRunId/lastResponse fields
2316
+ // bypass the debounce (they need durability for run telemetry).
2317
+ const save = useCallback((mutate) => {
2318
+ setWorkspaceConfig((current) => {
2319
+ if (!current) return current;
2320
+ const next = mutate(current);
2321
+ const patch = pendingPatchRef.current;
2322
+ let touchedSandboxRun = false;
2323
+ for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
2324
+ if (next[key] !== current[key]) patch[key] = next[key];
2325
+ }
2326
+ try {
2327
+ const sandboxKey = JSON.stringify((next.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2328
+ const prevSandboxKey = JSON.stringify((current.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2329
+ if (sandboxKey !== prevSandboxKey) touchedSandboxRun = true;
2330
+ } catch {}
2331
+ try {
2332
+ window.localStorage.setItem(LOCAL_CACHE_KEY, JSON.stringify({ savedAt: Date.now(), patch }));
2333
+ } catch {}
2334
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
2335
+ if (touchedSandboxRun) {
2336
+ // immediate flush: durable sandbox run state must persist
2337
+ Promise.resolve().then(flushPendingPatch);
2338
+ } else {
2339
+ saveTimerRef.current = setTimeout(flushPendingPatch, SAVE_DEBOUNCE_MS);
2340
+ }
2341
+ return next;
2342
+ });
2343
+ }, [flushPendingPatch]);
2344
+
2345
+ // Flush before navigation / tab close so the 20s window never silently drops a draft.
2346
+ useEffect(() => {
2347
+ function handleBeforeUnload() { flushPendingPatch(); }
2348
+ function handleVisibility() { if (document.visibilityState === "hidden") flushPendingPatch(); }
2349
+ window.addEventListener("beforeunload", handleBeforeUnload);
2350
+ document.addEventListener("visibilitychange", handleVisibility);
2351
+ return () => {
2352
+ window.removeEventListener("beforeunload", handleBeforeUnload);
2353
+ document.removeEventListener("visibilitychange", handleVisibility);
2354
+ flushPendingPatch();
2355
+ };
2356
+ }, [flushPendingPatch]);
1978
2357
 
1979
2358
  const createObject = useCallback(({ name, objectType, icon }) => {
1980
2359
  save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
@@ -1982,15 +2361,162 @@ export default function DataModelShell() {
1982
2361
  setAddOpen(false);
1983
2362
  }, [save]);
1984
2363
 
2364
+ const INTENT_FOR_TYPE = {
2365
+ people: "edit_view",
2366
+ tasks: "edit_view",
2367
+ "api-registry": "register_api",
2368
+ "sandbox-environment": "create_object",
2369
+ "data-source": "explain",
2370
+ custom: "create_object",
2371
+ };
2372
+
2373
+ // Starter prompt seeded into the textarea when the user asks the helper
2374
+ // about a specific Data Model object. Non-technical users see context-
2375
+ // appropriate guidance instead of an empty box.
2376
+ const STARTER_PROMPT_FOR_TYPE = {
2377
+ people: (name) => `Improve the "${name}" people list. Suggest fields and a view layout that fit a sales / outreach workflow.`,
2378
+ tasks: (name) => `Improve the "${name}" tasks board. Suggest status fields, owners, and a sensible view layout.`,
2379
+ "api-registry": (name) => `Register a new API integration for "${name}". Draft the row with integration label, base URL, endpoint, auth header, and method.`,
2380
+ "sandbox-environment": (name) => `Configure the "${name}" sandbox environment. Suggest runtime, prompt, instructions, and lifecycle status fields.`,
2381
+ "data-source": (name) => `Explain how the "${name}" data source is wired up and what changes would make it more reliable.`,
2382
+ custom: (name) => `Improve the "${name}" object. Suggest fields, relations, and starter rows that fit my use case.`,
2383
+ };
2384
+
2385
+ const openHelperForTable = (table) => {
2386
+ const intent = INTENT_FOR_TYPE[table?.objectType] || "create_object";
2387
+ const fill = STARTER_PROMPT_FOR_TYPE[table?.objectType];
2388
+ setHelperIntent(intent);
2389
+ setHelperInitialPrompt(fill ? fill(table?.label || table?.source || "this object") : "");
2390
+ setHelperInitialThread(null);
2391
+ setHelperOpen(true);
2392
+ };
2393
+
2394
+ const openHelperWith = (intent, prompt) => {
2395
+ setHelperIntent(intent);
2396
+ setHelperInitialPrompt(prompt || "");
2397
+ setHelperInitialThread(null);
2398
+ setHelperOpen(true);
2399
+ };
2400
+
2401
+ // Reopen a helper thread row from the Helper Threads Data Model object.
2402
+ // The row already holds the full prior turn (intent, prompt, proposals,
2403
+ // warnings, receipts) — passing it through initialThread rehydrates the
2404
+ // sidecar state so the user reads the conversation exactly where it ended.
2405
+ const openHelperThreadFromRow = (row) => {
2406
+ if (!row || !row.id) return;
2407
+ const proposals = Array.isArray(row.proposals) ? row.proposals : [];
2408
+ const warnings = Array.isArray(row.warnings) ? row.warnings : [];
2409
+ const result = {
2410
+ summary: row.summary || "",
2411
+ proposals,
2412
+ warnings,
2413
+ receipts: row.receipts || null,
2414
+ threadId: row.id,
2415
+ };
2416
+ setHelperIntent(row.intent || "explain");
2417
+ setHelperInitialPrompt(typeof row.prompt === "string" ? row.prompt : "");
2418
+ setHelperInitialThread({
2419
+ id: row.id,
2420
+ intent: row.intent || "explain",
2421
+ prompt: typeof row.prompt === "string" ? row.prompt : "",
2422
+ result,
2423
+ });
2424
+ setHelperOpen(true);
2425
+ };
2426
+
2427
+ const paletteCommands = [
2428
+ {
2429
+ id: "helper.build_dashboard", group: "Ask helper", label: "Ask helper — build a dashboard",
2430
+ run: () => openHelperWith("build_dashboard", "Draft a dashboard for a local agency with pipeline stages, weekly revenue, and a leaderboard widget.")
2431
+ },
2432
+ {
2433
+ id: "helper.create_object", group: "Ask helper", label: "Ask helper — create a custom object",
2434
+ run: () => openHelperWith("create_object", "Create a custom object for tracking client engagements: name, owner, status, value, next step.")
2435
+ },
2436
+ {
2437
+ id: "helper.register_api", group: "Ask helper", label: "Ask helper — register an API",
2438
+ run: () => openHelperWith("register_api", "Register an API integration: integration label, base URL, endpoint, auth header, and method.")
2439
+ },
2440
+ {
2441
+ id: "helper.repair", group: "Ask helper", label: "Ask helper — repair workspace",
2442
+ run: () => openHelperWith("repair", "Inspect this workspace for missing references, broken bindings, or incomplete views. Propose the smallest fix for each issue.")
2443
+ },
2444
+ {
2445
+ id: "helper.explain", group: "Ask helper", label: "Ask helper — explain this workspace",
2446
+ run: () => openHelperWith("explain", "Explain what this workspace contains and how the objects, dashboards, and bindings relate to each other.")
2447
+ },
2448
+ {
2449
+ id: "object.new", group: "Data Model", label: "New object",
2450
+ run: () => setAddOpen(true)
2451
+ },
2452
+ {
2453
+ id: "nav.dashboards", group: "Navigation", label: "Go to Dashboards",
2454
+ run: () => { window.location.href = "/"; }
2455
+ },
2456
+ {
2457
+ id: "nav.settings", group: "Navigation", label: "Go to Settings",
2458
+ run: () => { window.location.href = "/settings/general"; }
2459
+ },
2460
+ ];
2461
+
1985
2462
  return (
1986
2463
  <main className="workspace-builder workspace-settings-page">
1987
- <NavRail authority={authority} workspaceConfig={workspaceConfig} />
2464
+ <WorkspaceRail
2465
+ authority={authority}
2466
+ workspaceConfig={workspaceConfig}
2467
+ helperOpen={helperOpen}
2468
+ onOpenHelper={() => {
2469
+ if (helperOpen) { setHelperOpen(false); return; }
2470
+ // Rail pill ALWAYS opens a fresh thread (empty state, chip
2471
+ // stack visible). Reopening a specific conversation goes
2472
+ // through onOpenThread from the Chat tab.
2473
+ setHelperInitialThread(null);
2474
+ setHelperIntent("create_object");
2475
+ setHelperInitialPrompt("");
2476
+ setHelperOpen(true);
2477
+ }}
2478
+ onOpenThread={(row) => {
2479
+ setHelperInitialThread(row);
2480
+ setHelperOpen(true);
2481
+ }}
2482
+ onConfigChange={(next) => {
2483
+ if (typeof setWorkspaceConfig === "function") setWorkspaceConfig(next);
2484
+ }}
2485
+ />
1988
2486
 
1989
2487
  <section className="workspace-surface">
1990
2488
  <header className="workspace-toolbar">
1991
- <div><p>Workspace</p><h1>Data Model</h1></div>
2489
+ {selectedTable ? (
2490
+ <div className="workspace-toolbar-object">
2491
+ <div className="workspace-toolbar-object-title">
2492
+ <span className="workspace-toolbar-object-icon" aria-hidden="true">
2493
+ <LucideIcon
2494
+ name={selectedTable.icon || OBJECT_TYPE_PRESETS[selectedTable.objectType]?.icon || "Database"}
2495
+ size={16}
2496
+ />
2497
+ </span>
2498
+ <h1>{selectedTable.label}</h1>
2499
+ </div>
2500
+ <p className="workspace-toolbar-object-meta">
2501
+ {(selectedTable.columns?.length || 0)} {(selectedTable.columns?.length || 0) === 1 ? "Field" : "Fields"}
2502
+ {" · "}
2503
+ {(selectedTable.rows?.length || 0)} {(selectedTable.rows?.length || 0) === 1 ? "Record" : "Records"}
2504
+ </p>
2505
+ </div>
2506
+ ) : (
2507
+ <div><p>Workspace</p><h1>Data Model</h1></div>
2508
+ )}
1992
2509
  <div className="workspace-toolbar-actions">
1993
2510
  <SaveToast saving={saving} message={message} />
2511
+ {selectedTable && (
2512
+ <ObjectViewPicker
2513
+ tables={tables}
2514
+ selectedTable={selectedTable}
2515
+ saving={saving}
2516
+ onSelectSource={setSelectedSource}
2517
+ onSave={save}
2518
+ />
2519
+ )}
1994
2520
  <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
1995
2521
  <Plus size={14} />New object
1996
2522
  </button>
@@ -2005,6 +2531,55 @@ export default function DataModelShell() {
2005
2531
  allTables={tables}
2006
2532
  />
2007
2533
 
2534
+ <HelperSidecar
2535
+ open={helperOpen}
2536
+ onClose={() => setHelperOpen(false)}
2537
+ workspaceConfig={workspaceConfig}
2538
+ initialIntent={helperIntent}
2539
+ initialPrompt={helperInitialPrompt}
2540
+ initialThread={helperInitialThread}
2541
+ onOpenArtifact={(target) => {
2542
+ // Close the chat and route the user to the artifact they
2543
+ // just created — data-model object/row stays in-page, a
2544
+ // dashboard navigates to the workspace home with a query
2545
+ // param the builder reads to focus it.
2546
+ if (!target) return;
2547
+ if (target.surface === "data-model" && target.source) {
2548
+ setSelectedSource(target.source);
2549
+ setHelperOpen(false);
2550
+ return;
2551
+ }
2552
+ if (target.surface === "dashboard" && target.dashboardId) {
2553
+ setHelperOpen(false);
2554
+ router.push(`/?dashboard=${encodeURIComponent(target.dashboardId)}`);
2555
+ }
2556
+ }}
2557
+ onApplied={(updatedConfig) => {
2558
+ // Anchor the user on the most recently created/updated Data Model
2559
+ // object so a helper-driven object.create lands on the surface
2560
+ // instead of needing a manual click.
2561
+ setWorkspaceConfig(updatedConfig);
2562
+ const nextObjects = updatedConfig?.dataModel?.objects || [];
2563
+ const prevIds = new Set(
2564
+ (workspaceConfig?.dataModel?.objects || []).map((o) => o?.id).filter(Boolean)
2565
+ );
2566
+ const newlyCreated = nextObjects.find((o) => o?.id && !prevIds.has(o.id));
2567
+ const nextSource = (newlyCreated?.label || newlyCreated?.id)
2568
+ ? (newlyCreated.label || newlyCreated.id)
2569
+ : selectedSource;
2570
+ if (nextSource && nextSource !== selectedSource) {
2571
+ setSelectedSource(nextSource);
2572
+ }
2573
+ }}
2574
+ />
2575
+
2576
+ {commandPaletteOpen && (
2577
+ <DataModelCommandPalette
2578
+ commands={paletteCommands}
2579
+ onClose={() => setCommandPaletteOpen(false)}
2580
+ />
2581
+ )}
2582
+
2008
2583
  {loading && <div className="dm-loading">Loading workspace…</div>}
2009
2584
 
2010
2585
  {error && (
@@ -2019,13 +2594,8 @@ export default function DataModelShell() {
2019
2594
  {!loading && !error && tables.length > 0 && (
2020
2595
  selectedTable && (
2021
2596
  <section className="dm-detail-v2 dm-detail-v3">
2022
- <div className="dm-detail-v2-head dm-detail-v3-head">
2023
- <div className="dm-detail-v2-title">
2024
- <ObjectViewPicker tables={tables} selectedTable={selectedTable} saving={saving} onSelectSource={setSelectedSource} onSave={save} />
2025
- </div>
2026
- <SourceValidationBanner table={selectedTable} />
2027
- </div>
2028
- <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} />
2597
+ <SourceValidationBanner table={selectedTable} />
2598
+ <DataModelTableSurface workspaceConfig={workspaceConfig} table={selectedTable} tables={tables} saving={saving} onSave={save} onOpenThread={openHelperThreadFromRow} />
2029
2599
  </section>
2030
2600
  )
2031
2601
  )}
@@ -2034,10 +2604,22 @@ export default function DataModelShell() {
2034
2604
  <div className="dm-page-empty">
2035
2605
  <Database size={32} />
2036
2606
  <strong>No objects yet</strong>
2037
- <p>Create a Data Source, API Registry, People, Tasks, or Custom object to get started.</p>
2038
- <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
2039
- <Plus size={14} />New object
2040
- </button>
2607
+ <p>Create your first Data Source, API Registry, People list, or custom object to get started.</p>
2608
+ <div className="dm-page-empty-actions">
2609
+ <button type="button" className="dm-btn-primary" onClick={() => setAddOpen(true)}>
2610
+ <Plus size={14} />New object
2611
+ </button>
2612
+ <button
2613
+ type="button"
2614
+ className="dm-btn-outline"
2615
+ onClick={() => openHelperWith(
2616
+ "create_object",
2617
+ "I run a local agency. Create my first business object: a client list with name, owner, status, deal value, and next step. Then suggest a starter dashboard."
2618
+ )}
2619
+ >
2620
+ <Zap size={14} />Try the helper
2621
+ </button>
2622
+ </div>
2041
2623
  </div>
2042
2624
  )}
2043
2625
  </section>