@growthub/cli 0.10.0 → 0.10.1

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.
@@ -49,6 +49,7 @@ import {
49
49
  addTableRow,
50
50
  appendRowsToTable,
51
51
  createTypedBusinessObject,
52
+ deleteTableRow,
52
53
  describeBindingLane,
53
54
  effectiveRelations,
54
55
  exportTableAsCsv,
@@ -310,9 +311,13 @@ function ObjectViewPicker({ tables, selectedTable, saving, onSelectSource, onSav
310
311
  </div>
311
312
  )}
312
313
  <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}
314
+ {[
315
+ { id: "all", label: "All" },
316
+ { id: "objects", label: "Objects" },
317
+ { id: "views", label: "Views" },
318
+ ].map((item) => (
319
+ <button key={item.id} type="button" className={mode === item.id ? "active" : ""} onClick={() => setMode(item.id)}>
320
+ {item.label}
316
321
  </button>
317
322
  ))}
318
323
  </div>
@@ -1399,10 +1404,23 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1399
1404
  const [filterDraft, setFilterDraft] = useState({ fieldId: "", operator: "eq", value: "" });
1400
1405
  const [filterTarget, setFilterTarget] = useState("");
1401
1406
  const [menuColumn, setMenuColumn] = useState("");
1407
+ const [selectedRows, setSelectedRows] = useState(() => new Set());
1408
+ const [confirmDeleteSelection, setConfirmDeleteSelection] = useState(false);
1409
+ const [lastSelectedRowIndex, setLastSelectedRowIndex] = useState(null);
1410
+ const [selectMenuOpen, setSelectMenuOpen] = useState(false);
1411
+ const [pageSize, setPageSize] = useState(15);
1412
+ const [pageIndex, setPageIndex] = useState(0);
1402
1413
  const fieldInputRef = useRef(null);
1403
1414
 
1404
1415
  useEffect(() => { if (addingField) fieldInputRef.current?.focus(); }, [addingField]);
1405
- useEffect(() => { setSelectedRow(null); }, [table.id]);
1416
+ useEffect(() => {
1417
+ setSelectedRow(null);
1418
+ setSelectedRows(new Set());
1419
+ setConfirmDeleteSelection(false);
1420
+ setLastSelectedRowIndex(null);
1421
+ setSelectMenuOpen(false);
1422
+ setPageIndex(0);
1423
+ }, [table.id]);
1406
1424
  useEffect(() => {
1407
1425
  setFieldName("");
1408
1426
  setFieldType("text");
@@ -1430,6 +1448,25 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1430
1448
  () => (settings.views || []).find((view) => view.id === settings.activeViewId) || null,
1431
1449
  [settings.views, settings.activeViewId]
1432
1450
  );
1451
+ const selectedRowCount = selectedRows.size;
1452
+ const pageCount = Math.max(1, Math.ceil(rowEntries.length / pageSize));
1453
+ const safePageIndex = Math.min(pageIndex, pageCount - 1);
1454
+ const pageStart = safePageIndex * pageSize;
1455
+ const pageEntries = rowEntries.slice(pageStart, pageStart + pageSize);
1456
+ const pageEnd = Math.min(pageStart + pageSize, rowEntries.length);
1457
+ const pageSelectedCount = pageEntries.filter((entry) => selectedRows.has(entry.originalIndex)).length;
1458
+ const allPageSelected = pageEntries.length > 0 && pageSelectedCount === pageEntries.length;
1459
+
1460
+ useEffect(() => {
1461
+ setPageIndex((current) => Math.min(current, pageCount - 1));
1462
+ }, [pageCount]);
1463
+
1464
+ useEffect(() => {
1465
+ setPageIndex(0);
1466
+ setSelectedRow(null);
1467
+ setLastSelectedRowIndex(null);
1468
+ setSelectMenuOpen(false);
1469
+ }, [settings.filter, settings.sort, pageSize]);
1433
1470
 
1434
1471
  function commitField() {
1435
1472
  const name = fieldName.trim();
@@ -1540,6 +1577,78 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1540
1577
  }));
1541
1578
  }
1542
1579
 
