import { useMemo, useState } from 'react'; import type { Cli, MoveTarget, Group, Session, Tree } from '../types'; import { STATE_LABEL } from '../types'; import Logo from './Logo'; import NewSession from './NewSession'; import FolderPicker from './FolderPicker'; import { SlidersGlyph, SunGlyph, MoonGlyph, CloseGlyph, PencilGlyph, StopGlyph, PlayGlyph, GridGlyph, PlusGlyph, AmMark } from './icons'; type Zone = 'before' | 'after' | 'on'; const fmtAgo = (ts?: number) => { if (!ts) return ''; const m = Math.round((Date.now() - ts) / 60000); if (m < 1) return 'now'; if (m < 60) return `${m}m`; if (m < 48 * 60) return `${Math.round(m / 60)}h`; return `${Math.round(m / 1440)}d`; }; export default function Sidebar({ clis, tree, activeRef, focusedId, defaultPath, ages, onActivate, onOpenSession, onNewSession, onNewGroup, onRenameGroup, onRenameSession, onDeleteGroup, onStopSession, onDeleteSession, onMove, onDragState, onOpenSettings, theme, onToggleTheme, }: { clis: Cli[]; tree: Tree; activeRef: string | null; focusedId: string | null; defaultPath: string; ages?: Record; // session id -> last activity ts (ms) onActivate: (ref: string) => void; onOpenSession: (sessionId: string, groupId?: string) => void; onOpenSettings: () => void; onNewSession: (name: string, cli: string, path: string, groupId?: string) => void; onNewGroup: (name: string, cart?: { cli: string; count: number }[], path?: string) => void; onRenameGroup: (id: string, name: string) => void; onRenameSession: (id: string, name: string) => void; onDeleteGroup: (id: string) => void; onStopSession: (id: string) => void; onDeleteSession: (id: string) => void; onMove: (ref: string, to: MoveTarget) => void; onDragState?: (ref: string | null) => void; // lets the stage offer per-tile drop targets theme: 'light' | 'dark'; onToggleTheme: () => void; }) { const [panel, setPanel] = useState<'none' | 'create'>('none'); const [createTab, setCreateTab] = useState<'agent' | 'group'>('agent'); // When creation was launched from a group's + the new agent lands there. const [createTarget, setCreateTarget] = useState(null); const [groupName, setGroupName] = useState(''); const [groupLoc, setGroupLoc] = useState(defaultPath); const [cart, setCart] = useState>({}); const [editRef, setEditRef] = useState(null); const [editName, setEditName] = useState(''); const [collapsed, setCollapsed] = useState>(new Set()); const [dragRef, setDragRef] = useState(null); const [drop, setDrop] = useState<{ ref: string; zone: Zone } | null>(null); const sessById = useMemo(() => Object.fromEntries(tree.sessions.map((s) => [s.id, s])), [tree.sessions]); const groupById = useMemo(() => Object.fromEntries(tree.groups.map((g) => [g.id, g])), [tree.groups]); const colorOf = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c.color])), [clis]); const waiting = tree.sessions.filter((s) => s.state === 'waiting').length; const clearDrag = () => { setDragRef(null); setDrop(null); onDragState?.(null); }; const bump = (id: string, d: number) => setCart((c) => ({ ...c, [id]: Math.max(0, (c[id] || 0) + d) })); const closePanel = () => { setPanel('none'); setCreateTarget(null); }; const openCreate = (tab: 'agent' | 'group', target: string | null = null) => { setCreateTab(tab); setCreateTarget(target); setPanel('create'); }; const submitGroup = () => { const items = Object.entries(cart).filter(([, n]) => n > 0).map(([cli, count]) => ({ cli, count })); onNewGroup(groupName.trim() || 'Group', items, groupLoc); setGroupName(''); setCart({}); closePanel(); }; const startEdit = (ref: string, name: string) => { setEditRef(ref); setEditName(name); }; const commitEdit = () => { if (editRef) { const id = editRef.slice(2); if (editRef.startsWith('g:')) onRenameGroup(id, editName); else onRenameSession(id, editName); } setEditRef(null); }; // shared drag-and-drop wiring for any row const dndProps = (ref: string, kind: 'group' | 'session', nested: boolean) => ({ draggable: editRef !== ref, onDragStart: (e: React.DragEvent) => { e.dataTransfer.setData('text/plain', ref); e.dataTransfer.effectAllowed = 'move'; setDragRef(ref); onDragState?.(ref); }, onDragEnd: clearDrag, onDragOver: (e: React.DragEvent) => { if (!dragRef || dragRef === ref) return; const draggingGroup = dragRef.startsWith('g:'); if (nested && draggingGroup) return; // can't nest a group e.preventDefault(); e.stopPropagation(); const rect = e.currentTarget.getBoundingClientRect(); const y = e.clientY - rect.top; let zone: Zone; if (draggingGroup) zone = y < rect.height / 2 ? 'before' : 'after'; else { const th = rect.height / 3; zone = y < th ? 'before' : y > 2 * th ? 'after' : 'on'; } setDrop({ ref, zone }); }, onDrop: (e: React.DragEvent) => { if (!dragRef || dragRef === ref) { clearDrag(); return; } e.preventDefault(); e.stopPropagation(); const zone = drop && drop.ref === ref ? drop.zone : 'after'; const id = ref.slice(2); if (zone === 'on') { if (kind === 'group') onMove(dragRef, { kind: 'into', groupId: id }); else onMove(dragRef, { kind: 'pair', sessionId: id }); } else { onMove(dragRef, { kind: zone, ref }); } clearDrag(); }, className: drop && drop.ref === ref ? ` drop-${drop.zone}` : '', }); const SessionRow = (s: Session, groupId?: string) => { const ref = `s:${s.id}`; const nested = !!groupId; const dnd = dndProps(ref, 'session', nested); const editing = editRef === ref; // nested agents are highlighted as a whole group (see GroupBlock); loose ones individually const active = !nested && activeRef === ref; return (
onOpenSession(s.id, groupId)} onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, s.name); }} title={s.path ? `${s.name} · ${s.path}` : s.name} > {editing ? ( e.stopPropagation()} onChange={(e) => setEditName(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditRef(null); }} /> ) : ( {s.name} )} {fmtAgo(ages?.[s.id])} {s.running ? : }
); }; const GroupBlock = (g: Group) => { const ref = `g:${g.id}`; const dnd = dndProps(ref, 'group', false); const open = !collapsed.has(g.id); const editing = editRef === ref; return (
{/* the group's name rides its frame — still the drag handle / click target */}
onActivate(ref)} onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }} > {editing ? ( e.stopPropagation()} onChange={(e) => setEditName(e.target.value)} onBlur={commitEdit} onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditRef(null); }} /> ) : ( <> {g.name} {!open && {g.sessionIds.length}} )}
{open && g.sessionIds.map((sid) => sessById[sid]).filter(Boolean).map((s) => SessionRow(s as Session, g.id))} {open && g.sessionIds.length === 0 &&
Drag agents here
}
); }; return ( ); }