Spaces:
Runtime error
Runtime error
| 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<string, number>; // 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<string | null>(null); | |
| const [groupName, setGroupName] = useState(''); | |
| const [groupLoc, setGroupLoc] = useState(defaultPath); | |
| const [cart, setCart] = useState<Record<string, number>>({}); | |
| const [editRef, setEditRef] = useState<string | null>(null); | |
| const [editName, setEditName] = useState(''); | |
| const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); | |
| const [dragRef, setDragRef] = useState<string | null>(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 ( | |
| <div | |
| key={s.id} | |
| className={`row session${active ? ' active' : ''}${nested ? ' nested' : ''}${dragRef === ref ? ' dragging' : ''}${dnd.className}`} | |
| draggable={dnd.draggable} | |
| onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop} | |
| onClick={() => onOpenSession(s.id, groupId)} | |
| onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, s.name); }} | |
| title={s.path ? `${s.name} · ${s.path}` : s.name} | |
| > | |
| <span className={`status ${s.state}`} title={STATE_LABEL[s.state]} /> | |
| <Logo cli={s.cli} size={12} tint={colorOf[s.cli]} /> | |
| {editing ? ( | |
| <input | |
| autoFocus className="rename" value={editName} | |
| onClick={(e) => e.stopPropagation()} | |
| onChange={(e) => setEditName(e.target.value)} | |
| onBlur={commitEdit} | |
| onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditRef(null); }} | |
| /> | |
| ) : ( | |
| <span className="name">{s.name}</span> | |
| )} | |
| <span className="age">{fmtAgo(ages?.[s.id])}</span> | |
| <span className="row-actions"> | |
| {s.running | |
| ? <button className="mini-btn" title="Stop" onClick={(e) => { e.stopPropagation(); onStopSession(s.id); }}><StopGlyph /></button> | |
| : <button className="mini-btn" title="Start" onClick={(e) => { e.stopPropagation(); onOpenSession(s.id, groupId); }}><PlayGlyph /></button>} | |
| <button className="mini-btn" title="Delete" onClick={(e) => { e.stopPropagation(); onDeleteSession(s.id); }}><CloseGlyph /></button> | |
| </span> | |
| </div> | |
| ); | |
| }; | |
| 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 ( | |
| <div key={g.id} className={`group${activeRef === ref ? ' active' : ''}${!open ? ' closed' : ''}${drop && drop.ref === ref && drop.zone === 'on' ? ' drop-into' : ''}`}> | |
| {/* the group's name rides its frame — still the drag handle / click target */} | |
| <div | |
| className={`row group-head${dragRef === ref ? ' dragging' : ''}${dnd.className}`} | |
| draggable={dnd.draggable} | |
| onDragStart={dnd.onDragStart} onDragEnd={dnd.onDragEnd} onDragOver={dnd.onDragOver} onDrop={dnd.onDrop} | |
| onClick={() => onActivate(ref)} | |
| onDoubleClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }} | |
| > | |
| <button className="caret" onClick={(e) => { e.stopPropagation(); setCollapsed((c) => { const n = new Set(c); n.has(g.id) ? n.delete(g.id) : n.add(g.id); return n; }); }}>{open ? '▾' : '▸'}</button> | |
| {editing ? ( | |
| <input | |
| autoFocus className="rename" value={editName} | |
| onClick={(e) => e.stopPropagation()} | |
| onChange={(e) => setEditName(e.target.value)} | |
| onBlur={commitEdit} | |
| onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditRef(null); }} | |
| /> | |
| ) : ( | |
| <> | |
| <span className="name">{g.name}</span> | |
| {!open && <span className="count">{g.sessionIds.length}</span>} | |
| <span className="row-actions"> | |
| <button className="mini-btn" title="Rename" onClick={(e) => { e.stopPropagation(); startEdit(ref, g.name); }}><PencilGlyph /></button> | |
| <button className="mini-btn" title="Delete group" onClick={(e) => { e.stopPropagation(); onDeleteGroup(g.id); }}><CloseGlyph /></button> | |
| </span> | |
| </> | |
| )} | |
| </div> | |
| <button className="g-add" title={`New agent in ${g.name}`} onClick={(e) => { e.stopPropagation(); openCreate('agent', g.id); }}><PlusGlyph /></button> | |
| {open && g.sessionIds.map((sid) => sessById[sid]).filter(Boolean).map((s) => SessionRow(s as Session, g.id))} | |
| {open && g.sessionIds.length === 0 && <div className="empty-hint nested">Drag agents here</div>} | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <aside className="sidebar"> | |
| <div className="brand"> | |
| <div className="logo"> | |
| <AmMark className="am-mark" /> | |
| <h1>Agent Manager</h1> | |
| </div> | |
| <div className="brand-actions"> | |
| <button | |
| className={`icon-btn add-btn${panel === 'create' ? ' on' : ''}`} | |
| onClick={() => (panel === 'create' ? closePanel() : openCreate('agent'))} | |
| title="New agent or group" | |
| ><PlusGlyph /></button> | |
| <button className="icon-btn" onClick={onOpenSettings} title="Settings"><SlidersGlyph /></button> | |
| <button className="icon-btn" onClick={onToggleTheme} title="Toggle light / dark">{theme === 'dark' ? <MoonGlyph /> : <SunGlyph />}</button> | |
| </div> | |
| </div> | |
| {panel === 'create' && ( | |
| <div className="controls"> | |
| <div className="seg create-seg"> | |
| <button className={createTab === 'agent' ? 'on' : ''} onClick={() => setCreateTab('agent')}>Agent</button> | |
| <button className={createTab === 'group' ? 'on' : ''} onClick={() => { setCreateTab('group'); setCreateTarget(null); }}>Group</button> | |
| </div> | |
| {createTab === 'agent' && createTarget && groupById[createTarget] && ( | |
| <div className="create-hint mono">into {groupById[createTarget].name}</div> | |
| )} | |
| {createTab === 'agent' ? ( | |
| <NewSession | |
| clis={clis} | |
| sessions={tree.sessions} | |
| defaultPath={defaultPath} | |
| onCreate={(n, c, p) => { onNewSession(n, c, p, createTarget ?? undefined); closePanel(); }} | |
| onCancel={closePanel} | |
| /> | |
| ) : ( | |
| <div className="widget"> | |
| <input autoFocus placeholder="Group name" value={groupName} | |
| onChange={(e) => setGroupName(e.target.value)} | |
| onKeyDown={(e) => { if (e.key === 'Enter') submitGroup(); if (e.key === 'Escape') closePanel(); }} /> | |
| <FolderPicker value={groupLoc} autoLabel="new folder per agent (auto)" onChange={setGroupLoc} /> | |
| <div className="cart"> | |
| {clis.filter((c) => c.available).map((c) => { | |
| const n = cart[c.id] || 0; | |
| return ( | |
| <div key={c.id} className={`cart-row${n > 0 ? ' has' : ''}`}> | |
| <div className="stepper"> | |
| <button onClick={() => bump(c.id, -1)} disabled={n === 0} aria-label="Fewer">−</button> | |
| <span className="stepper-n">{n}</span> | |
| <button onClick={() => bump(c.id, 1)} aria-label="More">+</button> | |
| </div> | |
| <Logo cli={c.id} size={16} /> | |
| <span className="cart-name">{c.label}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="widget-actions"> | |
| <button className="btn-primary" onClick={submitGroup}>Create group</button> | |
| <button className="btn-ghost" onClick={() => { setCart({}); closePanel(); }}>Cancel</button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Overview: pinned above the tree with the row anatomy (tile · name · | |
| right slot) so it reads as clickable; the right slot counts agents | |
| waiting on you. */} | |
| <div className="ov-fixed"> | |
| <div | |
| className={`row session ov-row${activeRef === 'overview' ? ' active' : ''}`} | |
| onClick={() => onActivate('overview')} | |
| title="All agents: digests, states, replies" | |
| > | |
| <span className="status ov-spacer" /> | |
| <span className="ov-tile"><GridGlyph /></span> | |
| <span className="name">overview</span> | |
| {waiting > 0 && <span className="ov-wait">{waiting} waiting</span>} | |
| </div> | |
| </div> | |
| <div className="tree" onDragEnd={clearDrag}> | |
| {tree.order.length === 0 && ( | |
| <div className="empty-hint">Nothing yet. Add an agent with the + above.<br />Drag an agent onto another to group them.</div> | |
| )} | |
| {tree.order.map((ref) => (ref.startsWith('s:') | |
| ? (sessById[ref.slice(2)] ? SessionRow(sessById[ref.slice(2)]) : null) | |
| : (groupById[ref.slice(2)] ? GroupBlock(groupById[ref.slice(2)]) : null)))} | |
| </div> | |
| {/* Quick-add utilities: created instantly with a default name (double- | |
| click to rename afterwards). Both open at the workspaces root. */} | |
| <div className="quick-add"> | |
| <button className="btn-ghost" title="New shell at the workspaces root" onClick={() => onNewSession('', 'shell', '.')}> | |
| <Logo cli="shell" size={14} /> Shell | |
| </button> | |
| <button className="btn-ghost" title="New file browser (whole workspace)" onClick={() => onNewSession('', 'files', '')}> | |
| <Logo cli="files" size={14} /> Files | |
| </button> | |
| </div> | |
| <div className="legend"> | |
| <span><span className="status working" /> working</span> | |
| <span><span className="status waiting" /> your turn</span> | |
| <span><span className="status idle" /> idle</span> | |
| <span><span className="status stopped" /> stopped</span> | |
| </div> | |
| </aside> | |
| ); | |
| } | |