1580
+ function toggleRowSelection(originalIndex, visibleIndex, event) {
1581
+ setConfirmDeleteSelection(false);
1582
+ setSelectMenuOpen(false);
1583
+ setSelectedRows((current) => {
1584
+ const next = new Set(current);
1585
+ if (event?.shiftKey && lastSelectedRowIndex !== null) {
1586
+ const start = Math.min(lastSelectedRowIndex, visibleIndex);
1587
+ const end = Math.max(lastSelectedRowIndex, visibleIndex);
1588
+ rowEntries.slice(start, end + 1).forEach((entry) => next.add(entry.originalIndex));
1589
+ } else if (next.has(originalIndex)) {
1590
+ next.delete(originalIndex);
1591
+ } else {
1592
+ next.add(originalIndex);
1593
+ }
1594
+ return next;
1595
+ });
1596
+ setLastSelectedRowIndex(visibleIndex);
1597
+ }
1598
+
1599
+ function clearRowSelection() {
1600
+ setSelectedRows(new Set());
1601
+ setConfirmDeleteSelection(false);
1602
+ setLastSelectedRowIndex(null);
1603
+ setSelectMenuOpen(false);
1604
+ }
1605
+
1606
+ function selectCurrentPage() {
1607
+ setConfirmDeleteSelection(false);
1608
+ setSelectedRows((current) => {
1609
+ const next = new Set(current);
1610
+ pageEntries.forEach((entry) => next.add(entry.originalIndex));
1611
+ return next;
1612
+ });
1613
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1614
+ setSelectMenuOpen(false);
1615
+ }
1616
+
1617
+ function toggleCurrentPageSelection() {
1618
+ setConfirmDeleteSelection(false);
1619
+ setSelectedRows((current) => {
1620
+ const next = new Set(current);
1621
+ if (allPageSelected) pageEntries.forEach((entry) => next.delete(entry.originalIndex));
1622
+ else pageEntries.forEach((entry) => next.add(entry.originalIndex));
1623
+ return next;
1624
+ });
1625
+ setLastSelectedRowIndex(pageEntries.length ? pageStart : null);
1626
+ setSelectMenuOpen(false);
1627
+ }
1628
+
1629
+ function selectAllFilteredRows() {
1630
+ setConfirmDeleteSelection(false);
1631
+ setSelectedRows((current) => {
1632
+ const next = new Set(current);
1633
+ rowEntries.forEach((entry) => next.add(entry.originalIndex));
1634
+ return next;
1635
+ });
1636
+ setLastSelectedRowIndex(rowEntries.length ? 0 : null);
1637
+ setSelectMenuOpen(false);
1638
+ }
1639
+
1640
+ function deleteSelectedRows() {
1641
+ if (!selectedRows.size) return;
1642
+ if (!confirmDeleteSelection) {
1643
+ setConfirmDeleteSelection(true);
1644
+ return;
1645
+ }
1646
+ const rowIndexes = Array.from(selectedRows).sort((a, b) => b - a);
1647
+ onSave((config) => rowIndexes.reduce((nextConfig, rowIndex) => deleteTableRow(nextConfig, table, rowIndex), config));
1648
+ setSelectedRow(null);
1649
+ clearRowSelection();
1650
+ }
1651
+
1543
1652
  const selectedEntry = selectedRow === null ? null : rowEntries[selectedRow];
1544
1653
  const selectedRecord = selectedEntry?.row || null;
1545
1654
 
@@ -1553,6 +1662,11 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1553
1662
  )}
1554
1663
  <div className="dm-db-toolbar">
1555
1664
  <div className="dm-filter-chip-row">
1665
+ {selectedRowCount > 0 && (
1666
+ <span className="dm-filter-chip dm-selection-count">
1667
+ {pluralize(selectedRowCount, "record")} selected
1668
+ </span>
1669
+ )}
1556
1670
  {settings.filter?.clauses?.map((clause) => (
1557
1671
  <button key={`${clause.fieldId}:${clause.operator}`} type="button" className="dm-filter-chip" onClick={() => removeFilter(clause.fieldId)}>
1558
1672
  <LucideIcon name={FIELD_TYPE_ICON_NAMES[settings.types?.[clause.fieldId] || inferFieldType(clause.fieldId)] || "Type"} size={12} />
@@ -1562,9 +1676,24 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1562
1676
  ))}
1563
1677
  </div>
1564
1678
  <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>
1679
+ <span className="dm-filter-anchor">
1680
+ <button type="button" className="dm-btn-ghost" onClick={() => setFilterTarget((current) => current === "toolbar" ? "" : "toolbar")}>
1681
+ <Filter size={13} />Filter
1682
+ </button>
1683
+ {filterTarget === "toolbar" && (
1684
+ <div className="dm-filter-popover dm-filter-popover-toolbar">
1685
+ <StaticSelect value={filterDraft.fieldId} options={visibleColumns.map((column) => ({ value: column, label: column }))} onChange={(next) => setFilterDraft((current) => ({ ...current, fieldId: next }))} />
1686
+ <StaticSelect value={filterDraft.operator} options={FILTER_OPERATOR_OPTIONS.map((item) => ({ value: item.value, label: item.label }))} onChange={(next) => setFilterDraft((current) => ({ ...current, operator: next }))} />
1687
+ {!["isEmpty", "isNotEmpty"].includes(filterDraft.operator) && (
1688
+ <input value={filterDraft.value} placeholder="Value" onChange={(event) => setFilterDraft((current) => ({ ...current, value: event.target.value }))} />
1689
+ )}
1690
+ <div className="dm-filter-popover-actions">
1691
+ <button type="button" className="dm-btn-outline" onClick={() => setFilterTarget("")}>Cancel</button>
1692
+ <button type="button" className="dm-btn-primary-sm" onClick={applyFilter}>Apply</button>
1693
+ </div>
1694
+ </div>
1695
+ )}
1696
+ </span>
1568
1697
  {activeView ? (
1569
1698
  <button type="button" className="dm-btn-ghost" onClick={updateCurrentView}>
1570
1699
  Update view
@@ -1589,21 +1718,16 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1589
1718
  <Plus size={13} />Add record
1590
1719
  </button>
1591
1720
  )}
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 }))} />
1721
+ {table.mutable && selectedRowCount > 0 && (
1722
+ <>
1723
+ <button type="button" className="dm-btn-ghost" disabled={saving} onClick={clearRowSelection}>Cancel selection</button>
1724
+ <button type="button" className="dm-btn-danger-sm" disabled={saving} onClick={deleteSelectedRows}>
1725
+ {confirmDeleteSelection ? `Confirm delete ${selectedRowCount}` : "Delete"}
1726
+ </button>
1727
+ </>
1600
1728
  )}
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
1729
  </div>
