import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { X, Search, Pin, Trash2, Columns2, Clock, Bot, MessageSquare, Globe, Zap, ArrowUpCircle, Download } 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';
/** 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;
}
export function Sidebar({ sessions, activeSession, onSwitch, onDelete, onSplit, splitSession, open, onClose }: 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 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;
});
}, []);
// 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 => 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]);
return (
<>
{open && }
{/* Prevent text selection while dragging */}
{dragging && }
{/* Delete confirmation dialog */}
{confirmDelete && (
<>
setConfirmDelete(null)} />
{t('sidebar.deleteConfirm')}
>
)}
>
);
}