lvwerra's picture
lvwerra HF Staff
AM ligature mark; grid overview icon; sidebar polish (frame gap, play, mono panels, collapse count); settings de-box
088b27a verified
Raw
History Blame Contribute Delete
15.4 kB
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>
);
}