@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.
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/apply/route.js +307 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/query/route.js +372 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/helper/receipts/route.js +47 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/DataModelShell.jsx +664 -82
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/data-model/components/HelperSidecar.jsx +1371 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +1383 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +7 -21
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/ownership-panel.jsx +222 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/ownership/page.jsx +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/settings-shell.jsx +2 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +116 -24
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-rail.jsx +497 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/growthub.config.json +20 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/sandboxes/default-local-intelligence.js +19 -4
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-data-model.js +23 -5
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper-apply.js +473 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-helper.js +583 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package-lock.json +34 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/package.json +3 -1
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/export-training-traces.mjs +144 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/grade-raw-pairs.mjs +279 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/harvest-cursor-traces.mjs +288 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/helpers/upload-graded-traces.mjs +128 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +19 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/templates/seeded-configs/alignment-loop.config.json +264 -0
- package/assets/worker-kits/growthub-custom-workspace-starter-v1/workers/custom-workspace-operator/CLAUDE.md +38 -0
- package/dist/index.js +1416 -2627
- 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
|
-
{[
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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(() => {
|
|
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
|
-
<
|
|
1566
|
-
<
|
|
1567
|
-
|
|
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
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
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"
|
|
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
|
-
{
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1954
|
-
|
|
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
|
-
}, [
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
2023
|
-
|
|
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
|
|
2038
|
-
<
|
|
2039
|
-
<
|
|
2040
|
-
|
|
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>
|