import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Sidebar from './components/Sidebar'; import TerminalPane from './components/TerminalPane'; import FilesPane from './components/FilesPane'; import SettingsView from './components/SettingsView'; import NewSession from './components/NewSession'; import LayoutPicker from './components/LayoutPicker'; import Logo from './components/Logo'; import Overview from './components/Overview'; import Locked from './components/Locked'; import Welcome from './components/Welcome'; import * as api from './api'; import type { Cli, GridSpec, MoveTarget, OverviewFilter, Session, Tree } from './types'; import { GridGlyph, ListGlyph } from './components/icons'; // Phone-sized viewport: the app becomes two full-screen views (list ⇄ pane). function useIsMobile() { const [m, setM] = useState(() => window.matchMedia('(max-width: 720px)').matches); useEffect(() => { const mq = window.matchMedia('(max-width: 720px)'); const h = (e: MediaQueryListEvent) => setM(e.matches); mq.addEventListener('change', h); return () => mq.removeEventListener('change', h); }, []); return m; } // Auto layout: the grid grows to fit however many agents the group has. function autoGrid(n: number): GridSpec { if (n <= 1) return { cols: 1, rows: 1 }; if (n === 2) return { cols: 2, rows: 1 }; if (n <= 4) return { cols: 2, rows: 2 }; if (n <= 6) return { cols: 3, rows: 2 }; return { cols: 3, rows: 3 }; } type SettingsPage = 'general' | 'usage' | 'skills'; const ROOT_PATH = '.'; const normalizePath = (p?: string | null) => (p && p.trim() ? p : ROOT_PATH); function initialTheme(): 'light' | 'dark' { const stored = localStorage.getItem('am-theme'); if (stored === 'light' || stored === 'dark') return stored; return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } export default function App() { const [clis, setClis] = useState([]); const [tree, setTree] = useState({ order: [], groups: [], sessions: [] }); const [activeRef, setActiveRef] = useState(null); const [focusedId, setFocusedId] = useState(null); const [theme, setTheme] = useState<'light' | 'dark'>(initialTheme); const [dropMain, setDropMain] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsPage, setSettingsPage] = useState('general'); // First-run welcome: null until /api/info loads; true when reopened manually. const [showWelcome, setShowWelcome] = useState(false); // Transient error toast for failed mutations (create/delete/move/…). const [toast, setToast] = useState(null); const showErr = (msg: string) => (e: unknown) => { console.error(msg, e); setToast(msg); window.setTimeout(() => setToast(null), 4000); }; // Overview presentation: tiles (default) or the classic list. const [ovView, setOvViewRaw] = useState<'tiles' | 'list'>(() => (localStorage.getItem('am-ov-view') === 'list' ? 'list' : 'tiles')); const setOvView = (v: 'tiles' | 'list') => { setOvViewRaw(v); localStorage.setItem('am-ov-view', v); }; // Archiving: sessions quiet for longer than the configured window are hidden // from the sidebar and overview unless "archived" is checked. Derived, never // stored — flipping the setting instantly (un)archives. const [showArchived, setShowArchived] = useState(false); const [archiveAfter, setArchiveAfter] = useState<'week' | 'month' | 'never'>('month'); const [zoom, setZoom] = useState(() => { const z = parseInt(localStorage.getItem('am-zoom') || '100', 10); return Number.isFinite(z) ? z : 100; }); const [info, setInfo] = useState> | null>(null); // Default location for the next agent = where the last one was created, // falling back to the workspaces root. const [lastPath, setLastPath] = useState(() => normalizePath(localStorage.getItem('am-last-path'))); // Mobile navigation: false = the sidebar is the (full-screen) home view, // true = the selected session/group fills the screen. Desktop ignores this. const isMobile = useIsMobile(); const [mobileStage, setMobileStage] = useState(false); const [ovFilter, setOvFilter] = useState('all'); const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); const rememberPath = (p?: string | null) => { const next = normalizePath(p); setLastPath(next); localStorage.setItem('am-last-path', next); }; useEffect(() => { document.documentElement.dataset.theme = theme; localStorage.setItem('am-theme', theme); }, [theme]); // Refresh info while locked so flipping the Space to Private unlocks the UI // without a manual reload (the server re-checks visibility every minute). useEffect(() => { api.getInfo().then(setInfo).catch(() => {}); if (!info?.locked) return; const t = setInterval(() => api.getInfo().then(setInfo).catch(() => {}), 15_000); return () => clearInterval(t); }, [info?.locked]); useEffect(() => { localStorage.setItem('am-zoom', String(zoom)); }, [zoom]); // Show the welcome once, when /api/info first loads: on first run (never seen) // or whenever demo mode is active (so the Space reads like a fresh install). // Fires once so dismissing it during a demo doesn't make it re-pop. const welcomeBoot = useRef(false); useEffect(() => { if (!info || welcomeBoot.current) return; welcomeBoot.current = true; if (!info.locked && (info.welcomeSeen === false || info.demoMode)) setShowWelcome(true); }, [info]); const dismissWelcome = () => { setShowWelcome(false); setInfo((i) => (i ? { ...i, welcomeSeen: true } : i)); api.dismissWelcome().catch(() => {}); }; const openWelcome = () => { setSettingsOpen(false); setShowWelcome(true); }; // Demo mode: hide current sessions from view (nothing is deleted). Toggling // off restores the full sidebar. Turning it on shows the welcome; off hides it. const toggleDemo = async () => { const next = !info?.demoMode; try { const r = await api.setDemo(next); setInfo((i) => (i ? { ...i, demoMode: r.active } : i)); setSettingsOpen(false); await refresh(); setShowWelcome(next); } catch (e) { showErr('Couldn’t toggle demo mode')(e); } }; const refresh = useCallback(async () => { try { setTree(await api.getTree()); } catch { /* offline */ } }, []); useEffect(() => { api.getClis().then(setClis).catch(() => {}); refresh(); // Skip polling while the tab is hidden; catch up immediately on return. const t = setInterval(() => { if (!document.hidden) refresh(); }, 2500); const onVisible = () => { if (!document.hidden) refresh(); }; document.addEventListener('visibilitychange', onVisible); return () => { clearInterval(t); document.removeEventListener('visibilitychange', onVisible); }; }, [refresh]); // Last-activity ages for the sidebar clock column (from the trace digests; // low-frequency — the Overview does its own faster polling when open). const [ages, setAges] = useState>({}); useEffect(() => { let alive = true; const load = () => api.getMeta() .then((r) => { if (!alive) return; setAges(Object.fromEntries(r.sessions.map((s) => [ s.id, Math.max(s.digest?.lastAssistantTs || 0, s.digest?.lastPromptTs || 0), ]))); }) .catch(() => {}); load(); const t = setInterval(() => { if (!document.hidden) load(); }, 20_000); return () => { alive = false; clearInterval(t); }; }, []); // Archive threshold from the operator config; refresh when settings closes // (that's where it's edited). useEffect(() => { if (settingsOpen) return; api.getConfig().then((c) => setArchiveAfter(c.archive?.after ?? 'month')).catch(() => {}); }, [settingsOpen]); const archivedIds = useMemo(() => { const out = new Set(); if (archiveAfter === 'never') return out; const cut = Date.now() - (archiveAfter === 'week' ? 7 : 30) * 864e5; for (const s of tree.sessions) { // Shells and file panes have no trace clock — never archive them. if (s.cli === 'shell' || s.cli === 'files' || s.state === 'working') continue; const last = ages[s.id] || Date.parse(s.createdAt) || 0; if (last && last < cut) out.add(s.id); } return out; }, [tree.sessions, ages, archiveAfter]); const cliMap = useMemo(() => Object.fromEntries(clis.map((c) => [c.id, c])), [clis]); 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]); // Keep a valid selection ('overview' is always valid). useEffect(() => { const ok = activeRef && (activeRef === 'overview' || (activeRef.startsWith('g:') ? groupById[activeRef.slice(2)] : sessById[activeRef.slice(2)])); if (!ok) setActiveRef(tree.order[0] ?? null); }, [tree.order, groupById, sessById, activeRef]); const activeGroup = activeRef?.startsWith('g:') ? groupById[activeRef.slice(2)] : null; const activeSingle = activeRef?.startsWith('s:') ? sessById[activeRef.slice(2)] : null; const groupSessions = useMemo( () => (activeGroup ? activeGroup.sessionIds.map((id) => sessById[id]).filter(Boolean) as Session[] : []), [activeGroup, sessById], ); // Tile grid for the active group: the chosen layout, or auto (fit the count). // On mobile it's always a single pane — the chip strip switches between them. const grid: GridSpec = activeGroup && !isMobile ? (activeGroup.layout ?? autoGrid(groupSessions.length)) : { cols: 1, rows: 1 }; const cap = grid.cols * grid.rows; const pageCount = Math.max(1, Math.ceil(groupSessions.length / cap)); // Page resets happen explicitly in the navigation handlers (an effect on // activeRef would clobber openSession's "land on this agent's pane"). const [pageRaw, setPage] = useState(0); const page = Math.min(pageRaw, pageCount - 1); // clamp when agents/layout change const pageSessions = activeGroup ? groupSessions.slice(page * cap, (page + 1) * cap) : []; const visibleSessions = activeGroup ? pageSessions : activeSingle ? [activeSingle] : []; const visibleIds = visibleSessions.map((s) => s.id).join(','); const showZoom = visibleSessions.length > 0; // Pane rearrangement (drag a pane header onto another tile). const [paneDrag, setPaneDrag] = useState(false); const [overTile, setOverTile] = useState(null); // A sidebar row being dragged ("s:" / "g:") — the stage shows // per-cell drop targets for sessions instead of one big outline. const [sessionDrag, setSessionDrag] = useState(null); const sessionDragActive = !!sessionDrag?.startsWith('s:'); // Keep a focused pane within the visible set. useEffect(() => { const ids = visibleIds ? visibleIds.split(',') : []; if (!focusedId || !ids.includes(focusedId)) setFocusedId(ids[0] ?? null); }, [visibleIds, focusedId]); // actions const createSession = async (name: string, cli: string, path: string, groupId?: string) => { try { const s = await api.createSession(name, cli, groupId, path); rememberPath(s.path); await refresh(); if (groupId) { setActiveRef(`g:${groupId}`); setFocusedId(s.id); } else setActiveRef(`s:${s.id}`); } catch (e) { showErr('Couldn’t create the agent')(e); } }; // Quickstart: server boots the agent and types the prompt; we jump straight // to the new pane so you watch it happen. const quickStart = async (cli: string, prompt: string, name = '', path = '.') => { try { const s = await api.quickStart(cli, prompt, name, path); rememberPath(s.path); await refresh(); setActiveRef(`s:${s.id}`); } catch (e) { showErr('Couldn’t quickstart the agent')(e); } }; // Creations land in an explicitly targeted group (the group's + button), // else the group you're currently looking at; loose otherwise. const newSession = (name: string, cli: string, path: string, groupId?: string) => createSession(name, cli, path, groupId ?? activeGroup?.id); const newGroup = async (name: string, cart?: { cli: string; count: number }[], path = ROOT_PATH) => { try { const g = await api.createGroup(name); for (const { cli, count } of cart || []) { const base = cliMap[cli]?.label || cli; for (let i = 0; i < count; i++) { const s = await api.createSession(count > 1 ? `${base} ${i + 1}` : base, cli, g.id, path); rememberPath(s.path); } } await refresh(); setActiveRef(`g:${g.id}`); } catch (e) { showErr('Couldn’t create the group')(e); } }; const doMove = (ref: string, to: MoveTarget) => api.move(ref, to).then(refresh).catch(showErr('Couldn’t move that')); const renameGroup = (id: string, name: string) => api.renameGroup(id, name).then(refresh).catch(showErr('Couldn’t rename')); const renameSession = (id: string, name: string) => { if (name.trim()) api.renameSession(id, name.trim()).then(refresh).catch(showErr('Couldn’t rename')); }; const deleteGroup = (id: string) => api.deleteGroup(id).then(() => { if (activeRef === `g:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the group')); const stopSession = (id: string) => api.stopSession(id).then(refresh).catch(showErr('Couldn’t stop the agent')); const deleteSession = (id: string) => api.deleteSession(id).then(() => { if (activeRef === `s:${id}`) setActiveRef(null); refresh(); }).catch(showErr('Couldn’t delete the agent')); // Clicking a session: nested → open its group with that pane focused; loose → solo view. const openSession = (sid: string, groupId?: string) => { if (groupId) { setActiveRef(`g:${groupId}`); setFocusedId(sid); // Land on the page that actually contains this agent's pane. const g = groupById[groupId]; const idx = g?.sessionIds.indexOf(sid) ?? -1; const gg = isMobile ? { cols: 1, rows: 1 } : (g?.layout ?? autoGrid(g?.sessionIds.length ?? 1)); setPage(idx >= 0 ? Math.floor(idx / (gg.cols * gg.rows)) : 0); } else { setActiveRef(`s:${sid}`); setPage(0); } if (isMobile) setMobileStage(true); }; const activate = (ref: string) => { setActiveRef(ref); setPage(0); if (isMobile) setMobileStage(true); }; const closePane = (sid: string) => { // On mobile ✕ just returns to the list — never the desktop ungroup gesture. if (isMobile) { setMobileStage(false); return; } if (activeGroup) doMove(`s:${sid}`, { kind: 'after', ref: activeRef! }); else setActiveRef(null); }; const setLayout = async (l: GridSpec | null) => { if (!activeGroup) return; await api.updateGroup(activeGroup.id, { layout: l }); refresh(); }; // Drop pane `paneId` on the tile showing position `targetIdx` (absolute index // into the group's order): occupied tile → swap places; empty tile → move to // the end. const movePane = async (paneId: string, targetIdx: number) => { if (!activeGroup) return; const ids = activeGroup.sessionIds.slice(); const from = ids.indexOf(paneId); if (from < 0) return; const target = groupSessions[targetIdx]; if (target && target.id !== paneId) { const ti = ids.indexOf(target.id); ids[from] = ids[ti]; ids[ti] = paneId; } else if (!target) { ids.splice(from, 1); ids.push(paneId); } else return; await api.updateGroup(activeGroup.id, { sessionIds: ids }); refresh(); }; const promptUser = info?.spaceId?.split('/')[0] || 'you'; // Empty states speak the app's native language: a prompt, waiting. const EmptyPrompt = ({ path, hints }: { path: string; hints: string[] }) => (
{promptUser} / {path} $
{hints.map((h) =>

{h}

)}
); const allowDrop = (e: React.DragEvent) => { if (paneDrag) return; e.preventDefault(); setDropMain(true); }; const onDropMain = (e: React.DragEvent) => { e.preventDefault(); const ref = e.dataTransfer.getData('text/plain'); if (ref?.startsWith('s:') && activeGroup) doMove(ref, { kind: 'into', groupId: activeGroup.id }); setDropMain(false); }; // One grid cell. Empty cells become visible drop targets while a pane drags // (rearrange) or a sidebar session drags over the group (join at that spot). const tileDnd = (i: number, occupied: boolean) => ({ onDragOver: (e: React.DragEvent) => { if (paneDrag || (sessionDragActive && !occupied)) { e.preventDefault(); e.stopPropagation(); setOverTile(i); } }, onDragLeave: () => setOverTile((t) => (t === i ? null : t)), onDrop: (e: React.DragEvent) => { const d = e.dataTransfer.getData('text/plain'); if (d.startsWith('p:')) { e.preventDefault(); e.stopPropagation(); movePane(d.slice(2), page * cap + i); } else if (d.startsWith('s:') && activeGroup) { e.preventDefault(); e.stopPropagation(); doMove(d, { kind: 'into', groupId: activeGroup.id }); } setOverTile(null); }, }); const renderTiles = (sessions: Session[], g: GridSpec) => { const slotCount = activeGroup ? g.cols * g.rows : sessions.length; const slots = Array.from({ length: slotCount }, (_, i) => sessions[i] ?? null); const canDrag = !!activeGroup && groupSessions.length > 1; // A sidebar session hovering a full grid still needs somewhere to land: // offer a ghost strip appended below the tiles. const ghost = sessionDragActive && !!activeGroup; return (
setDropMain(false)} onDrop={activeGroup ? onDropMain : undefined} > {slots.map((s, i) => (
{s && (s.cli === 'files' ? ( 1 && s.id === focusedId} dragId={canDrag ? `p:${s.id}` : undefined} onDragActive={setPaneDrag} onFocus={() => setFocusedId(s.id)} onClose={() => closePane(s.id)} /> ) : ( 1 && s.id === focusedId} active={s.id === focusedId} dragId={canDrag ? `p:${s.id}` : undefined} onDragActive={setPaneDrag} onFocus={() => setFocusedId(s.id)} onRename={(name) => renameSession(s.id, name)} onClose={() => closePane(s.id)} /> ))}
))} {ghost && (
drop to add to {activeGroup!.name}
)}
); }; if (info?.locked) return ; if (settingsOpen) { return ( setSettingsOpen(false)} theme={theme} onToggleTheme={toggleTheme} clis={clis} info={info} onShowWelcome={openWelcome} demoMode={!!info?.demoMode} onToggleDemo={toggleDemo} /> ); } return (
{showWelcome && } {toast &&
{toast}
} setSettingsOpen(true)} theme={theme} onToggleTheme={toggleTheme} onQuickStart={quickStart} archived={archivedIds} showArchived={showArchived} onToggleArchived={() => setShowArchived((v) => !v)} />
{isMobile && mobileStage && (
{activeGroup ? (
{groupSessions.map((s, i) => ( ))}
) : ( {activeRef === 'overview' ? 'Overview' : activeSingle?.name} )}
)}
{activeRef === 'overview' ? ( { const g = tree.groups.find((x) => x.sessionIds.includes(sid)); openSession(sid, g?.id); }} /> ) : activeGroup ? ( groupSessions.length === 0 ? (
setDropMain(false)} onDrop={onDropMain} >
drop an agent here to add it to this group
) : renderTiles(pageSessions, grid) ) : activeSingle ? ( renderTiles([activeSingle], { cols: 1, rows: 1 }) ) : (
)}
{activeRef === 'overview' && (
{(['all', 'waiting', 'working', 'quiet'] as OverviewFilter[]).map((f) => ( ))}
)} {showZoom && (
{!isMobile && activeGroup && groupSessions.length > 0 && ( )} {!isMobile && activeGroup && pageCount > 1 && ( {page + 1}/{pageCount} )}
)}
); }