import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download, Pencil } from 'lucide-react'; import type { Session } from '../types'; import { useT } from '../hooks/useLocale'; import { SessionIcon } from './SessionIcon'; import { sessionDisplayName } from '../lib/sessionName'; import { relativeTime } from '../lib/relativeTime'; import { useUpdateCheck } from '../hooks/useUpdateCheck'; import { usePwaInstall } from '../hooks/usePwaInstall'; function VersionBadge() { const update = useUpdateCheck(__APP_VERSION__); if (update.available) { return ( v{__APP_VERSION__} {update.latestVersion} available ); } return ( v{__APP_VERSION__} ); } function SidebarFooter() { const pwa = usePwaInstall(); return (
{pwa.canInstall && ( )}
); } const PINNED_KEY = 'pinchchat-pinned-sessions'; const WIDTH_KEY = 'pinchchat-sidebar-width'; const ORDER_KEY = 'pinchchat-session-order'; const FILTER_KEY = 'pinchchat-session-filter'; const NAMES_KEY = 'pinchchat-session-names'; function getCustomNames(): Record { try { const raw = localStorage.getItem(NAMES_KEY); if (raw) return JSON.parse(raw) as Record; } catch { /* noop */ } return {}; } function saveCustomNames(names: Record) { try { localStorage.setItem(NAMES_KEY, JSON.stringify(names)); } catch { /* noop */ } } /** Detect the category of a session for filtering */ function sessionCategory(s: Session): string { if (s.key.includes(':cron:')) return 'cron'; if (s.key.includes(':spawn:') || s.key.includes(':sub:')) return 'agent'; const ch = s.channel?.toLowerCase(); if (ch && ch !== 'webchat') return ch; return 'other'; } /** Get unique categories present in sessions */ function getAvailableCategories(sessions: Session[]): string[] { const cats = new Set(); for (const s of sessions) cats.add(sessionCategory(s)); return Array.from(cats).sort(); } /** Icons for filter chips */ function FilterChipIcon({ cat, size = 12 }: { cat: string; size?: number }) { switch (cat) { case 'cron': return ; case 'agent': return ; case 'discord': return ; case 'telegram': return ; default: return ; } } /** Pretty label for category */ function categoryLabel(cat: string): string { if (cat === 'cron') return 'Cron'; if (cat === 'agent') return 'Agents'; if (cat === 'other') return 'Chat'; return cat.charAt(0).toUpperCase() + cat.slice(1); } const MIN_WIDTH = 220; const MAX_WIDTH = 480; const DEFAULT_WIDTH = 288; // w-72 function getSavedWidth(): number { try { const v = localStorage.getItem(WIDTH_KEY); if (v) { const n = Number(v); if (n >= MIN_WIDTH && n <= MAX_WIDTH) return n; } } catch { /* noop */ } return DEFAULT_WIDTH; } function getPinnedSessions(): Set { try { const raw = localStorage.getItem(PINNED_KEY); if (raw) return new Set(JSON.parse(raw) as string[]); } catch { /* noop */ } return new Set(); } function savePinnedSessions(pinned: Set) { try { localStorage.setItem(PINNED_KEY, JSON.stringify([...pinned])); } 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 { sessions: Session[]; activeSession: string; onSwitch: (key: string) => void; onDelete: (key: string) => void; onSplit?: (key: string) => void; splitSession?: string | null; open: boolean; onClose: () => void; onRename?: (key: string, label: string) => Promise; } export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit, splitSession, open, onClose, onRename }: Props) { const t = useT(); const [filter, setFilter] = useState(''); const [focusIdx, setFocusIdx] = useState(-1); const [pinned, setPinned] = useState(getPinnedSessions); const [width, setWidth] = useState(getSavedWidth); const [dragging, setDragging] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); const [customOrder, setCustomOrder] = useState(getSavedOrder); const [channelFilter, setChannelFilter] = useState(() => { try { return localStorage.getItem(FILTER_KEY); } catch { return null; } }); const [dragKey, setDragKey] = useState(null); const [dropTarget, setDropTarget] = useState(null); const [customNames, setCustomNames] = useState>(getCustomNames); const [renamingKey, setRenamingKey] = useState(null); const [renameValue, setRenameValue] = useState(''); const renameInputRef = useRef(null); const searchRef = useRef(null); const listRef = useRef(null); const dragRef = useRef({ startX: 0, startW: 0 }); // Drag-to-resize logic useEffect(() => { if (!dragging) return; const onMove = (e: MouseEvent | TouchEvent) => { const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; const newW = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, dragRef.current.startW + (clientX - dragRef.current.startX))); setWidth(newW); }; const onUp = () => { setDragging(false); // persist on release localStorage.setItem(WIDTH_KEY, String(width)); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('touchmove', onMove); document.addEventListener('touchend', onUp); return () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); }; }, [dragging, width]); // Save width when it changes (debounced via drag end above, but also on unmount) useEffect(() => { return () => { try { localStorage.setItem(WIDTH_KEY, String(width)); } catch { /* noop */ } }; }, [width]); const startDrag = useCallback((e: React.MouseEvent | React.TouchEvent) => { e.preventDefault(); const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; dragRef.current = { startX: clientX, startW: width }; setDragging(true); }, [width]); const togglePin = useCallback((key: string, e: React.MouseEvent) => { e.stopPropagation(); setPinned(prev => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); savePinnedSessions(next); return next; }); }, []); const startRename = useCallback((key: string, currentName: string, e: React.MouseEvent) => { e.stopPropagation(); setRenamingKey(key); setRenameValue(currentName); // Focus the input after render requestAnimationFrame(() => renameInputRef.current?.focus()); }, []); const commitRename = useCallback(() => { if (!renamingKey) return; const trimmed = renameValue.trim(); setCustomNames(prev => { const next = { ...prev }; if (trimmed) { next[renamingKey] = trimmed; } else { delete next[renamingKey]; } saveCustomNames(next); return next; }); // Also persist server-side via sessions.patch if (onRename && trimmed) { onRename(renamingKey, trimmed).catch(() => { /* best effort */ }); } setRenamingKey(null); setRenameValue(''); }, [renamingKey, renameValue, onRename]); const cancelRename = useCallback(() => { setRenamingKey(null); setRenameValue(''); }, []); // Focus rename input when it appears useEffect(() => { if (renamingKey) { renameInputRef.current?.focus(); renameInputRef.current?.select(); } }, [renamingKey]); // Keyboard shortcut: Ctrl+K or Cmd+K to focus search when sidebar is open useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); searchRef.current?.focus(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, []); const updateFilter = useCallback((value: string) => { setFilter(value); setFocusIdx(-1); }, []); const availableCategories = useMemo(() => getAvailableCategories(sessions), [sessions]); const toggleChannelFilter = useCallback((cat: string) => { setChannelFilter(prev => { const next = prev === cat ? null : cat; try { if (next) localStorage.setItem(FILTER_KEY, next); else localStorage.removeItem(FILTER_KEY); } catch { /* noop */ } return next; }); }, []); const filtered = useMemo(() => { let list = sessions; // Apply channel filter if (channelFilter === 'active') { list = list.filter(s => s.isActive); } else if (channelFilter) { list = list.filter(s => sessionCategory(s) === channelFilter); } if (filter.trim()) { const q = filter.toLowerCase(); list = list.filter(s => (customNames[s.key] || sessionDisplayName(s)).toLowerCase().includes(q)); } // Sort pinned sessions to top (preserving relative order within each group) const pinnedList = list.filter(s => pinned.has(s.key)); const unpinnedList = list.filter(s => !pinned.has(s.key)); // Sort each group: use custom order if set, then fall back to most recently updated const orderMap = new Map(customOrder.map((k, i) => [k, i])); const byCustomThenRecent = (a: Session, b: Session) => { 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]; }, [sessions, filter, pinned, customOrder, channelFilter, customNames]); return ( <> {open &&
{ if (e.key === 'Escape') onClose(); }} role="button" tabIndex={-1} aria-label="Close sidebar" />} {/* Prevent text selection while dragging */} {dragging &&
} {/* Delete confirmation dialog */} {confirmDelete && ( <>
setConfirmDelete(null)} onKeyDown={(e) => { if (e.key === 'Escape') setConfirmDelete(null); }} role="button" tabIndex={-1} aria-label="Cancel deletion" />

{t('sidebar.deleteConfirm')}

)} ); }