1606
- )}
1730
+ </div>
1607
1731
  {csvOpen && (
1608
1732
  <div className="dm-csv-panel">
1609
1733
  <textarea className="dm-csv-textarea" rows={4} value={csvText} onChange={(e) => setCsvText(e.target.value)} placeholder={"Name,Status\nAcme,Active"} />
@@ -1615,10 +1739,30 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1615
1739
  </div>
1616
1740
  )}
1617
1741
  <div className="dm-db-grid-wrap">
1742
+ <div className="dm-db-grid-scroll">
1618
1743
  <table className="dm-db-grid">
1619
1744
  <thead>
1620
1745
  <tr>
1621
- <th className="dm-db-rownum">#</th>
1746
+ <th className="dm-db-rownum dm-db-rownum-head">
1747
+ {table.mutable ? (
1748
+ <div className="dm-row-select-head-wrap">
1749
+ <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(); }}>
1750
+ <span className="dm-row-select-box" />
1751
+ <span className="dm-row-number">#</span>
1752
+ </button>
1753
+ <button type="button" className="dm-row-select-menu-btn" aria-label="Selection options" aria-expanded={selectMenuOpen} onClick={(event) => { event.stopPropagation(); setSelectMenuOpen((open) => !open); }}>
1754
+ <ChevronDown size={11} />
1755
+ </button>
1756
+ {selectMenuOpen && (
1757
+ <div className="dm-row-select-menu">
1758
+ <button type="button" onClick={selectCurrentPage}>Select page</button>
1759
+ <button type="button" onClick={selectAllFilteredRows}>Select all filtered</button>
1760
+ <button type="button" disabled={!selectedRowCount} onClick={clearRowSelection}>Clear selection</button>
1761
+ </div>
1762
+ )}
1763
+ </div>
1764
+ ) : "#"}
1765
+ </th>
1622
1766
  {visibleColumns.map((column) => (
1623
1767
  <th key={column}>
1624
1768
  <button type="button" className="dm-db-head-btn" onClick={() => setMenuColumn((current) => current === column ? "" : column)}>
@@ -1694,9 +1838,19 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1694
1838
  </tr>
1695
1839
  </thead>
1696
1840
  <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>
1841
+ {pageEntries.map(({ row, originalIndex }, rowIndex) => {
1842
+ const visibleIndex = pageStart + rowIndex;
1843
+ const displayIndex = visibleIndex + 1;
1844
+ return (
1845
+ <tr key={`${originalIndex}:${visibleIndex}`} className={`${selectedRow === visibleIndex ? "selected" : ""}${selectedRows.has(originalIndex) ? " multi-selected" : ""}`} onClick={() => setSelectedRow(visibleIndex)}>
1846
+ <td className="dm-db-rownum">
1847
+ {table.mutable ? (
1848
+ <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); }}>
1849
+ <span className="dm-row-select-box" />
1850
+ <span className="dm-row-number">{displayIndex}</span>
1851
+ </button>
1852
+ ) : displayIndex}
1853
+ </td>
1700
1854
  {visibleColumns.map((column) => {
1701
1855
  const relation = relationForColumn(table, column);
1702
1856
  return (
@@ -1721,7 +1875,7 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1721
1875
  );})}
1722
1876
  {table.mutable && <td className="dm-db-empty-cell" />}
1723
1877
  </tr>
1724
- ))}
1878
+ );})}
1725
1879
  {table.mutable && (
1726
1880
  <tr className="dm-db-new-row" onClick={() => onSave((config) => addTableRow(config, table))}>
1727
1881
  <td className="dm-db-rownum">+</td>
@@ -1730,6 +1884,24 @@ function DataModelTableSurface({ table, tables, workspaceConfig, saving, onSave
1730
1884
  )}
1731
1885
  </tbody>
1732
1886
  </table>
1887
+ </div>
1888
+ <div className="dm-pagination-bar">
1889
+ <span className="dm-pagination-summary">Showing {rowEntries.length ? pageStart + 1 : 0}-{pageEnd} of {rowEntries.length}</span>
1890
+ <div className="dm-pagination-controls">
1891
+ <label className="dm-page-size-control">
1892
+ <span>Rows</span>
1893
+ <select value={pageSize} onChange={(event) => { setPageSize(Number(event.target.value)); setPageIndex(0); }}>
1894
+ <option value={15}>15</option>
1895
+ <option value={25}>25</option>
1896
+ <option value={50}>50</option>
1897
+ <option value={100}>100</option>
1898
+ </select>
1899
+ </label>
1900
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex === 0} onClick={() => setPageIndex((current) => Math.max(0, current - 1))}>Previous</button>
1901
+ <span className="dm-pagination-page">{safePageIndex + 1} / {pageCount}</span>
1902
+ <button type="button" className="dm-pagination-btn" disabled={safePageIndex >= pageCount - 1} onClick={() => setPageIndex((current) => Math.min(pageCount - 1, current + 1))}>Next</button>
1903
+ </div>
1904
+ </div>
1733
1905
  </div>
1734
1906
  <DataModelRecordDrawer
1735
1907
  table={table}
@@ -1911,6 +2083,12 @@ function AddObjectSidebar({ open, saving, onClose, onCreate, allTables }) {
1911
2083
 
1912
2084
  // ─── Page ─────────────────────────────────────────────────────────────────────
1913
2085
 
2086
+ // Auto-save tempo: hold local edits in memory + localStorage, only PATCH the
2087
+ // server after this idle window. Keeps growthub.config.json from rewriting on
2088
+ // every keystroke and lets the UI stay snappy on slow disks.
2089
+ const SAVE_DEBOUNCE_MS = 20000;
2090
+ const LOCAL_CACHE_KEY = "growthub.workspace.dataModel.localDraft.v1";
2091
+
1914
2092
  export default function DataModelShell() {
1915
2093
  const [workspaceConfig, setWorkspaceConfig] = useState(null);
1916
2094
  const [authority, setAuthority] = useState(null);
@@ -1920,6 +2098,8 @@ export default function DataModelShell() {
1920
2098
  const [message, setMessage] = useState("");
1921
2099
  const [selectedSource, setSelectedSource] = useState("");
1922
2100
  const [addOpen, setAddOpen] = useState(false);
2101
+ const pendingPatchRef = useRef({});
2102
+ const saveTimerRef = useRef(null);
1923
2103
 
1924
2104
  const load = useCallback(async () => {
1925
2105
  setLoading(true);
@@ -1950,16 +2130,19 @@ export default function DataModelShell() {
1950
2130
  if (!selectedSource && tables[0]) setSelectedSource(tables[0].source);
1951
2131
  }, [selectedSource, tables]);
1952
2132
 
1953
- const save = useCallback(async (mutate) => {
1954
- if (!workspaceConfig) return;
2133
+ // Flush any accumulated patch keys to the server. Called by the debounce
2134
+ // timer and on visibilitychange/beforeunload so no local edit is lost.
2135
+ const flushPendingPatch = useCallback(async () => {
2136
+ const patch = pendingPatchRef.current;
2137
+ pendingPatchRef.current = {};
2138
+ if (saveTimerRef.current) {
2139
+ clearTimeout(saveTimerRef.current);
2140
+ saveTimerRef.current = null;
2141
+ }
2142
+ if (Object.keys(patch).length === 0) return;
1955
2143
  setSaving(true);
1956
2144
  setMessage("");
1957
- const next = mutate(workspaceConfig);
1958
2145
  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
2146
  const res = await fetch("/api/workspace", {
1964
2147
  method: "PATCH",
1965
2148
  headers: { "content-type": "application/json" },
@@ -1969,12 +2152,58 @@ export default function DataModelShell() {
1969
2152
  if (!res.ok) throw new Error(payload.error || "Save failed");
1970
2153
  setWorkspaceConfig(payload.workspaceConfig);
1971
2154
  setMessage("Saved");
2155
+ try { window.localStorage.removeItem(LOCAL_CACHE_KEY); } catch {}
1972
2156
  } catch (err) {
1973
2157
  setMessage(`Error: ${err.message || "Save failed"}`);
1974
2158
  } finally {
1975
2159
  setSaving(false);
1976
2160
  }
1977
- }, [workspaceConfig]);
2161
+ }, []);
2162
+
2163
+ // Mutate-in-memory immediately so the UI feels instant, persist a draft to
2164
+ // localStorage every change, and only PATCH the server after SAVE_DEBOUNCE_MS
2165
+ // of idleness. Sandbox-environment objects' lastRunId/lastResponse fields
2166
+ // bypass the debounce (they need durability for run telemetry).
2167
+ const save = useCallback((mutate) => {
2168
+ setWorkspaceConfig((current) => {
2169
+ if (!current) return current;
2170
+ const next = mutate(current);
2171
+ const patch = pendingPatchRef.current;
2172
+ let touchedSandboxRun = false;
2173
+ for (const key of ["dashboards", "widgetTypes", "canvas", "dataModel"]) {
2174
+ if (next[key] !== current[key]) patch[key] = next[key];
2175
+ }
2176
+ try {
2177
+ const sandboxKey = JSON.stringify((next.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2178
+ const prevSandboxKey = JSON.stringify((current.dataModel?.objects || []).find((o) => o.objectType === "sandbox-environment")?.rows || []);
2179
+ if (sandboxKey !== prevSandboxKey) touchedSandboxRun = true;
2180
+ } catch {}
2181
+ try {
2182
+ window.localStorage.setItem(LOCAL_CACHE_KEY, JSON.stringify({ savedAt: Date.now(), patch }));
2183
+ } catch {}
2184
+ if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
2185
+ if (touchedSandboxRun) {
2186
+ // immediate flush: durable sandbox run state must persist
2187
+ Promise.resolve().then(flushPendingPatch);
2188
+ } else {
2189
+ saveTimerRef.current = setTimeout(flushPendingPatch, SAVE_DEBOUNCE_MS);
2190
+ }
2191
+ return next;
2192
+ });
2193
+ }, [flushPendingPatch]);
2194
+
2195
+ // Flush before navigation / tab close so the 20s window never silently drops a draft.
2196
+ useEffect(() => {
2197
+ function handleBeforeUnload() { flushPendingPatch(); }
2198
+ function handleVisibility() { if (document.visibilityState === "hidden") flushPendingPatch(); }
2199
+ window.addEventListener("beforeunload", handleBeforeUnload);
2200
+ document.addEventListener("visibilitychange", handleVisibility);
2201
+ return () => {
2202
+ window.removeEventListener("beforeunload", handleBeforeUnload);
2203
+ document.removeEventListener("visibilitychange", handleVisibility);
2204
+ flushPendingPatch();
2205
+ };
2206
+ }, [flushPendingPatch]);
1978
2207
 
1979
2208
  const createObject = useCallback(({ name, objectType, icon }) => {
1980
2209
  save((config) => createTypedBusinessObject(config, { name, objectType, icon }));
@@ -3965,7 +3965,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
3965
3965
  .dm-detail-v2-title h2 { margin: 0; font-size: 15px; font-weight: 600; color: #111827; flex: 1; }
3966
3966
  .dm-detail-v2-meta { display: flex; align-items: center; gap: 10px; padding-left: 21px; font-size: 12px; color: #9ca3af; }
3967
3967
  .dm-detail-v2-meta code { font-size: 11px; color: #6b7280; background: #f3f4f6; border-radius: 4px; padding: 2px 6px; }
3968
- .dm-detail-v3 { border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; overflow: visible; }
3968
+ .dm-detail-v3 { border: 1px solid #e5e7eb; border-radius: 12px; background: #fff; overflow: hidden; }
3969
3969
  .dm-detail-v3-head { padding-bottom: 14px; }
3970
3970
  .dm-picker { position: relative; min-width: 280px; }
3971
3971
  .dm-picker-trigger { width: min(420px, 100%); display: inline-flex; align-items: center; gap: 10px; min-height: 40px; border: 1px solid #dbe2ea; border-radius: 10px; background: #fff; color: #0f172a; box-shadow: 0 1px 2px rgba(15,23,42,.05); font: inherit; padding: 0 12px; cursor: pointer; text-align: left; }
@@ -4064,20 +4064,43 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
4064
4064
  .dm-inline-panel input { height: 32px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: #111827; font: inherit; font-size: 12px; padding: 0 10px; }
4065
4065
  .dm-filter-chip-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-height: 32px; }
4066
4066
  .dm-filter-chip { display: inline-flex; align-items: center; gap: 6px; height: 30px; border: 1px solid #c7d2fe; border-radius: 8px; background: #eef2ff; color: #3730a3; font: inherit; font-size: 12px; padding: 0 10px; cursor: pointer; }
4067
+ .dm-selection-count { border-color: #d1d5db; background: #fff; color: #475569; cursor: default; }
4067
4068
  .dm-filter-chip span { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4068
4069
  .dm-filter-popover { position: absolute; z-index: 40; display: grid; gap: 8px; width: 320px; padding: 10px; border: 1px solid #dbe2ea; border-radius: 10px; background: #fff; box-shadow: 0 18px 42px rgba(15,23,42,.18), 0 3px 10px rgba(15,23,42,.08); }
4069
4070
  .dm-filter-popover input { height: 32px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: #111827; font: inherit; font-size: 12px; padding: 0 10px; }
4070
- .dm-filter-popover-toolbar { top: 48px; right: 0; }
4071
+ .dm-filter-anchor { position: relative; display: inline-flex; }
4072
+ .dm-filter-popover-toolbar { top: calc(100% + 6px); left: 0; right: auto; }
4071
4073
  .dm-filter-popover-column { top: calc(100% + 6px); left: 0; }
4072
4074
  .dm-filter-popover-actions { display: flex; justify-content: flex-end; gap: 8px; }
4073
- .dm-db-grid-wrap { flex: 1; min-height: 420px; overflow: auto; border: 1px solid #dfe3e8; border-radius: 7px; background: #fff; }
4075
+ .dm-db-grid-wrap { display: flex; flex: 1; min-height: 420px; flex-direction: column; overflow: hidden; border: 1px solid #dfe3e8; border-radius: 7px; background: #fff; }
4076
+ .dm-db-grid-wrap > .dm-db-grid-scroll { flex: 1 1 auto; overflow: auto; }
4074
4077
  .dm-db-grid { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12px; color: #1f2937; }
4075
4078
  .dm-db-grid th { position: sticky; top: 0; z-index: 1; height: 32px; background: #f8fafc; color: #475569; border-bottom: 1px solid #dfe3e8; border-right: 1px solid #edf0f3; padding: 0; text-align: left; font-weight: 650; white-space: nowrap; }
4076
4079
  .dm-db-grid td { height: 32px; max-width: 260px; border-bottom: 1px solid #f1f5f9; border-right: 1px solid #f1f5f9; padding: 0 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
4077
4080
  .dm-db-grid tbody tr:hover td { background: #f8fafc; }
4078
4081
  .dm-db-grid tbody tr.selected td { background: #eef2ff; }
4082
+ .dm-db-grid tbody tr.multi-selected td { background: #f8fafc; }
4079
4083
  .dm-db-head-btn { display: inline-flex; align-items: center; justify-content: flex-start; width: 100%; height: 32px; gap: 6px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 12px; font-weight: inherit; padding: 0 10px; cursor: pointer; }
4080
4084
  .dm-db-rownum { width: 42px; min-width: 42px; text-align: center !important; color: #94a3b8 !important; background: #f8fafc; font-variant-numeric: tabular-nums; }
4085
+ .dm-row-select { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 100%; height: 32px; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; }
4086
+ .dm-row-select-box { position: relative; display: none; width: 16px; height: 16px; border: 1px solid #d1d5db; border-radius: 5px; background: #fff; box-sizing: border-box; }
4087
+ .dm-row-select:hover .dm-row-select-box, .dm-row-select[aria-pressed="true"] .dm-row-select-box { border-color: #cbd5e1; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.14); }
4088
+ .dm-row-select[aria-pressed="true"] .dm-row-select-box { display: inline-flex; }
4089
+ .dm-row-select[aria-pressed="true"] .dm-row-select-box::after { content: ""; position: absolute; left: 5px; top: 3px; width: 4px; height: 7px; border: solid #334155; border-width: 0 1.5px 1.5px 0; transform: rotate(45deg); }
4090
+ .dm-db-grid tbody tr:hover .dm-row-select-box { display: inline-flex; }
4091
+ .dm-row-select[aria-pressed="true"] .dm-row-number, .dm-db-grid tbody tr:hover .dm-row-select .dm-row-number { display: none; }
4092
+ .dm-db-rownum-head { position: sticky; overflow: visible; z-index: 4 !important; }
4093
+ .dm-row-select-head-wrap { position: relative; display: flex; align-items: center; justify-content: center; height: 32px; }
4094
+ .dm-row-select-all { width: 26px; flex: 0 0 26px; }
4095
+ .dm-row-select-menu-btn { display: none; }
4096
+ .dm-db-rownum-head:hover .dm-row-select-menu-btn, .dm-row-select-menu-btn[aria-expanded="true"] { display: none; }
4097
+ .dm-row-select-menu-btn:hover { background: #eef2f7; color: #475569; }
4098
+ .dm-db-rownum-head:hover .dm-row-select-all .dm-row-select-box, .dm-row-select-all[aria-pressed="true"] .dm-row-select-box { display: inline-flex; }
4099
+ .dm-db-rownum-head:hover .dm-row-select-all .dm-row-number, .dm-row-select-all[aria-pressed="true"] .dm-row-number { display: none; }
4100
+ .dm-row-select-menu { position: absolute; top: calc(100% + 6px); left: 6px; z-index: 50; display: grid; gap: 3px; min-width: 150px; padding: 6px; border: 1px solid #dbe2ea; border-radius: 8px; background: #fff; box-shadow: 0 14px 34px rgba(15,23,42,.16), 0 2px 8px rgba(15,23,42,.08); }
4101
+ .dm-row-select-menu button { display: flex; align-items: center; height: 28px; border: 0; border-radius: 6px; background: transparent; color: #334155; font: inherit; font-size: 12px; padding: 0 8px; text-align: left; cursor: pointer; }
4102
+ .dm-row-select-menu button:hover { background: #f1f5f9; }
4103
+ .dm-row-select-menu button:disabled { opacity: .45; cursor: not-allowed; }
4081
4104
  .dm-db-field-type { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; margin-right: 5px; border-radius: 4px; color: #64748b; background: #eef2f7; vertical-align: middle; }
4082
4105
  .dm-db-add-field { position: relative; min-width: 118px; }
4083
4106
  .dm-db-add-field button { display: inline-flex; align-items: center; gap: 4px; border: 0; background: transparent; color: #64748b; font: inherit; font-size: 12px; cursor: pointer; }
@@ -4094,6 +4117,15 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0
4094
4117
  .dm-field-creator-actions { display: flex; justify-content: flex-end; gap: 8px; }
4095
4118
  .dm-db-empty-cell { background: #fbfdff; }
4096
4119
  .dm-db-new-row td { color: #64748b; background: #fbfdff; font-weight: 600; }
4120
+ .dm-pagination-bar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; padding: 8px 14px; background: #fff; color: #64748b; font-size: 12px; border-top: 1px solid #edf0f3; }
4121
+ .dm-pagination-summary { white-space: nowrap; }
4122
+ .dm-pagination-controls { display: inline-flex; align-items: center; gap: 8px; }
4123
+ .dm-page-size-control { display: inline-flex; align-items: center; gap: 6px; color: #64748b; }
4124
+ .dm-page-size-control select { height: 28px; border: 1px solid #dbe2ea; border-radius: 6px; background: #fff; color: #334155; font: inherit; font-size: 12px; padding: 0 22px 0 8px; }
4125
+ .dm-pagination-btn { height: 28px; border: 1px solid #dbe2ea; border-radius: 6px; background: #fff; color: #334155; font: inherit; font-size: 12px; padding: 0 10px; cursor: pointer; }
4126
+ .dm-pagination-btn:hover:not(:disabled) { background: #f8fafc; border-color: #cbd5e1; }
4127
+ .dm-pagination-btn:disabled { opacity: .45; cursor: not-allowed; }
4128
+ .dm-pagination-page { min-width: 44px; text-align: center; color: #64748b; }
4097
4129
  .dm-db-status { display: inline-flex; align-items: center; gap: 5px; height: 20px; border: 1px solid #e2e8f0; border-radius: 999px; padding: 0 8px; background: #f8fafc; color: #64748b; font-size: 11px; font-weight: 650; }
4098
4130
  .dm-db-status span { width: 6px; height: 6px; border-radius: 50%; background: #94a3b8; }
4099
4131
  .dm-db-status.ok { border-color: #bbf7d0; background: #f0fdf4; color: #166534; }
@@ -275,6 +275,7 @@ function deriveManualObjectTable(object) {
275
275
  source,
276
276
  objectType: object.objectType || "custom",
277
277
  icon: object.icon || null,
278
+ pickerHidden: Boolean(object.pickerHidden),
278
279
  columns,
279
280
  rows,
280
281
  binding: object.binding || { mode: "manual", source: "Data Model" },
@@ -1064,7 +1065,13 @@ function resolveLocalReferenceOptions(workspaceConfig, {
1064
1065
  const pageSize = Math.min(100, Math.max(1, Number(relation.pageSize) || Number(limit) || 25));
1065
1066
  const offset = decodeRefCursor(cursor);
1066
1067
 
1067
- const targets = objects.filter((o) => o.objectType === relation.targetObjectType);
1068
+ const targetObjectId = typeof relation.targetObjectId === "string" && relation.targetObjectId.trim()
1069
+ ? relation.targetObjectId.trim()
1070
+ : "";
1071
+ const targets = objects.filter((o) => (
1072
+ o.objectType === relation.targetObjectType
1073
+ && (!targetObjectId || o.id === targetObjectId)
1074
+ ));
1068
1075
  const needle = String(query || "").trim().toLowerCase();
1069
1076
 
1070
1077
  const candidates = [];
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * helpers/export-training-traces.mjs — Distillation Pipeline V1, Phase 3
4
+ *
5
+ * Reads `training-traces.rows` from the live workspace, filters rows where
6
+ * qualityScore >= --min-score AND exported == "false", emits an Unsloth-ready
7
+ * JSONL of {instruction, input, output} on disk, then PATCHes the same rows
8
+ * with exported = "true" so they are not re-exported on the next run.
9
+ *
10
+ * Output format (one JSON object per line):
11
+ * {"instruction": "<system + task>", "input": "<user prompt>", "output": "<agent output>"}
12
+ *
13
+ * Usage:
14
+ * node helpers/export-training-traces.mjs \
15
+ * --workspace http://localhost:3000 \
16
+ * --traces-object training-traces \
17
+ * --min-score 4 \
18
+ * --out ./antonio/distillation/unsloth-batch-001.jsonl \
19
+ * --instruction "You are growthub-local-expert. Respect AWaC V2 invariants and the PATCH allowlist." \
20
+ * [--dry-run]
21
+ */
22
+
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+
26
+ function parseArgs(argv) {
27
+ const a = {
28
+ workspace: "http://localhost:3000",
29
+ tracesObject: "training-traces",
30
+ minScore: 4,
31
+ out: "",
32
+ instruction: "You are growthub-local-expert. Respect AWaC V2 invariants and the PATCH allowlist.",
33
+ dryRun: false,
34
+ };
35
+ for (let i = 0; i < argv.length; i += 1) {
36
+ const t = argv[i];
37
+ const next = () => String(argv[++i] || "").trim();
38
+ if (t === "--workspace") a.workspace = next().replace(/\/+$/, "");
39
+ else if (t === "--traces-object") a.tracesObject = next();
40
+ else if (t === "--min-score") a.minScore = Number(next()) || 4;
41
+ else if (t === "--out") a.out = next();
42
+ else if (t === "--instruction") a.instruction = next();
43
+ else if (t === "--dry-run") a.dryRun = true;
44
+ else if (t === "--help" || t === "-h") {
45
+ process.stdout.write(
46
+ "Usage: export-training-traces.mjs [--workspace URL] [--traces-object id] [--min-score N] --out <path> [--instruction TEXT] [--dry-run]\n",
47
+ );
48
+ process.exit(0);
49
+ }
50
+ }
51
+ if (!a.out) {
52
+ process.stderr.write("error: --out is required\n");
53
+ process.exit(2);
54
+ }
55
+ return a;
56
+ }
57
+
58
+ const args = parseArgs(process.argv.slice(2));
59
+ const outAbs = path.resolve(args.out);
60
+ fs.mkdirSync(path.dirname(outAbs), { recursive: true });
61
+
62
+ async function getObjects() {
63
+ const r = await fetch(`${args.workspace}/api/workspace`, { cache: "no-store" });
64
+ if (!r.ok) throw new Error(`GET /api/workspace ${r.status}`);
65
+ return (await r.json()).workspaceConfig.dataModel.objects;
66
+ }
67
+ async function patchObjects(objects) {
68
+ const r = await fetch(`${args.workspace}/api/workspace`, {
69
+ method: "PATCH",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify({ dataModel: { objects } }),
72
+ });
73
+ if (!r.ok) throw new Error(`PATCH ${r.status}: ${(await r.text()).slice(0, 300)}`);
74
+ }
75
+
76
+ const objects = await getObjects();
77
+ const tracesIdx = objects.findIndex((o) => o.id === args.tracesObject);
78
+ if (tracesIdx < 0) {
79
+ process.stderr.write(`error: object ${args.tracesObject} not found in workspace\n`);
80
+ process.exit(3);
81
+ }
82
+ const tracesObj = objects[tracesIdx];
83
+ const allRows = Array.isArray(tracesObj.rows) ? tracesObj.rows : [];
84
+
85
+ const eligible = allRows
86
+ .map((row, idx) => ({ row, idx }))
87
+ .filter(({ row }) =>
88
+ Number(row.qualityScore) >= args.minScore &&
89
+ String(row.exported || "false").toLowerCase() !== "true" &&
90
+ String(row.inputPrompt || "").trim() &&
91
+ String(row.agentOutput || "").trim(),
92
+ );
93
+
94
+ if (eligible.length === 0) {
95
+ process.stdout.write(
96
+ JSON.stringify(
97
+ {
98
+ ok: true,
99
+ out: outAbs,
100
+ eligible: 0,
101
+ exported: 0,
102
+ totalRows: allRows.length,
103
+ reason: "no rows match score >= min-score AND exported == false",
104
+ },
105
+ null,
106
+ 2,
107
+ ) + "\n",
108
+ );
109
+ process.exit(0);
110
+ }
111
+
112
+ const outStream = fs.createWriteStream(outAbs, { encoding: "utf8" });
113
+ for (const { row } of eligible) {
114
+ const sample = {
115
+ instruction: args.instruction,
116
+ input: String(row.inputPrompt),
117
+ output: String(row.agentOutput),
118
+ };
119
+ outStream.write(`${JSON.stringify(sample)}\n`);
120
+ }
121
+ await new Promise((r) => outStream.end(r));
122
+
123
+ if (!args.dryRun) {
124
+ const eligibleIdx = new Set(eligible.map((e) => e.idx));
125
+ const updatedRows = allRows.map((row, i) => (eligibleIdx.has(i) ? { ...row, exported: "true" } : row));
126
+ const nextObjects = objects.map((o, i) => (i !== tracesIdx ? o : { ...o, rows: updatedRows }));
127
+ await patchObjects(nextObjects);
128
+ }
129
+
130
+ process.stdout.write(
131
+ JSON.stringify(
132
+ {
133
+ ok: true,
134
+ out: outAbs,
135
+ totalRows: allRows.length,
136
+ eligible: eligible.length,
137
+ exported: args.dryRun ? 0 : eligible.length,
138
+ dryRun: args.dryRun,
139
+ format: "unsloth-jsonl-v1 ({instruction, input, output})",
140
+ },
141
+ null,
142
+ 2,
143
+ ) + "\n",
144
+ );