@growthub/cli 0.12.2 → 0.13.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.
@@ -30,22 +30,41 @@
30
30
  * while keeping the visual treatment identical across every page.
31
31
  */
32
32
 
33
- import { useEffect, useMemo, useRef, useState } from "react";
33
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
34
+ import { createPortal } from "react-dom";
34
35
  import Link from "next/link";
35
36
  import { usePathname, useRouter } from "next/navigation";
36
37
  import {
37
38
  Archive,
38
39
  ChevronDown,
40
+ ChevronRight,
41
+ Folder,
42
+ FolderPlus,
39
43
  Home,
44
+ LayoutDashboard,
40
45
  MessageCircle,
41
46
  MessageCirclePlus,
42
47
  MoreHorizontal,
48
+ MoreVertical,
43
49
  PanelLeftClose,
44
50
  Pencil,
51
+ Plus,
45
52
  Search,
53
+ Settings,
54
+ SlidersHorizontal,
55
+ Table as TableIcon,
46
56
  Trash2,
47
57
  X,
48
58
  } from "lucide-react";
59
+ import {
60
+ NAV_FOLDERS_OBJECT_ID,
61
+ NAV_FOLDER_NAME_MAX,
62
+ NAV_ITEM_LABEL_MAX,
63
+ ensureNavFoldersObject,
64
+ nextNavFolderId,
65
+ nextNavItemId,
66
+ } from "@/lib/workspace-helper-apply";
67
+ import { ICON_PICKER_SET, LucideIcon } from "./data-model/components/dm-shared.jsx";
49
68
 
