feat: drag & drop session reordering in sidebar

- Drag sessions to reorder within pinned/unpinned groups
- Custom order persists in localStorage
- Visual feedback: dragged item fades, drop target highlights
- Disabled during search filtering
- Works alongside existing pin feature (pinned group stays on top)
This commit is contained in:
Nicolas Varrot
2026-02-13 02:10:53 +00:00
parent 16af579e3c
commit 35652eaeb5

View File

@@ -8,6 +8,7 @@ import { relativeTime } from '../lib/relativeTime';
const PINNED_KEY = 'pinchchat-pinned-sessions'; const PINNED_KEY = 'pinchchat-pinned-sessions';
const WIDTH_KEY = 'pinchchat-sidebar-width'; const WIDTH_KEY = 'pinchchat-sidebar-width';
const ORDER_KEY = 'pinchchat-session-order';
const MIN_WIDTH = 220; const MIN_WIDTH = 220;
const MAX_WIDTH = 480; const MAX_WIDTH = 480;
const DEFAULT_WIDTH = 288; // w-72 const DEFAULT_WIDTH = 288; // w-72
@@ -37,6 +38,20 @@ function savePinnedSessions(pinned: Set<string>) {
} catch { /* noop */ } } catch { /* noop */ }
} }
function getSavedOrder(): string[] {
try {
const raw = localStorage.getItem(ORDER_KEY);
if (raw) return JSON.parse(raw) as string[];
} catch { /* noop */ }
return [];
}
function saveOrder(order: string[]) {
try {
localStorage.setItem(ORDER_KEY, JSON.stringify(order));
} catch { /* noop */ }
}
interface Props { interface Props {
sessions: Session[]; sessions: Session[];
activeSession: string; activeSession: string;
@@ -54,6 +69,9 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
const [width, setWidth] = useState(getSavedWidth); const [width, setWidth] = useState(getSavedWidth);
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null); const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const [customOrder, setCustomOrder] = useState<string[]>(getSavedOrder);
const [dragKey, setDragKey] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const dragRef = useRef({ startX: 0, startW: 0 }); const dragRef = useRef({ startX: 0, startW: 0 });
@@ -132,12 +150,20 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
// Sort pinned sessions to top (preserving relative order within each group) // Sort pinned sessions to top (preserving relative order within each group)
const pinnedList = list.filter(s => pinned.has(s.key)); const pinnedList = list.filter(s => pinned.has(s.key));
const unpinnedList = list.filter(s => !pinned.has(s.key)); const unpinnedList = list.filter(s => !pinned.has(s.key));
// Sort each group by most recently updated // Sort each group: use custom order if set, then fall back to most recently updated
const byRecent = (a: Session, b: Session) => (b.updatedAt || 0) - (a.updatedAt || 0); const orderMap = new Map(customOrder.map((k, i) => [k, i]));
pinnedList.sort(byRecent); const byCustomThenRecent = (a: Session, b: Session) => {
unpinnedList.sort(byRecent); const aIdx = orderMap.get(a.key);
const bIdx = orderMap.get(b.key);
if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
if (aIdx !== undefined) return -1;
if (bIdx !== undefined) return 1;
return (b.updatedAt || 0) - (a.updatedAt || 0);
};
pinnedList.sort(byCustomThenRecent);
unpinnedList.sort(byCustomThenRecent);
return [...pinnedList, ...unpinnedList]; return [...pinnedList, ...unpinnedList];
}, [sessions, filter, pinned]); }, [sessions, filter, pinned, customOrder]);
return ( return (
<> <>
@@ -224,6 +250,8 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
const isFocused = idx === focusIdx; const isFocused = idx === focusIdx;
const isPinned = pinned.has(s.key); const isPinned = pinned.has(s.key);
const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key); const isFirstUnpinned = !isPinned && idx > 0 && pinned.has(filtered[idx - 1].key);
const isDragged = dragKey === s.key;
const isDropTarget = dropTarget === s.key && dragKey !== s.key;
return ( return (
<div key={s.key}> <div key={s.key}>
{isFirstUnpinned && ( {isFirstUnpinned && (
@@ -234,6 +262,38 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
<button <button
role="option" role="option"
aria-selected={isActive} aria-selected={isActive}
draggable={!filter.trim()}
onDragStart={(e) => {
setDragKey(s.key);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', s.key);
}}
onDragEnd={() => { setDragKey(null); setDropTarget(null); }}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragKey && dragKey !== s.key) setDropTarget(s.key);
}}
onDragLeave={() => { if (dropTarget === s.key) setDropTarget(null); }}
onDrop={(e) => {
e.preventDefault();
if (!dragKey || dragKey === s.key) return;
// Only reorder within same group (pinned or unpinned)
const dragPinned = pinned.has(dragKey);
const dropPinned = pinned.has(s.key);
if (dragPinned !== dropPinned) { setDragKey(null); setDropTarget(null); return; }
// Build new order from current filtered list
const keys = filtered.map(f => f.key);
const fromIdx = keys.indexOf(dragKey);
const toIdx = keys.indexOf(s.key);
if (fromIdx === -1 || toIdx === -1) return;
keys.splice(fromIdx, 1);
keys.splice(toIdx, 0, dragKey);
setCustomOrder(keys);
saveOrder(keys);
setDragKey(null);
setDropTarget(null);
}}
onClick={() => { onSwitch(s.key); onClose(); }} onClick={() => { onSwitch(s.key); onClose(); }}
onMouseEnter={() => setFocusIdx(idx)} onMouseEnter={() => setFocusIdx(idx)}
className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${ className={`group/item w-full flex items-center gap-3 px-3 py-2.5 rounded-2xl text-left text-sm transition-all mb-1 ${
@@ -242,7 +302,7 @@ export function Sidebar({ sessions, activeSession, onSwitch, onDelete, open, onC
: s.isActive : s.isActive
? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]' ? 'bg-violet-500/5 text-violet-200 border border-violet-500/15 shadow-[0_0_10px_rgba(168,85,247,0.06)]'
: 'text-pc-text-secondary hover:bg-[var(--pc-hover)] border border-transparent' : 'text-pc-text-secondary hover:bg-[var(--pc-hover)] border border-transparent'
} ${isFocused && !isActive ? 'ring-1 ring-[var(--pc-accent-dim)]' : ''}`} } ${isFocused && !isActive ? 'ring-1 ring-[var(--pc-accent-dim)]' : ''} ${isDragged ? 'opacity-40' : ''} ${isDropTarget ? 'ring-1 ring-[var(--pc-accent)] bg-[var(--pc-accent-glow)]' : ''}`}
> >
<div className="relative"> <div className="relative">
<SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} /> <SessionIcon session={s} isActive={s.isActive} isCurrentSession={isActive} />