PresetNavigationBar.tsx27.2 KBView on GitHub /**
* PresetNavigationBar Component
*
* Renders inside the "Fields" popover of CanvasFilterSearchSection.
* Layout:
* [ Columns | Presets ] tabs
*
* - "Columns" tab: live column visibility checkboxes for the active canvas.
* - "Presets" tab: preset buttons in a vertical list + "Add preset" at the bottom.
* - Keyboard navigation (1-5, left/right): always active via PresetHotkeysLayer,
* which must be mounted in the canvas view (not just when this popover is open).
*/
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import type { CanvasPreset, ConversationViewConfig } from '@/modules/canvas/types/canvas-types';
import Container3D from '@/modules/cedar-os/src/cedar-os-components/containers/Container3D';
import { useCedarStore } from '@/modules/store';
import { useGlobalCanvasPresets } from '../../hooks/use-global-canvas-presets';
import { Pencil, Plus, Search, Trash2 } from 'lucide-react';
import { motion } from 'motion/react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useCanvasConfiguration } from '../../hooks/use-canvas-configuration';
import { BUILTIN_CANVAS_PRESETS } from './ColumnConfigurationItem';
// ─── Shared helpers ───────────────────────────────────────────────────────────
type ColumnMeta = {
id: string;
name?: string;
type: string;
visible?: boolean;
parentColumnId?: string;
signalDimension?: 'signal';
};
function getColumnLabel(column: ColumnMeta): string {
if (column.type === 'company-combined') return 'Company';
if (column.type === 'current-action') return 'Suggested Action';
if (column.type === 'future-action') return 'Future Action';
return column.name ?? column.id;
}
// ─── Shared preset logic hook ─────────────────────────────────────────────────
function useCanvasPresets(canvasId?: string) {
const { columns: visibleColumns, allColumns } = useCanvasConfiguration(canvasId);
const {
globalPresets,
hasGlobalPresets,
upsertGlobalPreset,
removeGlobalPreset,
syncActivePresetColumns,
} = useGlobalCanvasPresets();
const canvasViewConfig = useCedarStore((state) => {
if (!canvasId) return null;
return state.canvasesById[canvasId]?.viewConfig as ConversationViewConfig | null | undefined;
});
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const activePresets: CanvasPreset[] = hasGlobalPresets ? globalPresets : BUILTIN_CANVAS_PRESETS;
const applyPreset = useCallback(
(preset: CanvasPreset) => {
if (!canvasId || !canvasViewConfig) return;
const visibleSet = new Set(preset.columnIds);
const orderMap = new Map(preset.columnIds.map((id, idx) => [id, idx + 1]));
const currentConfig = canvasViewConfig.columnConfiguration ?? {};
const newColumnConfig = { ...currentConfig };
allColumns
.filter(
(col) =>
col.type !== 'timeline' && col.type !== 'working-memory' && col.id !== 'wm_custom',
)
.forEach((col) => {
newColumnConfig[col.id] = {
...newColumnConfig[col.id],
visible: col.id === 'primaryCompany' ? true : visibleSet.has(col.id),
order: col.id === 'primaryCompany' ? 0 : (orderMap.get(col.id) ?? 999),
};
});
updateCanvasViewConfig(canvasId, {
...canvasViewConfig,
presetId: preset.id,
columnConfiguration: newColumnConfig,
expanded: preset.expanded ?? false,
});
saveCanvasViewConfig(canvasId);
},
[canvasId, canvasViewConfig, allColumns, updateCanvasViewConfig, saveCanvasViewConfig],
);
const applyPresetByIndex = useCallback(
(index: number) => {
if (index >= 0 && index < activePresets.length) applyPreset(activePresets[index]);
},
[applyPreset, activePresets],
);
const isPresetActive = useCallback(
(preset: CanvasPreset) => {
const visibleColIds = visibleColumns
.filter(
(col) =>
col.type !== 'timeline' &&
col.type !== 'working-memory' &&
col.id !== 'wm_custom' &&
col.id !== 'primaryCompany',
)
.map((col) => col.id);
if (visibleColIds.length !== preset.columnIds.length) return false;
return preset.columnIds.every((id) => visibleColIds.includes(id));
},
[visibleColumns],
);
const getActivePresetIndex = useCallback(
() => activePresets.findIndex((preset) => isPresetActive(preset)),
[isPresetActive, activePresets],
);
const navigateToPreviousPreset = useCallback(() => {
const currentIndex = getActivePresetIndex();
applyPreset(activePresets[currentIndex <= 0 ? activePresets.length - 1 : currentIndex - 1]);
}, [getActivePresetIndex, applyPreset, activePresets]);
const navigateToNextPreset = useCallback(() => {
const currentIndex = getActivePresetIndex();
applyPreset(activePresets[currentIndex >= activePresets.length - 1 ? 0 : currentIndex + 1]);
}, [getActivePresetIndex, applyPreset, activePresets]);
return {
activePresets,
visibleColumns,
allColumns,
canvasViewConfig,
applyPreset,
applyPresetByIndex,
isPresetActive,
navigateToPreviousPreset,
navigateToNextPreset,
upsertGlobalPreset,
removeGlobalPreset,
syncActivePresetColumns,
updateCanvasViewConfig,
saveCanvasViewConfig,
};
}
// ─── PresetHotkeysLayer ───────────────────────────────────────────────────────
// Always-mounted component that keeps preset hotkeys active regardless of
// whether the Fields popover is open. Place this in ConversationCanvas /
// GroupedConversationCanvas alongside ConversationListHotkeys.
export function PresetHotkeysLayer({ canvasId }: { canvasId?: string }) {
const { applyPresetByIndex, navigateToPreviousPreset, navigateToNextPreset } =
useCanvasPresets(canvasId);
useHotkeys('left', navigateToPreviousPreset, { preventDefault: true }, [
navigateToPreviousPreset,
]);
useHotkeys('right', navigateToNextPreset, { preventDefault: true }, [navigateToNextPreset]);
useHotkeys('1', () => applyPresetByIndex(0), { preventDefault: true }, [applyPresetByIndex]);
useHotkeys('2', () => applyPresetByIndex(1), { preventDefault: true }, [applyPresetByIndex]);
useHotkeys('3', () => applyPresetByIndex(2), { preventDefault: true }, [applyPresetByIndex]);
useHotkeys('4', () => applyPresetByIndex(3), { preventDefault: true }, [applyPresetByIndex]);
useHotkeys('5', () => applyPresetByIndex(4), { preventDefault: true }, [applyPresetByIndex]);
return null;
}
// ─── Columns tab content ──────────────────────────────────────────────────────
function ColumnsTabContent({
allColumns,
onToggleColumn,
}: {
allColumns: ColumnMeta[];
onToggleColumn: (id: string) => void;
}) {
const [columnSearch, setColumnSearch] = useState('');
const searchInputRef = useRef<HTMLInputElement>(null);
const editableColumns = allColumns.filter(
(col) =>
col.type !== 'timeline' &&
col.type !== 'working-memory' &&
col.type !== 'score' &&
col.id !== 'wm_custom' &&
col.id !== 'primaryCompany',
);
const scoreColumns = allColumns.filter((col) => col.type === 'score');
const filteredColumns = useMemo(() => {
if (!columnSearch.trim()) return editableColumns;
const q = columnSearch.toLowerCase();
return editableColumns.filter((col) => getColumnLabel(col).toLowerCase().includes(q));
}, [editableColumns, columnSearch]);
return (
<>
<div className="mb-1 flex items-center gap-2 rounded-md bg-muted/50 px-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Input
ref={searchInputRef}
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredColumns.length > 0) {
e.preventDefault();
onToggleColumn(filteredColumns[0].id);
setColumnSearch('');
}
}}
className="h-7 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
placeholder="Search columns..."
/>
</div>
<div className="max-h-[50vh] overflow-y-auto px-1 py-0.5">
{filteredColumns.map((column, idx) => {
const isChecked = column.visible !== false;
const label = getColumnLabel(column);
const isFirstMatch = idx === 0 && columnSearch.trim().length > 0;
const childScores = scoreColumns.filter((sc) => sc.parentColumnId === column.id);
return (
<div key=[redacted]
<div
className={cn(
'flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/50',
isFirstMatch && 'bg-muted/50',
)}
onClick={() => onToggleColumn(column.id)}
>
<Checkbox checked={isChecked} tabIndex={-1} className="pointer-events-none" />
<span className="flex-1 cursor-pointer text-sm">{label}</span>
</div>
{childScores.map((scoreCol) => {
const scoreVisible = allColumns.find((c) => c.id === scoreCol.id)?.visible ?? false;
return (
<div
key=[redacted]
className="flex cursor-pointer items-center gap-2.5 rounded-md py-1.5 pl-9 pr-2 hover:bg-muted/50"
onClick={() => onToggleColumn(scoreCol.id)}
>
<Checkbox
checked={scoreVisible}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex-1 cursor-pointer text-sm text-muted-foreground">
Signal
</span>
</div>
);
})}
</div>
);
})}
</div>
</>
);
}
// ─── Preset Editor Popover ────────────────────────────────────────────────────
interface PresetEditorPopoverProps {
preset: CanvasPreset;
allColumns: ColumnMeta[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (updated: CanvasPreset) => void;
onDelete: () => void;
onOpen: () => void;
onExpandedChange: (expanded: boolean) => void;
onToggleColumn: (colId: string) => void;
getOrderedColumnIds: () => string[];
children: React.ReactNode;
}
export const PresetEditorPopover = memo(function PresetEditorPopover({
preset,
allColumns,
open,
onOpenChange,
onSave,
onDelete,
onOpen,
onExpandedChange,
onToggleColumn,
getOrderedColumnIds,
children,
}: PresetEditorPopoverProps) {
const [name, setName] = useState(preset.name);
const [expanded, setExpanded] = useState(preset.expanded ?? false);
const [columnSearch, setColumnSearch] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const handleOpenChange = useCallback(
(nextOpen: boolean) => {
if (nextOpen) {
setName(preset.name);
setExpanded(preset.expanded ?? false);
setColumnSearch('');
onOpen();
setTimeout(() => inputRef.current?.focus(), 50);
} else {
const currentColumnIds = getOrderedColumnIds();
const trimmedName = name.trim() || preset.name;
onSave({ ...preset, name: trimmedName, columnIds: currentColumnIds, expanded });
}
onOpenChange(nextOpen);
},
[preset, name, expanded, onSave, onOpen, onOpenChange, getOrderedColumnIds],
);
const editableColumns = allColumns.filter(
(col) =>
col.type !== 'timeline' &&
col.type !== 'working-memory' &&
col.type !== 'score' &&
col.id !== 'wm_custom' &&
col.id !== 'primaryCompany',
);
const scoreColumns = allColumns.filter((col) => col.type === 'score');
const filteredEditableColumns = useMemo(() => {
if (!columnSearch.trim()) return editableColumns;
const q = columnSearch.toLowerCase();
return editableColumns.filter((col) => getColumnLabel(col).toLowerCase().includes(q));
}, [editableColumns, columnSearch]);
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>{children}</PopoverAnchor>
<PopoverContent className="w-80 p-0" align="start" onClick={(e) => e.stopPropagation()}>
{/* Header: editable name */}
<div className="border-b px-3 py-2.5">
<Input
ref={inputRef}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
}}
className="h-7 border-0 bg-transparent p-0 text-sm font-semibold shadow-none focus-visible:ring-0"
placeholder="Preset name..."
/>
</div>
{/* Expanded toggle */}
<div className="border-b px-1 py-1.5">
<div className="flex items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/50">
<Checkbox
id="pe-expanded"
checked={expanded}
onCheckedChange={(v) => {
const next = !!v;
setExpanded(next);
onExpandedChange(next);
}}
/>
<label htmlFor="pe-expanded" className="flex-1 cursor-pointer select-none text-sm">
Expanded view
</label>
</div>
</div>
{/* Column search */}
<div className="border-b px-2 py-1.5">
<div className="flex items-center gap-2 rounded-md bg-muted/50 px-2">
<Search className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Input
ref={searchInputRef}
value={columnSearch}
onChange={(e) => setColumnSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredEditableColumns.length > 0) {
e.preventDefault();
onToggleColumn(filteredEditableColumns[0].id);
setColumnSearch('');
}
}}
className="h-7 border-0 bg-transparent p-0 text-sm shadow-none focus-visible:ring-0"
placeholder="Search columns..."
/>
</div>
</div>
{/* Column selection */}
<div className="max-h-[50vh] overflow-y-auto px-1 py-1.5">
{filteredEditableColumns.map((column, idx) => {
const isChecked = column.visible !== false;
const label = getColumnLabel(column);
const isFirstMatch = idx === 0 && columnSearch.trim().length > 0;
const childScores = scoreColumns.filter((sc) => sc.parentColumnId === column.id);
return (
<div key=[redacted]
<div
className={cn(
'flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 hover:bg-muted/50',
isFirstMatch && 'bg-muted/50',
)}
onClick={() => onToggleColumn(column.id)}
>
<Checkbox checked={isChecked} tabIndex={-1} className="pointer-events-none" />
<span className="flex-1 cursor-pointer text-sm">{label}</span>
</div>
{childScores.map((scoreCol) => {
const scoreVisible = allColumns.find((c) => c.id === scoreCol.id)?.visible ?? false;
return (
<div
key=[redacted]
className="flex cursor-pointer items-center gap-2.5 rounded-md py-1.5 pl-9 pr-2 hover:bg-muted/50"
onClick={() => onToggleColumn(scoreCol.id)}
>
<Checkbox
checked={scoreVisible}
tabIndex={-1}
className="pointer-events-none"
/>
<span className="flex-1 cursor-pointer text-sm text-muted-foreground">
Signal
</span>
</div>
);
})}
</div>
);
})}
</div>
{/* Footer: delete */}
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="sm"
className="h-7 w-full justify-start gap-1.5 px-2 text-xs text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
Delete preset
</Button>
</div>
</PopoverContent>
</Popover>
);
});
// ─── PresetNavigationBar ──────────────────────────────────────────────────────
export const PresetNavigationBar = memo(function PresetNavigationBar({
canvasId,
}: {
canvasId?: string;
/** @deprecated – refresh is now in the canvas toolbar */
onRefresh?: () => void;
/** @deprecated */
isLoading?: boolean;
onOpenColumnConfig?: () => void;
}) {
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
const {
activePresets,
allColumns,
visibleColumns,
canvasViewConfig,
applyPreset,
isPresetActive,
upsertGlobalPreset,
removeGlobalPreset,
syncActivePresetColumns,
updateCanvasViewConfig,
saveCanvasViewConfig,
} = useCanvasPresets(canvasId);
// ── Preset CRUD ──────────────────────────────────────────────────────────
const handleSavePreset = useCallback(
(updated: CanvasPreset) => void upsertGlobalPreset(updated),
[upsertGlobalPreset],
);
const handlePresetExpandedChange = useCallback(
(preset: CanvasPreset, expanded: boolean) =>
void upsertGlobalPreset({ ...preset, expanded }, { silent: true }),
[upsertGlobalPreset],
);
const handleDeletePreset = useCallback(
(presetId: string) => void removeGlobalPreset(presetId),
[removeGlobalPreset],
);
const getOrderedPresetColumnIds = useCallback(
() =>
visibleColumns
.filter(
(col) =>
col.type !== 'timeline' &&
col.type !== 'working-memory' &&
col.id !== 'wm_custom' &&
col.id !== 'primaryCompany',
)
.map((col) => col.id),
[visibleColumns],
);
const toggleColumnVisibilityInCanvas = useCallback(
(columnId: string) => {
if (!canvasId || !canvasViewConfig) return;
const currentConfig = canvasViewConfig.columnConfiguration ?? {};
const currentVisible = allColumns.find((col) => col.id === columnId)?.visible ?? true;
updateCanvasViewConfig(canvasId, {
...canvasViewConfig,
columnConfiguration: {
...currentConfig,
[columnId]: { ...currentConfig[columnId], visible: !currentVisible },
},
});
saveCanvasViewConfig(canvasId);
const targetPresetId = editingPresetId ?? canvasViewConfig.presetId;
const newIds = allColumns
.filter((col) => {
if (
col.type === 'timeline' ||
col.type === 'working-memory' ||
col.id === 'wm_custom' ||
col.id === 'primaryCompany'
)
return false;
return col.id === columnId ? !currentVisible : (col.visible ?? true);
})
.map((col) => col.id);
void syncActivePresetColumns(targetPresetId, newIds);
},
[
canvasId,
canvasViewConfig,
allColumns,
updateCanvasViewConfig,
saveCanvasViewConfig,
syncActivePresetColumns,
editingPresetId,
],
);
const handleAddPreset = useCallback(() => {
const defaultColumnIds = visibleColumns
.filter(
(col) =>
col.type !== 'timeline' &&
col.type !== 'working-memory' &&
col.id !== 'wm_custom' &&
col.id !== 'primaryCompany' &&
col.visible !== false,
)
.map((col) => col.id);
const newPreset: CanvasPreset = {
id: crypto.randomUUID(),
name: 'New preset',
columnIds: defaultColumnIds,
};
void upsertGlobalPreset(newPreset);
setEditingPresetId(newPreset.id);
}, [visibleColumns, upsertGlobalPreset]);
// ── Render ───────────────────────────────────────────────────────────────
return (
<div className="w-72 p-2">
<Tabs defaultValue="columns" type="underline">
{/* Tab switcher */}
<TabsList className="mb-2 w-full">
<TabsTrigger value="columns" className="flex-1 text-xs">Columns</TabsTrigger>
<TabsTrigger value="presets" className="flex-1 text-xs">Presets</TabsTrigger>
</TabsList>
{/* Columns tab */}
<TabsContent value="columns" className="mt-0">
<ColumnsTabContent
allColumns={allColumns}
onToggleColumn={toggleColumnVisibilityInCanvas}
/>
</TabsContent>
{/* Presets tab — vertical list + Add preset at bottom */}
<TabsContent value="presets" className="mt-0">
<div className="flex flex-col gap-1">
{activePresets.map((preset, index) => {
const isActive = isPresetActive(preset);
const hotkeyNumber = index + 1;
const isEditing = editingPresetId === preset.id;
const buttonContent = (
<motion.button
onClick={() => applyPreset(preset)}
whileFocus={{ backgroundColor: 'var(--accent)' }}
className={`relative z-10 flex w-full items-center gap-2 rounded-md bg-transparent px-3 py-1.5 text-sm transition-colors cursor-pointer ${
isActive ? 'text-primary-foreground' : 'hover:bg-muted/80'
}`}
>
{hotkeyNumber <= 9 && (
<kbd
className={cn(
'h-5 w-5 min-w-[1.25rem] rounded border px-1 py-0.5 text-center font-mono text-[10px] font-medium',
isActive
? 'border-primary-foreground/30 bg-primary-foreground/20'
: 'border-border bg-background',
)}
>
{hotkeyNumber}
</kbd>
)}
<span className="text-xs font-medium">{preset.name}</span>
</motion.button>
);
return (
<div key=[redacted] className="group/preset relative flex items-center">
<Container3D
className="relative flex w-full rounded-md"
shadowSize={isActive ? 'm' : 's'}
style={{ zIndex: isActive ? 10 : 1 }}
motionProps={{ whileTap: { scale: 0.97 } }}
>
{isActive && (
<motion.div
layoutId="preset-selected-indicator"
className="absolute inset-0 rounded-md bg-primary"
transition={{ type: 'spring', stiffness: 500, damping: 35 }}
/>
)}
<PresetEditorPopover
preset={preset}
allColumns={allColumns}
open={isEditing}
onOpenChange={(nextOpen) =>
setEditingPresetId(nextOpen ? preset.id : null)
}
onSave={handleSavePreset}
onDelete={() => handleDeletePreset(preset.id)}
onOpen={() => applyPreset(preset)}
onExpandedChange={(expanded) =>
handlePresetExpandedChange(preset, expanded)
}
onToggleColumn={toggleColumnVisibilityInCanvas}
getOrderedColumnIds={getOrderedPresetColumnIds}
>
{buttonContent}
</PresetEditorPopover>
</Container3D>
{/* Pencil (hover) → X (while editing) */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (isEditing) {
setEditingPresetId(null);
} else {
applyPreset(preset);
setEditingPresetId(preset.id);
}
}}
className={cn(
'absolute -right-1.5 -top-1.5 z-20 flex h-4 w-4 items-center justify-center rounded-full border transition-opacity',
isEditing
? 'border-border bg-background text-muted-foreground opacity-100 hover:text-foreground'
: 'border-border bg-background text-muted-foreground opacity-0 group-hover/preset:opacity-100 hover:!opacity-100 hover:text-foreground',
)}
>
{isEditing ? (
<span className="text-[9px] font-bold leading-none">{'\u2715'}</span>
) : (
<Pencil className="h-2 w-2" />
)}
</button>
</div>
);
})}
{/* Add preset — bottom of the list */}
<Container3D
className="mt-1 flex rounded-md"
shadowSize="s"
motionProps={{ whileTap: { scale: 0.97 } }}
>
<motion.button
onClick={handleAddPreset}
whileFocus={{ backgroundColor: 'var(--accent)' }}
className="relative z-10 flex w-full items-center gap-1.5 rounded-md bg-transparent px-3 py-1.5 text-sm transition-colors cursor-pointer hover:bg-muted/80"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
<span className="whitespace-nowrap text-xs font-medium">Add preset</span>
</motion.button>
</Container3D>
</div>
</TabsContent>
</Tabs>
</div>
);
});