50
69
  function textColorForAccent(accent) {
51
70
  const hex = String(accent || "").replace("#", "");
@@ -93,6 +112,42 @@ function deriveThreadTitle(row) {
93
112
  return INTENT_LABEL[row?.intent] || "Helper conversation";
94
113
  }
95
114
 
115
+ function getNavFolderRows(workspaceConfig) {
116
+ const obj = (workspaceConfig?.dataModel?.objects || []).find((o) => o?.id === NAV_FOLDERS_OBJECT_ID);
117
+ const rows = Array.isArray(obj?.rows) ? obj.rows : [];
118
+ return rows
119
+ .slice()
120
+ .sort((a, b) => {
121
+ const oa = Number.isFinite(a?.order) ? a.order : 0;
122
+ const ob = Number.isFinite(b?.order) ? b.order : 0;
123
+ return oa - ob;
124
+ });
125
+ }
126
+
127
+ function listAvailableDashboards(workspaceConfig) {
128
+ const dashboards = Array.isArray(workspaceConfig?.dashboards) ? workspaceConfig.dashboards : [];
129
+ return dashboards
130
+ .filter((d) => d && d.id && d.name)
131
+ .map((d) => ({ id: d.id, name: d.name }));
132
+ }
133
+
134
+ function listAvailableObjectsForViews(workspaceConfig) {
135
+ // Mirror the user-facing object set surfaced in the data-model UI:
136
+ // exclude the helper-owned hidden custom objects (helper sandbox,
137
+ // nav-folders). Everything else — including helper-threads and the
138
+ // six core governed business objects — is fair game for a folder
139
+ // view item.
140
+ const HIDDEN = new Set(["workspace-helper-sandbox", NAV_FOLDERS_OBJECT_ID]);
141
+ const objects = Array.isArray(workspaceConfig?.dataModel?.objects) ? workspaceConfig.dataModel.objects : [];
142
+ return objects
143
+ .filter((o) => o && o.id && o.label && !HIDDEN.has(o.id))
144
+ .map((o) => ({
145
+ id: o.id,
146
+ label: o.label,
147
+ columns: Array.isArray(o.columns) ? o.columns : [],
148
+ }));
149
+ }
150
+
96
151
  function getHelperThreadRows(workspaceConfig) {
97
152
  const objects = workspaceConfig?.dataModel?.objects || [];
98
153
  const ht = objects.find((o) => o?.id === "helper-threads");
@@ -103,6 +158,1166 @@ function getHelperThreadRows(workspaceConfig) {
103
158
  .sort((a, b) => String(b.updatedAt || "").localeCompare(String(a.updatedAt || "")));
104
159
  }
105
160
 
161
+ /** Preset swatches for folder / nav-item icon badges (Twenty-style). */
162
+ const NAV_COLOR_SWATCHES = [
163
+ { color: "#f97316", iconBg: "#fff7ed", label: "Orange" },
164
+ { color: "#3b82f6", iconBg: "#eff6ff", label: "Blue" },
165
+ { color: "#14b8a6", iconBg: "#f0fdfa", label: "Teal" },
166
+ { color: "#8b5cf6", iconBg: "#f5f3ff", label: "Violet" },
167
+ { color: "#ec4899", iconBg: "#fdf2f8", label: "Pink" },
168
+ { color: "#64748b", iconBg: "#f8fafc", label: "Slate" },
169
+ ];
170
+
171
+ const NAV_FOLDER_STYLE_DEFAULT = { icon: "Folder", color: "#f97316", iconBg: "#fff7ed" };
172
+ const NAV_ITEM_STYLE_DEFAULT = {
173
+ dashboard: { icon: "LayoutDashboard", color: "#3b82f6", iconBg: "#eff6ff" },
174
+ view: { icon: "Table", color: "#14b8a6", iconBg: "#f0fdfa" },
175
+ };
176
+
177
+ /** Default visible rows before scroll — keeps the rail from growing unbounded. */
178
+ const NAV_MAX_VISIBLE_FOLDERS = 10;
179
+ const NAV_MAX_VISIBLE_ITEMS = 10;
180
+
181
+ function navFolderStyle(folder) {
182
+ return {
183
+ icon: folder?.icon || NAV_FOLDER_STYLE_DEFAULT.icon,
184
+ color: folder?.color || NAV_FOLDER_STYLE_DEFAULT.color,
185
+ iconBg: folder?.iconBg || NAV_FOLDER_STYLE_DEFAULT.iconBg,
186
+ };
187
+ }
188
+
189
+ function navItemStyle(item) {
190
+ const base = NAV_ITEM_STYLE_DEFAULT[item?.type] || NAV_ITEM_STYLE_DEFAULT.view;
191
+ return {
192
+ icon: item?.icon || base.icon,
193
+ color: item?.color || base.color,
194
+ iconBg: item?.iconBg || base.iconBg,
195
+ };
196
+ }
197
+
198
+ function filterNavFolderRows(rows, query, typeFilter) {
199
+ const q = query.trim().toLowerCase();
200
+ const typeActive = typeFilter !== "all";
201
+ if (!q && !typeActive) {
202
+ return rows.map((folder) => ({
203
+ folder,
204
+ items: Array.isArray(folder.items) ? folder.items : [],
205
+ expand: false,
206
+ }));
207
+ }
208
+ return rows.flatMap((folder) => {
209
+ const items = Array.isArray(folder.items) ? folder.items : [];
210
+ const folderNameMatch = !q || String(folder.name || "").toLowerCase().includes(q);
211
+ const filteredItems = items.filter((item) => {
212
+ if (typeActive && item.type !== typeFilter) return false;
213
+ if (!q || folderNameMatch) return true;
214
+ const label = String(item.label || item.refId || item.objectId || "").toLowerCase();
215
+ return label.includes(q);
216
+ });
217
+ if (typeActive && !filteredItems.length && !folderNameMatch) return [];
218
+ if (q && !folderNameMatch && !filteredItems.length) return [];
219
+ return [{
220
+ folder,
221
+ items: typeActive || q ? filteredItems : items,
222
+ expand: Boolean(q || typeActive),
223
+ }];
224
+ }).map((entry, index, list) => {
225
+ if (!entry.expand) return entry;
226
+ const firstExpandIdx = list.findIndex((e) => e.expand);
227
+ if (index !== firstExpandIdx) return { ...entry, expand: false };
228
+ return entry;
229
+ });
230
+ }
231
+
232
+ function hexToTintBg(hex, alpha = 0.1) {
233
+ const h = String(hex || "").replace("#", "");
234
+ if (!/^[0-9a-f]{6}$/i.test(h)) return "#f5f5f5";
235
+ const r = parseInt(h.slice(0, 2), 16);
236
+ const g = parseInt(h.slice(2, 4), 16);
237
+ const b = parseInt(h.slice(4, 6), 16);
238
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
239
+ }
240
+
241
+ function navCustomizeDirty(snapshot, draft) {
242
+ return (
243
+ snapshot.name !== draft.name
244
+ || snapshot.icon !== draft.icon
245
+ || snapshot.color !== draft.color
246
+ || snapshot.iconBg !== draft.iconBg
247
+ );
248
+ }
249
+
250
+ function NavIconBadge({ icon, color, iconBg }) {
251
+ return (
252
+ <span
253
+ className="workspace-rail-nav-icon-badge"
254
+ style={{ background: iconBg, color }}
255
+ >
256
+ <LucideIcon name={icon} size={14} />
257
+ </span>
258
+ );
259
+ }
260
+
261
+ function NavColorPicker({ color, iconBg, onPick }) {
262
+ return (
263
+ <div className="workspace-rail-nav-color-picker">
264
+ <div className="workspace-rail-nav-color-swatches" role="listbox" aria-label="Icon color">
265
+ {NAV_COLOR_SWATCHES.map((swatch) => (
266
+ <button
267
+ key={swatch.color}
268
+ type="button"
269
+ role="option"
270
+ aria-selected={color === swatch.color}
271
+ className={"workspace-rail-nav-color-swatch" + (color === swatch.color ? " active" : "")}
272
+ title={swatch.label}
273
+ style={{ background: swatch.iconBg, color: swatch.color }}
274
+ onClick={() => onPick(swatch)}
275
+ >
276
+ <span className="workspace-rail-nav-color-swatch-dot" style={{ background: swatch.color }} />
277
+ </button>
278
+ ))}
279
+ </div>
280
+ <label className="workspace-rail-nav-color-custom">
281
+ <span>Custom</span>
282
+ <input
283
+ type="color"
284
+ value={color}
285
+ onChange={(e) => {
286
+ const hex = e.target.value;
287
+ onPick({ color: hex, iconBg: hexToTintBg(hex) });
288
+ }}
289
+ />
290
+ </label>
291
+ </div>
292
+ );
293
+ }
294
+
295
+ function NavCustomizePanel({
296
+ nameLabel,
297
+ nameMax,
298
+ draft,
299
+ setDraft,
300
+ discardWarn,
301
+ onSave,
302
+ onCancel,
303
+ }) {
304
+ return (
305
+ <div className="workspace-rail-nav-customize" onClick={(e) => e.stopPropagation()}>
306
+ {discardWarn ? (
307
+ <p className="workspace-rail-nav-discard-warn" role="status">
308
+ Unsaved changes. Click outside again to discard, or save below.
309
+ </p>
310
+ ) : null}
311
+ <label className="workspace-rail-nav-customize-field">
312
+ <span>{nameLabel}</span>
313
+ <input
314
+ type="text"
315
+ value={draft.name}
316
+ maxLength={nameMax}
317
+ onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
318
+ onKeyDown={(e) => {
319
+ if (e.key === "Enter") { e.preventDefault(); onSave(); }
320
+ if (e.key === "Escape") { e.preventDefault(); onCancel(); }
321
+ }}
322
+ />
323
+ </label>
324
+ <span className="workspace-rail-nav-customize-label">Icon</span>
325
+ <div className="dm-icon-picker workspace-rail-nav-icon-picker">
326
+ {ICON_PICKER_SET.map((name) => (
327
+ <button
328
+ key={name}
329
+ type="button"
330
+ className={"dm-icon-picker-btn" + (draft.icon === name ? " active" : "")}
331
+ title={name}
332
+ onClick={() => setDraft((d) => ({ ...d, icon: name }))}
333
+ >
334
+ <LucideIcon name={name} size={16} />
335
+ </button>
336
+ ))}
337
+ </div>
338
+ <span className="workspace-rail-nav-customize-label">Color</span>
339
+ <NavColorPicker
340
+ color={draft.color}
341
+ iconBg={draft.iconBg}
342
+ onPick={(swatch) => setDraft((d) => ({
343
+ ...d,
344
+ color: swatch.color,
345
+ iconBg: swatch.iconBg || d.iconBg,
346
+ }))}
347
+ />
348
+ <div className="workspace-rail-nav-customize-actions">
349
+ <button type="button" className="workspace-rail-nav-btn-ghost" onClick={onCancel}>
350
+ Cancel
351
+ </button>
352
+ <button type="button" className="workspace-rail-nav-btn-primary" onClick={onSave}>
353
+ Save
354
+ </button>
355
+ </div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ function NavCustomizeOverlay({ children, panelRef }) {
361
+ if (typeof document === "undefined") return null;
362
+ return createPortal(
363
+ <div
364
+ className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack is-customize"
365
+ role="menu"
366
+ ref={panelRef}
367
+ >
368
+ {children}
369
+ </div>,
370
+ document.body,
371
+ );
372
+ }
373
+
374
+ function NavFolderPickerOverlay({ children, onClose }) {
375
+ if (typeof document === "undefined") return null;
376
+ return createPortal(
377
+ <div className="workspace-rail-folder-picker-backdrop" onClick={onClose}>
378
+ {children}
379
+ </div>,
380
+ document.body,
381
+ );
382
+ }
383
+
384
+ /**
385
+ * Custom Folders Navigation Module — mirrors Twenty CRM's drag-to-reorder,
386
+ * user-named folders that group Dashboard links and lightweight Views of
387
+ * governed Data Model objects. Persists in the well-known nav-folders
388
+ * Data Model object via the same PATCH allowlist used by the rest of the
389
+ * rail; if the object is absent the section silently shows zero folders.
390
+ */
391
+ function NavFoldersSection({
392
+ workspaceConfig,
393
+ pathname,
394
+ onPatchNavFolders,
395
+ }) {
396
+ const router = useRouter();
397
+ const [creating, setCreating] = useState(false);
398
+ const [createDraft, setCreateDraft] = useState("");
399
+ const [createDiscardWarn, setCreateDiscardWarn] = useState(false);
400
+ const [openMenuId, setOpenMenuId] = useState(null); // folderId or `${folderId}::${itemId}`
401
+ const [menuAnchor, setMenuAnchor] = useState(null);
402
+ const [customizeTarget, setCustomizeTarget] = useState(null);
403
+ const [discardWarn, setDiscardWarn] = useState(false);
404
+ const [addPickerFor, setAddPickerFor] = useState(null); // { folderId, kind: "dashboard"|"view" }
405
+ const [filterQuery, setFilterQuery] = useState("");
406
+ const [filterType, setFilterType] = useState("all"); // all | dashboard | view
407
+ const [filterMenuOpen, setFilterMenuOpen] = useState(false);
408
+ const [sectionCollapsed, setSectionCollapsed] = useState(true);
409
+
410
+ const rows = useMemo(() => getNavFolderRows(workspaceConfig), [workspaceConfig]);
411
+ const dashboards = useMemo(() => listAvailableDashboards(workspaceConfig), [workspaceConfig]);
412
+ const viewableObjects = useMemo(() => listAvailableObjectsForViews(workspaceConfig), [workspaceConfig]);
413
+ const filteredEntries = useMemo(
414
+ () => filterNavFolderRows(rows, filterQuery, filterType),
415
+ [rows, filterQuery, filterType],
416
+ );
417
+ const filterActive = Boolean(filterQuery.trim()) || filterType !== "all";
418
+ const menuWrapRef = useRef(null);
419
+ const filterMenuRef = useRef(null);
420
+ const customizePanelRef = useRef(null);
421
+ const createInputRef = useRef(null);
422
+ const dragState = useRef(null);
423
+
424
+ const closeCustomize = useCallback(() => {
425
+ setCustomizeTarget(null);
426
+ setDiscardWarn(false);
427
+ setOpenMenuId(null);
428
+ setMenuAnchor(null);
429
+ }, []);
430
+
431
+ const openAnchoredMenu = useCallback((menuId, trigger) => {
432
+ const rect = trigger.getBoundingClientRect();
433
+ const menuWidth = 188;
434
+ const top = Math.min(rect.bottom + 4, window.innerHeight - 86);
435
+ const left = Math.max(10, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - 8));
436
+ setMenuAnchor({ top, left });
437
+ setOpenMenuId(menuId);
438
+ }, []);
439
+
440
+ const requestDiscardCustomize = useCallback(() => {
441
+ if (!customizeTarget) return;
442
+ if (!navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft)) {
443
+ closeCustomize();
444
+ return;
445
+ }
446
+ if (!discardWarn) {
447
+ setDiscardWarn(true);
448
+ return;
449
+ }
450
+ closeCustomize();
451
+ }, [customizeTarget, discardWarn, closeCustomize]);
452
+
453
+ useEffect(() => {
454
+ if (!openMenuId && !customizeTarget) return undefined;
455
+ const onPointerDown = (e) => {
456
+ if (menuWrapRef.current?.contains(e.target)) return;
457
+ if (customizePanelRef.current?.contains(e.target)) return;
458
+ if (customizeTarget) {
459
+ requestDiscardCustomize();
460
+ return;
461
+ }
462
+ setOpenMenuId(null);
463
+ setMenuAnchor(null);
464
+ };
465
+ document.addEventListener("pointerdown", onPointerDown);
466
+ return () => document.removeEventListener("pointerdown", onPointerDown);
467
+ }, [openMenuId, customizeTarget, requestDiscardCustomize]);
468
+
469
+ useEffect(() => {
470
+ if (!filterMenuOpen) return undefined;
471
+ const onPointerDown = (e) => {
472
+ if (filterMenuRef.current?.contains(e.target)) return;
473
+ setFilterMenuOpen(false);
474
+ };
475
+ document.addEventListener("pointerdown", onPointerDown);
476
+ return () => document.removeEventListener("pointerdown", onPointerDown);
477
+ }, [filterMenuOpen]);
478
+
479
+ useEffect(() => {
480
+ if (!creating) {
481
+ setCreateDiscardWarn(false);
482
+ return undefined;
483
+ }
484
+ const onPointerDown = (e) => {
485
+ if (createInputRef.current?.contains(e.target)) return;
486
+ const trimmed = createDraft.trim();
487
+ if (!trimmed) {
488
+ setCreating(false);
489
+ setCreateDraft("");
490
+ setCreateDiscardWarn(false);
491
+ return;
492
+ }
493
+ if (!createDiscardWarn) {
494
+ setCreateDiscardWarn(true);
495
+ return;
496
+ }
497
+ setCreating(false);
498
+ setCreateDraft("");
499
+ setCreateDiscardWarn(false);
500
+ };
501
+ document.addEventListener("pointerdown", onPointerDown);
502
+ return () => document.removeEventListener("pointerdown", onPointerDown);
503
+ }, [creating, createDraft, createDiscardWarn]);
504
+
505
+ useEffect(() => {
506
+ if (!addPickerFor) return undefined;
507
+ const onKey = (e) => { if (e.key === "Escape") setAddPickerFor(null); };
508
+ document.addEventListener("keydown", onKey);
509
+ return () => document.removeEventListener("keydown", onKey);
510
+ }, [addPickerFor]);
511
+
512
+ const writeRows = useCallback(async (nextRows) => {
513
+ await onPatchNavFolders(nextRows.map((row, i) => ({ ...row, order: i })));
514
+ }, [onPatchNavFolders]);
515
+
516
+ const createFolder = useCallback(async () => {
517
+ const trimmed = createDraft.trim();
518
+ if (!trimmed) {
519
+ setCreating(false);
520
+ setCreateDraft("");
521
+ setCreateDiscardWarn(false);
522
+ return;
523
+ }
524
+ const name = trimmed.length > NAV_FOLDER_NAME_MAX ? trimmed.slice(0, NAV_FOLDER_NAME_MAX) : trimmed;
525
+ const next = [
526
+ ...rows,
527
+ {
528
+ id: nextNavFolderId(),
529
+ name,
530
+ order: rows.length,
531
+ collapsed: false,
532
+ items: [],
533
+ ...NAV_FOLDER_STYLE_DEFAULT,
534
+ },
535
+ ];
536
+ setCreating(false);
537
+ setCreateDraft("");
538
+ setCreateDiscardWarn(false);
539
+ await writeRows(next);
540
+ }, [createDraft, rows, writeRows]);
541
+
542
+ const startCustomizeFolder = useCallback((folder) => {
543
+ const style = navFolderStyle(folder);
544
+ const snapshot = {
545
+ name: folder.name,
546
+ icon: style.icon,
547
+ color: style.color,
548
+ iconBg: style.iconBg,
549
+ };
550
+ setCustomizeTarget({
551
+ scope: "folder",
552
+ folderId: folder.id,
553
+ snapshot,
554
+ draft: { ...snapshot },
555
+ });
556
+ setDiscardWarn(false);
557
+ setOpenMenuId(folder.id);
558
+ }, []);
559
+
560
+ const startCustomizeItem = useCallback((folder, item) => {
561
+ const style = navItemStyle(item);
562
+ const snapshot = {
563
+ name: item.label || item.refId || item.objectId || "",
564
+ icon: style.icon,
565
+ color: style.color,
566
+ iconBg: style.iconBg,
567
+ };
568
+ const composedId = `${folder.id}::${item.id}`;
569
+ setCustomizeTarget({
570
+ scope: "item",
571
+ folderId: folder.id,
572
+ itemId: item.id,
573
+ snapshot,
574
+ draft: { ...snapshot },
575
+ });
576
+ setDiscardWarn(false);
577
+ setOpenMenuId(composedId);
578
+ }, []);
579
+
580
+ const saveCustomize = useCallback(async () => {
581
+ if (!customizeTarget) return;
582
+ const trimmed = customizeTarget.draft.name.trim();
583
+ if (!trimmed) return;
584
+ if (customizeTarget.scope === "folder") {
585
+ const next = rows.map((row) => (row.id === customizeTarget.folderId
586
+ ? {
587
+ ...row,
588
+ name: trimmed.slice(0, NAV_FOLDER_NAME_MAX),
589
+ icon: customizeTarget.draft.icon,
590
+ color: customizeTarget.draft.color,
591
+ iconBg: customizeTarget.draft.iconBg,
592
+ }
593
+ : row));
594
+ await writeRows(next);
595
+ } else {
596
+ const label = trimmed.slice(0, NAV_ITEM_LABEL_MAX);
597
+ const next = rows.map((row) => {
598
+ if (row.id !== customizeTarget.folderId) return row;
599
+ return {
600
+ ...row,
601
+ items: (row.items || []).map((it) => (it.id === customizeTarget.itemId
602
+ ? {
603
+ ...it,
604
+ label,
605
+ icon: customizeTarget.draft.icon,
606
+ color: customizeTarget.draft.color,
607
+ iconBg: customizeTarget.draft.iconBg,
608
+ }
609
+ : it)),
610
+ };
611
+ });
612
+ await writeRows(next);
613
+ }
614
+ closeCustomize();
615
+ }, [customizeTarget, rows, writeRows, closeCustomize]);
616
+
617
+ const toggleCollapsed = useCallback(async (folderId) => {
618
+ const target = rows.find((row) => row.id === folderId);
619
+ const isOpen = target && !target.collapsed;
620
+ const next = isOpen
621
+ ? rows.map((row) => (row.id === folderId ? { ...row, collapsed: true } : row))
622
+ : rows.map((row) => ({ ...row, collapsed: row.id !== folderId }));
623
+ await writeRows(next);
624
+ }, [rows, writeRows]);
625
+
626
+ const deleteFolder = useCallback(async (folderId) => {
627
+ setOpenMenuId(null);
628
+ await writeRows(rows.filter((row) => row.id !== folderId));
629
+ }, [rows, writeRows]);
630
+
631
+ const deleteItem = useCallback(async (folderId, itemId) => {
632
+ setOpenMenuId(null);
633
+ const next = rows.map((row) => {
634
+ if (row.id !== folderId) return row;
635
+ return { ...row, items: (row.items || []).filter((it) => it.id !== itemId) };
636
+ });
637
+ await writeRows(next);
638
+ }, [rows, writeRows]);
639
+
640
+ const addDashboardItem = useCallback(async (folderId, dashboard) => {
641
+ setAddPickerFor(null);
642
+ setOpenMenuId(null);
643
+ const style = NAV_ITEM_STYLE_DEFAULT.dashboard;
644
+ const item = {
645
+ id: nextNavItemId(),
646
+ type: "dashboard",
647
+ refId: dashboard.id,
648
+ label: dashboard.name,
649
+ icon: style.icon,
650
+ color: style.color,
651
+ iconBg: style.iconBg,
652
+ };
653
+ const next = rows.map((row) => {
654
+ if (row.id !== folderId) return row;
655
+ return { ...row, items: [...(row.items || []), item] };
656
+ });
657
+ await writeRows(next);
658
+ }, [rows, writeRows]);
659
+
660
+ const addViewItem = useCallback(async (folderId, dmObject) => {
661
+ setAddPickerFor(null);
662
+ setOpenMenuId(null);
663
+ const style = NAV_ITEM_STYLE_DEFAULT.view;
664
+ const item = {
665
+ id: nextNavItemId(),
666
+ type: "view",
667
+ objectId: dmObject.id,
668
+ label: dmObject.label,
669
+ icon: style.icon,
670
+ color: style.color,
671
+ iconBg: style.iconBg,
672
+ viewConfig: {
673
+ columns: dmObject.columns,
674
+ },
675
+ };
676
+ const next = rows.map((row) => {
677
+ if (row.id !== folderId) return row;
678
+ return { ...row, items: [...(row.items || []), item] };
679
+ });
680
+ await writeRows(next);
681
+ }, [rows, writeRows]);
682
+
683
+ // ── Drag-and-drop ────────────────────────────────────────────────────
684
+ //
685
+ // HTML5 DnD with a tiny in-ref state machine; mirrors Twenty's
686
+ // reorder-folders + reorder-items + move-between-folders behaviour.
687
+ //
688
+ // - drag a folder row → reorder among folders
689
+ // - drag an item row → reorder inside its folder, or drop into a
690
+ // different folder (folder header or another folder's body)
691
+
692
+ const handleFolderDragStart = (e, folderId) => {
693
+ dragState.current = { kind: "folder", folderId };
694
+ e.dataTransfer.effectAllowed = "move";
695
+ try { e.dataTransfer.setData("text/plain", folderId); } catch { /* noop */ }
696
+ };
697
+
698
+ const handleItemDragStart = (e, folderId, itemId) => {
699
+ dragState.current = { kind: "item", folderId, itemId };
700
+ e.dataTransfer.effectAllowed = "move";
701
+ try { e.dataTransfer.setData("text/plain", `${folderId}::${itemId}`); } catch { /* noop */ }
702
+ e.stopPropagation();
703
+ };
704
+
705
+ const handleDragEnd = () => { dragState.current = null; };
706
+
707
+ const handleDragOver = (e) => {
708
+ if (!dragState.current) return;
709
+ e.preventDefault();
710
+ e.dataTransfer.dropEffect = "move";
711
+ };
712
+
713
+ const handleFolderDrop = async (e, targetFolderId) => {
714
+ if (!dragState.current) return;
715
+ e.preventDefault();
716
+ const drag = dragState.current;
717
+ dragState.current = null;
718
+ if (drag.kind === "folder") {
719
+ if (!drag.folderId || drag.folderId === targetFolderId) return;
720
+ const fromIdx = rows.findIndex((r) => r.id === drag.folderId);
721
+ const toIdx = rows.findIndex((r) => r.id === targetFolderId);
722
+ if (fromIdx < 0 || toIdx < 0) return;
723
+ const next = rows.slice();
724
+ const [moved] = next.splice(fromIdx, 1);
725
+ next.splice(toIdx, 0, moved);
726
+ await writeRows(next);
727
+ return;
728
+ }
729
+ if (drag.kind === "item") {
730
+ if (drag.folderId === targetFolderId) return;
731
+ const next = rows.map((row) => ({ ...row, items: Array.isArray(row.items) ? row.items.slice() : [] }));
732
+ const fromRow = next.find((r) => r.id === drag.folderId);
733
+ const toRow = next.find((r) => r.id === targetFolderId);
734
+ if (!fromRow || !toRow) return;
735
+ const itemIdx = fromRow.items.findIndex((it) => it.id === drag.itemId);
736
+ if (itemIdx < 0) return;
737
+ const [item] = fromRow.items.splice(itemIdx, 1);
738
+ toRow.items.push(item);
739
+ await writeRows(next);
740
+ }
741
+ };
742
+
743
+ const handleItemDrop = async (e, targetFolderId, targetItemId) => {
744
+ if (!dragState.current) return;
745
+ e.preventDefault();
746
+ e.stopPropagation();
747
+ const drag = dragState.current;
748
+ dragState.current = null;
749
+ if (drag.kind !== "item") {
750
+ // A folder dragged onto an item is treated as a folder drop on the
751
+ // target item's folder, keeping behaviour predictable.
752
+ if (drag.kind === "folder") {
753
+ await handleFolderDrop(e, targetFolderId);
754
+ }
755
+ return;
756
+ }
757
+ const next = rows.map((row) => ({ ...row, items: Array.isArray(row.items) ? row.items.slice() : [] }));
758
+ const fromRow = next.find((r) => r.id === drag.folderId);
759
+ const toRow = next.find((r) => r.id === targetFolderId);
760
+ if (!fromRow || !toRow) return;
761
+ const fromIdx = fromRow.items.findIndex((it) => it.id === drag.itemId);
762
+ if (fromIdx < 0) return;
763
+ const [item] = fromRow.items.splice(fromIdx, 1);
764
+ const toIdx = toRow.items.findIndex((it) => it.id === targetItemId);
765
+ const insertAt = toIdx < 0 ? toRow.items.length : toIdx;
766
+ toRow.items.splice(insertAt, 0, item);
767
+ await writeRows(next);
768
+ };
769
+
770
+ const openDashboardItem = (item) => {
771
+ // Dashboards are top-level surfaces; the builder reads the active
772
+ // dashboard from query params if present. Other surfaces simply
773
+ // navigate home; the user lands on the dashboards list. This keeps
774
+ // the rail itself agnostic of surface-specific routing.
775
+ router.push(`/?dashboard=${encodeURIComponent(item.refId)}`);
776
+ };
777
+
778
+ const openViewItem = (item) => {
779
+ router.push(`/data-model?object=${encodeURIComponent(item.objectId || "")}`);
780
+ };
781
+
782
+ const renderItemMenu = (folder, item) => {
783
+ const composedId = `${folder.id}::${item.id}`;
784
+ const isMenuOpen = openMenuId === composedId;
785
+ const isCustomizing = customizeTarget?.scope === "item"
786
+ && customizeTarget.folderId === folder.id
787
+ && customizeTarget.itemId === item.id;
788
+ return (
789
+ <div className="workspace-rail-thread-menu-wrap workspace-rail-nav-menu-wrap"
790
+ ref={isMenuOpen ? menuWrapRef : null}
791
+ >
792
+ <button
793
+ type="button"
794
+ className="workspace-rail-thread-menu-btn workspace-rail-nav-menu-btn"
795
+ aria-label={`Actions for ${item.label || "item"}`}
796
+ aria-haspopup="menu"
797
+ aria-expanded={isMenuOpen}
798
+ onClick={(e) => {
799
+ e.stopPropagation();
800
+ if (isMenuOpen) {
801
+ if (isCustomizing) requestDiscardCustomize();
802
+ else {
803
+ setOpenMenuId(null);
804
+ setMenuAnchor(null);
805
+ }
806
+ } else {
807
+ openAnchoredMenu(composedId, e.currentTarget);
808
+ }
809
+ }}
810
+ >
811
+ <MoreVertical size={14} />
812
+ </button>
813
+ {isMenuOpen && (isCustomizing ? (
814
+ <NavCustomizeOverlay panelRef={customizePanelRef}>
815
+ <NavCustomizePanel
816
+ nameLabel="Display name"
817
+ nameMax={NAV_ITEM_LABEL_MAX}
818
+ draft={customizeTarget.draft}
819
+ setDraft={(updater) => setCustomizeTarget((t) => ({
820
+ ...t,
821
+ draft: typeof updater === "function" ? updater(t.draft) : updater,
822
+ }))}
823
+ discardWarn={discardWarn}
824
+ onSave={saveCustomize}
825
+ onCancel={() => {
826
+ if (navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft) && !discardWarn) {
827
+ setDiscardWarn(true);
828
+ return;
829
+ }
830
+ closeCustomize();
831
+ }}
832
+ />
833
+ </NavCustomizeOverlay>
834
+ ) : (
835
+ <div
836
+ className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack"
837
+ role="menu"
838
+ style={menuAnchor ? { top: `${menuAnchor.top}px`, left: `${menuAnchor.left}px` } : undefined}
839
+ >
840
+ <button
841
+ type="button"
842
+ role="menuitem"
843
+ className="workspace-rail-thread-menu-item"
844
+ onClick={() => startCustomizeItem(folder, item)}
845
+ >
846
+ <Pencil size={13} aria-hidden="true" /> Customize
847
+ </button>
848
+ <button
849
+ type="button"
850
+ role="menuitem"
851
+ className="workspace-rail-thread-menu-item is-destructive"
852
+ onClick={() => deleteItem(folder.id, item.id)}
853
+ >
854
+ <Trash2 size={13} aria-hidden="true" /> Remove
855
+ </button>
856
+ </div>
857
+ ))}
858
+ </div>
859
+ );
860
+ };
861
+
862
+ const renderItemRow = (folder, item) => {
863
+ const composedId = `${folder.id}::${item.id}`;
864
+ const isMenuOpen = openMenuId === composedId;
865
+ const isActive = item.type === "view" && pathname.startsWith(`/views/${encodeURIComponent(item.id)}`);
866
+ const style = navItemStyle(item);
867
+ const typeHint = item.type === "dashboard" ? "Dashboard" : "View";
868
+ return (
869
+ <li
870
+ key={item.id}
871
+ className={`workspace-rail-folder-item workspace-rail-nav-row${isActive ? " is-active" : ""}${isMenuOpen ? " is-menu-open" : ""}`}
872
+ draggable
873
+ onDragStart={(e) => handleItemDragStart(e, folder.id, item.id)}
874
+ onDragEnd={handleDragEnd}
875
+ onDragOver={handleDragOver}
876
+ onDrop={(e) => handleItemDrop(e, folder.id, item.id)}
877
+ >
878
+ <span className="workspace-rail-tree-guide" aria-hidden="true" />
879
+ <div className="workspace-rail-nav-row-body">
880
+ <button
881
+ type="button"
882
+ className="workspace-rail-nav-row-main"
883
+ onClick={() => (item.type === "dashboard" ? openDashboardItem(item) : openViewItem(item))}
884
+ title={`${item.label || item.refId || item.objectId} · ${typeHint}`}
885
+ >
886
+ <NavIconBadge icon={style.icon} color={style.color} iconBg={style.iconBg} />
887
+ <span className="workspace-rail-nav-row-text">
888
+ <span className="workspace-rail-folder-item-label">{item.label || item.refId || item.objectId}</span>
889
+ <span className="workspace-rail-nav-row-meta">{typeHint}</span>
890
+ </span>
891
+ </button>
892
+ {renderItemMenu(folder, item)}
893
+ </div>
894
+ </li>
895
+ );
896
+ };
897
+
898
+ const renderFolderMenu = (folder) => {
899
+ const isMenuOpen = openMenuId === folder.id;
900
+ const isCustomizing = customizeTarget?.scope === "folder" && customizeTarget.folderId === folder.id;
901
+ return (
902
+ <div className="workspace-rail-thread-menu-wrap workspace-rail-nav-menu-wrap"
903
+ ref={isMenuOpen ? menuWrapRef : null}
904
+ >
905
+ <button
906
+ type="button"
907
+ className="workspace-rail-thread-menu-btn workspace-rail-nav-menu-btn"
908
+ aria-label={`Actions for folder ${folder.name}`}
909
+ aria-haspopup="menu"
910
+ aria-expanded={isMenuOpen}
911
+ onClick={(e) => {
912
+ e.stopPropagation();
913
+ if (isMenuOpen) {
914
+ if (isCustomizing) requestDiscardCustomize();
915
+ else {
916
+ setOpenMenuId(null);
917
+ setMenuAnchor(null);
918
+ }
919
+ } else {
920
+ openAnchoredMenu(folder.id, e.currentTarget);
921
+ }
922
+ }}
923
+ >
924
+ <MoreVertical size={14} />
925
+ </button>
926
+ {isMenuOpen && (isCustomizing ? (
927
+ <NavCustomizeOverlay panelRef={customizePanelRef}>
928
+ <NavCustomizePanel
929
+ nameLabel="Folder name"
930
+ nameMax={NAV_FOLDER_NAME_MAX}
931
+ draft={customizeTarget.draft}
932
+ setDraft={(updater) => setCustomizeTarget((t) => ({
933
+ ...t,
934
+ draft: typeof updater === "function" ? updater(t.draft) : updater,
935
+ }))}
936
+ discardWarn={discardWarn}
937
+ onSave={saveCustomize}
938
+ onCancel={() => {
939
+ if (navCustomizeDirty(customizeTarget.snapshot, customizeTarget.draft) && !discardWarn) {
940
+ setDiscardWarn(true);
941
+ return;
942
+ }
943
+ closeCustomize();
944
+ }}
945
+ />
946
+ </NavCustomizeOverlay>
947
+ ) : (
948
+ <div
949
+ className="workspace-rail-thread-menu workspace-rail-nav-menu workspace-rail-nav-menu-stack"
950
+ role="menu"
951
+ style={menuAnchor ? { top: `${menuAnchor.top}px`, left: `${menuAnchor.left}px` } : undefined}
952
+ >
953
+ <button
954
+ type="button"
955
+ role="menuitem"
956
+ className="workspace-rail-thread-menu-item"
957
+ onClick={() => startCustomizeFolder(folder)}
958
+ >
959
+ <Pencil size={13} aria-hidden="true" /> Customize
960
+ </button>
961
+ <button
962
+ type="button"
963
+ role="menuitem"
964
+ className="workspace-rail-thread-menu-item"
965
+ disabled={dashboards.length === 0}
966
+ onClick={() => {
967
+ setOpenMenuId(null);
968
+ setMenuAnchor(null);
969
+ setAddPickerFor({ folderId: folder.id, kind: "dashboard" });
970
+ }}
971
+ >
972
+ <LayoutDashboard size={13} aria-hidden="true" /> Add dashboard
973
+ </button>
974
+ <button
975
+ type="button"
976
+ role="menuitem"
977
+ className="workspace-rail-thread-menu-item"
978
+ disabled={viewableObjects.length === 0}
979
+ onClick={() => {
980
+ setOpenMenuId(null);
981
+ setMenuAnchor(null);
982
+ setAddPickerFor({ folderId: folder.id, kind: "view" });
983
+ }}
984
+ >
985
+ <TableIcon size={13} aria-hidden="true" /> Add view
986
+ </button>
987
+ <button
988
+ type="button"
989
+ role="menuitem"
990
+ className="workspace-rail-thread-menu-item is-destructive"
991
+ onClick={() => deleteFolder(folder.id)}
992
+ >
993
+ <Trash2 size={13} aria-hidden="true" /> Delete
994
+ </button>
995
+ </div>
996
+ ))}
997
+ </div>
998
+ );
999
+ };
1000
+
1001
+ const renderFolder = (entry) => {
1002
+ const { folder, items, expand: forceExpand } = entry;
1003
+ const isMenuOpen = openMenuId === folder.id;
1004
+ const isCustomizing = customizeTarget?.scope === "folder" && customizeTarget.folderId === folder.id;
1005
+ const collapsed = Boolean(folder.collapsed) && !forceExpand;
1006
+ const isExpanded = !collapsed;
1007
+ const style = navFolderStyle(folder);
1008
+ const visibleItems = items;
1009
+ const itemOverflow = visibleItems.length > NAV_MAX_VISIBLE_ITEMS;
1010
+ return (
1011
+ <li
1012
+ key={folder.id}
1013
+ className={
1014
+ "workspace-rail-folder workspace-rail-nav-row"
1015
+ + (isExpanded ? " is-expanded" : "")
1016
+ + (isMenuOpen ? " is-menu-open" : "")
1017
+ }
1018
+ draggable={!isCustomizing}
1019
+ onDragStart={(e) => handleFolderDragStart(e, folder.id)}
1020
+ onDragEnd={handleDragEnd}
1021
+ onDragOver={handleDragOver}
1022
+ onDrop={(e) => handleFolderDrop(e, folder.id)}
1023
+ >
1024
+ <div className="workspace-rail-nav-row-body workspace-rail-folder-header">
1025
+ <button
1026
+ type="button"
1027
+ className="workspace-rail-nav-row-main workspace-rail-folder-toggle"
1028
+ aria-expanded={isExpanded}
1029
+ aria-label={collapsed ? `Expand ${folder.name}` : `Collapse ${folder.name}`}
1030
+ onClick={() => toggleCollapsed(folder.id)}
1031
+ >
1032
+ <NavIconBadge icon={style.icon} color={style.color} iconBg={style.iconBg} />
1033
+ <span className="workspace-rail-folder-name">{folder.name}</span>
1034
+ </button>
1035
+ {renderFolderMenu(folder)}
1036
+ </div>
1037
+ <div
1038
+ className="workspace-rail-folder-accordion-panel"
1039
+ aria-hidden={collapsed}
1040
+ >
1041
+ <div className="workspace-rail-folder-accordion-inner">
1042
+ <ul
1043
+ className={"workspace-rail-folder-items" + (itemOverflow ? " is-scrollable" : "")}
1044
+ role="list"
1045
+ aria-label={`Items in ${folder.name}`}
1046
+ >
1047
+ {visibleItems.length === 0 ? (
1048
+ <li className="workspace-rail-folder-empty-spacer" aria-hidden="true" />
1049
+ ) : (
1050
+ visibleItems.map((item) => renderItemRow(folder, item))
1051
+ )}
1052
+ </ul>
1053
+ </div>
1054
+ </div>
1055
+ </li>
1056
+ );
1057
+ };
1058
+
1059
+ const picker = addPickerFor ? (
1060
+ <NavFolderPickerOverlay onClose={() => setAddPickerFor(null)}>
1061
+ <div className="workspace-rail-folder-picker" onClick={(e) => e.stopPropagation()}>
1062
+ <div className="workspace-rail-folder-picker-head">
1063
+ <strong>{addPickerFor.kind === "dashboard" ? "Add dashboard" : "Add view"}</strong>
1064
+ <button
1065
+ type="button"
1066
+ className="workspace-rail-folder-picker-close"
1067
+ aria-label="Close picker"
1068
+ onClick={() => setAddPickerFor(null)}
1069
+ >
1070
+ <X size={14} />
1071
+ </button>
1072
+ </div>
1073
+ <ul className="workspace-rail-folder-picker-list" role="list">
1074
+ {addPickerFor.kind === "dashboard"
1075
+ ? dashboards.map((d) => (
1076
+ <li key={d.id}>
1077
+ <button
1078
+ type="button"
1079
+ className="workspace-rail-folder-picker-item"
1080
+ onClick={() => addDashboardItem(addPickerFor.folderId, d)}
1081
+ >
1082
+ <NavIconBadge
1083
+ icon={NAV_ITEM_STYLE_DEFAULT.dashboard.icon}
1084
+ color={NAV_ITEM_STYLE_DEFAULT.dashboard.color}
1085
+ iconBg={NAV_ITEM_STYLE_DEFAULT.dashboard.iconBg}
1086
+ />
1087
+ <span>{d.name}</span>
1088
+ </button>
1089
+ </li>
1090
+ ))
1091
+ : viewableObjects.map((o) => (
1092
+ <li key={o.id}>
1093
+ <button
1094
+ type="button"
1095
+ className="workspace-rail-folder-picker-item"
1096
+ onClick={() => addViewItem(addPickerFor.folderId, o)}
1097
+ >
1098
+ <NavIconBadge
1099
+ icon={NAV_ITEM_STYLE_DEFAULT.view.icon}
1100
+ color={NAV_ITEM_STYLE_DEFAULT.view.color}
1101
+ iconBg={NAV_ITEM_STYLE_DEFAULT.view.iconBg}
1102
+ />
1103
+ <span>{o.label}</span>
1104
+ <span className="workspace-rail-folder-picker-hint">{o.columns.length} field{o.columns.length === 1 ? "" : "s"}</span>
1105
+ </button>
1106
+ </li>
1107
+ ))}
1108
+ </ul>
1109
+ </div>
1110
+ </NavFolderPickerOverlay>
1111
+ ) : null;
1112
+
1113
+ const folderOverflow = filteredEntries.length > NAV_MAX_VISIBLE_FOLDERS;
1114
+
1115
+ return (
1116
+ <div
1117
+ className={"workspace-rail-folders" + (sectionCollapsed ? " is-section-collapsed" : "")}
1118
+ aria-label="Custom folders"
1119
+ >
1120
+ <div className="workspace-rail-folders-head">
1121
+ <button
1122
+ type="button"
1123
+ className="workspace-rail-folders-section-toggle"
1124
+ aria-expanded={!sectionCollapsed}
1125
+ onClick={(e) => {
1126
+ e.currentTarget.blur();
1127
+ setSectionCollapsed((v) => !v);
1128
+ }}
1129
+ >
1130
+ <span className="workspace-rail-section-label">Folders</span>
1131
+ {sectionCollapsed
1132
+ ? <ChevronRight size={12} className="workspace-rail-folders-section-chevron" aria-hidden="true" />
1133
+ : <ChevronDown size={12} className="workspace-rail-folders-section-chevron" aria-hidden="true" />}
1134
+ </button>
1135
+ <button
1136
+ type="button"
1137
+ className="workspace-rail-folders-add-btn"
1138
+ aria-label="Create folder"
1139
+ title="New folder"
1140
+ onClick={() => {
1141
+ setSectionCollapsed(false);
1142
+ setCreating(true);
1143
+ setCreateDraft("");
1144
+ }}
1145
+ >
1146
+ <FolderPlus size={13} aria-hidden="true" />
1147
+ </button>
1148
+ </div>
1149
+ {!sectionCollapsed ? (
1150
+ <>
1151
+ <div className="workspace-rail-folders-filters">
1152
+ <div className="workspace-rail-folders-search">
1153
+ <Search size={12} aria-hidden="true" />
1154
+ <input
1155
+ type="search"
1156
+ className="workspace-rail-folders-search-input"
1157
+ placeholder="Filter folders & views"
1158
+ value={filterQuery}
1159
+ onChange={(e) => setFilterQuery(e.target.value)}
1160
+ aria-label="Filter folders and views by name"
1161
+ />
1162
+ {filterQuery ? (
1163
+ <button
1164
+ type="button"
1165
+ className="workspace-rail-chat-search-clear"
1166
+ onClick={() => setFilterQuery("")}
1167
+ aria-label="Clear filter"
1168
+ >
1169
+ <X size={11} />
1170
+ </button>
1171
+ ) : null}
1172
+ </div>
1173
+ <div className="workspace-rail-folders-filter-menu-wrap" ref={filterMenuRef}>
1174
+ <button
1175
+ type="button"
1176
+ className={
1177
+ "workspace-rail-folders-filter-btn"
1178
+ + (filterMenuOpen ? " is-open" : "")
1179
+ + (filterActive ? " has-live-state" : "")
1180
+ + (rows.length === 0 ? " is-disabled" : "")
1181
+ }
1182
+ aria-label="Folder display options"
1183
+ aria-haspopup="menu"
1184
+ aria-expanded={filterMenuOpen}
1185
+ aria-disabled={rows.length === 0}
1186
+ title={rows.length === 0
1187
+ ? "No folders yet. Create one to organize dashboards and table views."
1188
+ : "Folder display options"}
1189
+ onClick={(e) => {
1190
+ e.currentTarget.blur();
1191
+ if (rows.length === 0) return;
1192
+ setFilterMenuOpen((v) => !v);
1193
+ }}
1194
+ >
1195
+ <SlidersHorizontal size={14} aria-hidden="true" />
1196
+ {filterActive ? <span className="workspace-rail-folders-filter-state-dot" aria-hidden="true" /> : null}
1197
+ </button>
1198
+ {filterMenuOpen && rows.length > 0 ? (
1199
+ <div className="workspace-rail-folders-filter-menu" role="menu">
1200
+ <div className="workspace-rail-folders-filter-menu-group">
1201
+ <p className="workspace-rail-folders-filter-menu-label">Type</p>
1202
+ {[
1203
+ { id: "all", label: "All" },
1204
+ { id: "dashboard", label: "Dashboards" },
1205
+ { id: "view", label: "Views" },
1206
+ ].map((opt) => (
1207
+ <button
1208
+ key={opt.id}
1209
+ type="button"
1210
+ role="menuitemradio"
1211
+ aria-checked={filterType === opt.id}
1212
+ className="workspace-rail-folders-filter-menu-item"
1213
+ onClick={() => {
1214
+ setFilterType(opt.id);
1215
+ setFilterMenuOpen(false);
1216
+ }}
1217
+ >
1218
+ <span>{opt.label}</span>
1219
+ <span className="workspace-rail-folders-filter-menu-value">
1220
+ {filterType === opt.id ? "Active" : ""}
1221
+ </span>
1222
+ <ChevronRight size={13} aria-hidden="true" />
1223
+ </button>
1224
+ ))}
1225
+ </div>
1226
+ {filterActive ? (
1227
+ <div className="workspace-rail-folders-filter-menu-group">
1228
+ <button
1229
+ type="button"
1230
+ role="menuitem"
1231
+ className="workspace-rail-folders-filter-menu-item is-reset"
1232
+ onClick={() => {
1233
+ setFilterQuery("");
1234
+ setFilterType("all");
1235
+ setFilterMenuOpen(false);
1236
+ }}
1237
+ >
1238
+ <span>Clear folder config</span>
1239
+ <span className="workspace-rail-folders-filter-menu-value">Reset</span>
1240
+ <X size={13} aria-hidden="true" />
1241
+ </button>
1242
+ </div>
1243
+ ) : null}
1244
+ <div className="workspace-rail-folders-filter-menu-group">
1245
+ <button type="button" role="menuitem" className="workspace-rail-folders-filter-menu-item">
1246
+ <span>Group by</span>
1247
+ <span className="workspace-rail-folders-filter-menu-value">Folder</span>
1248
+ <ChevronRight size={13} aria-hidden="true" />
1249
+ </button>
1250
+ <button type="button" role="menuitem" className="workspace-rail-folders-filter-menu-item">
1251
+ <span>Sort by</span>
1252
+ <span className="workspace-rail-folders-filter-menu-value">Custom order</span>
1253
+ <ChevronRight size={13} aria-hidden="true" />
1254
+ </button>
1255
+ </div>
1256
+ </div>
1257
+ ) : null}
1258
+ </div>
1259
+ </div>
1260
+ {creating ? (
1261
+ <div className="workspace-rail-folder-create">
1262
+ <NavIconBadge
1263
+ icon={NAV_FOLDER_STYLE_DEFAULT.icon}
1264
+ color={NAV_FOLDER_STYLE_DEFAULT.color}
1265
+ iconBg={NAV_FOLDER_STYLE_DEFAULT.iconBg}
1266
+ />
1267
+ <input
1268
+ ref={createInputRef}
1269
+ autoFocus
1270
+ className="workspace-rail-thread-rename"
1271
+ value={createDraft}
1272
+ onChange={(e) => {
1273
+ setCreateDraft(e.target.value);
1274
+ setCreateDiscardWarn(false);
1275
+ }}
1276
+ placeholder="Folder name"
1277
+ onKeyDown={(e) => {
1278
+ if (e.key === "Enter") { e.preventDefault(); createFolder(); }
1279
+ if (e.key === "Escape") {
1280
+ setCreating(false);
1281
+ setCreateDraft("");
1282
+ setCreateDiscardWarn(false);
1283
+ }
1284
+ }}
1285
+ />
1286
+ <button type="button" className="workspace-rail-nav-btn-primary is-compact" onClick={createFolder}>
1287
+ Save
1288
+ </button>
1289
+ {createDiscardWarn ? (
1290
+ <p className="workspace-rail-nav-discard-warn is-inline" role="status">
1291
+ Click outside again to discard
1292
+ </p>
1293
+ ) : null}
1294
+ </div>
1295
+ ) : null}
1296
+ {rows.length === 0 && !creating ? null : filteredEntries.length === 0 ? (
1297
+ <p className="workspace-rail-folders-empty">No folders or views match this filter.</p>
1298
+ ) : (
1299
+ <div
1300
+ className={"workspace-rail-folders-scroll" + (folderOverflow ? " is-scrollable" : "")}
1301
+ role="region"
1302
+ aria-label="Folder list"
1303
+ >
1304
+ <ul className="workspace-rail-folders-list" role="list">
1305
+ {filteredEntries.map(renderFolder)}
1306
+ </ul>
1307
+ {folderOverflow ? (
1308
+ <p className="workspace-rail-folders-scroll-hint">
1309
+ {filteredEntries.length} folders · scroll for more
1310
+ </p>
1311
+ ) : null}
1312
+ </div>
1313
+ )}
1314
+ {picker}
1315
+ </>
1316
+ ) : null}
1317
+ </div>
1318
+ );
1319
+ }
1320
+
106
1321
  export function WorkspaceRail({
107
1322
  workspaceConfig,
108
1323
  authority,
@@ -124,6 +1339,7 @@ export function WorkspaceRail({
124
1339
  const router = useRouter();
125
1340
 
126
1341
  const [activeTab, setActiveTab] = useState("home");
1342
+ const [railCollapsed, setRailCollapsed] = useState(false);
127
1343
  const [openMenuId, setOpenMenuId] = useState(null);
128
1344
  const [renamingId, setRenamingId] = useState(null);
129
1345
  const [renameDraft, setRenameDraft] = useState("");
@@ -144,6 +1360,12 @@ export function WorkspaceRail({
144
1360
 
145
1361
  const threads = useMemo(() => getHelperThreadRows(workspaceConfig), [workspaceConfig]);
146
1362
 
1363
+ useEffect(() => {
1364
+ if (typeof document === "undefined") return undefined;
1365
+ document.body.classList.toggle("workspace-rail-collapsed", railCollapsed);
1366
+ return () => document.body.classList.remove("workspace-rail-collapsed");
1367
+ }, [railCollapsed]);
1368
+
147
1369
  const handleAskHelperClick = () => {
148
1370
  if (onOpenHelper) {
149
1371
  onOpenHelper();
@@ -160,6 +1382,31 @@ export function WorkspaceRail({
160
1382
  router.push(`/data-model?thread=${encodeURIComponent(row.id)}`);
161
1383
  };
162
1384
 
1385
+ async function patchNavFolders(updatedRows) {
1386
+ const seeded = ensureNavFoldersObject(workspaceConfig);
1387
+ const dm = seeded.dataModel;
1388
+ const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
1389
+ const idx = objects.findIndex((o) => o?.id === NAV_FOLDERS_OBJECT_ID);
1390
+ if (idx === -1) return;
1391
+ objects[idx] = { ...objects[idx], rows: updatedRows };
1392
+ const nextDataModel = { ...dm, objects };
1393
+ try {
1394
+ const res = await fetch("/api/workspace", {
1395
+ method: "PATCH",
1396
+ headers: { "content-type": "application/json" },
1397
+ body: JSON.stringify({ dataModel: nextDataModel }),
1398
+ });
1399
+ if (res.ok) {
1400
+ const body = await res.json().catch(() => ({}));
1401
+ if (body?.workspaceConfig && onConfigChange) {
1402
+ onConfigChange(body.workspaceConfig);
1403
+ }
1404
+ }
1405
+ } catch {
1406
+ // Best-effort: read-only runtimes return 409; the user can retry.
1407
+ }
1408
+ }
1409
+
163
1410
  async function patchHelperThreads(updatedRows) {
164
1411
  const dm = workspaceConfig?.dataModel || {};
165
1412
  const objects = Array.isArray(dm.objects) ? dm.objects.slice() : [];
@@ -215,7 +1462,7 @@ export function WorkspaceRail({
215
1462
  };
216
1463
 
217
1464
  return (
218
- <aside className="workspace-rail" aria-label="Workspace navigation">
1465
+ <aside className={"workspace-rail" + (railCollapsed ? " is-collapsed" : "")} aria-label="Workspace navigation">
219
1466
  {/* Row 1: brand + utility actions */}
220
1467
  <div className="workspace-rail-topbar">
221
1468
  <button type="button" className="workspace-rail-brand-button" aria-label={`Workspace ${workspaceName}`}>
@@ -252,11 +1499,22 @@ export function WorkspaceRail({
252
1499
  <button
253
1500
  type="button"
254
1501
  className="workspace-rail-icon-btn"
255
- aria-label="Collapse sidebar"
256
- title="Collapse sidebar"
1502
+ aria-label={railCollapsed ? "Expand sidebar" : "Collapse sidebar"}
1503
+ title={railCollapsed ? "Expand sidebar" : "Collapse sidebar"}
1504
+ aria-pressed={railCollapsed}
1505
+ onClick={() => setRailCollapsed((v) => !v)}
257
1506
  >
258
1507
  <PanelLeftClose size={13} />
259
1508
  </button>
1509
+ <button
1510
+ type="button"
1511
+ className="workspace-rail-icon-btn"
1512
+ aria-label="Workspace settings"
1513
+ title="Workspace settings"
1514
+ onClick={() => router.push("/settings/general")}
1515
+ >
1516
+ <Settings size={13} />
1517
+ </button>
260
1518
  </div>
261
1519
  </div>
262
1520
 
@@ -300,6 +1558,19 @@ export function WorkspaceRail({
300
1558
  </button>
301
1559
  </div>
302
1560
 
1561
+ {/* Custom Folders Navigation Module — sits directly below the tab
1562
+ row and above both the Home nav items and the Chat thread list,
1563
+ mirroring Twenty CRM's drag-to-reorder sidebar folders. The
1564
+ module is fully backwards compatible: with no `nav-folders`
1565
+ object (or zero rows) it shows a thin empty-state hint and
1566
+ renders nothing in the body of the rail it would otherwise
1567
+ push down. */}
1568
+ <NavFoldersSection
1569
+ workspaceConfig={workspaceConfig}
1570
+ pathname={pathname}
1571
+ onPatchNavFolders={patchNavFolders}
1572
+ />
1573
+
303
1574
  {/* Body: switches by tab. The legacy `Management` nav item now
304
1575
  lives as the 4th Workspace Settings tab (`/settings/ownership`).
305
1576
  The Data Model link is renamed to `Management` since the data