import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { LayoutEngine } from "./LayoutEngine"; import { Sidebar } from "./Sidebar"; import { TopBar } from "./TopBar"; import { CenterToolbar } from "./CenterToolbar"; import { Wizard } from "./Wizard"; import { ConfirmDelete } from "./ConfirmDelete"; import { Settings } from "./Settings"; import { EventCenter } from "./EventCenter"; import { maybeNotify } from "./notify"; import { COLORS, applyTheme, resolvePalette } from "./theme"; import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead, getHealth, closeWorkspaceCmd, getConfig } from "./socketBridge"; import type { EventRecord, DaemonHealth, ConfigView } from "./socketBridge"; import { leafIds } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; /** Read a boolean UI flag from localStorage, falling back to `def`. */ function loadFlag(key: string, def: boolean): boolean { try { const v = localStorage.getItem(key); return v === null ? def : v === "1"; } catch { return def; } } function saveFlag(key: string, value: boolean): void { try { localStorage.setItem(key, value ? "1" : "0"); } catch { /* ignore */ } } export function App() { const [groups, setGroups] = useState([]); const [workspaces, setWorkspaces] = useState([]); const [activeId, setActiveId] = useState(null); const [running, setRunning] = useState>({}); const [states, setStates] = useState>({}); const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [eventsOpen, setEventsOpen] = useState(() => loadFlag("spacesh.eventsOpen", true)); const [sidebarOpen, setSidebarOpen] = useState(() => loadFlag("spacesh.sidebarOpen", true)); const [health, setHealth] = useState(null); const [config, setConfigState] = useState(null); // Bumped when the daemon connection is re-established; used to remount the // layout so terminals re-attach (snapshot + live stream) to the restarted daemon. const [connEpoch, setConnEpoch] = useState(0); const [connected, setConnected] = useState(false); const [focusedId, setFocusedId] = useState(null); const [searchSurfaceId, setSearchSurfaceId] = useState(null); const [searchNonce, setSearchNonce] = useState(0); const activeRef = useRef(null); const effectiveFocusRef = useRef(null); const wsRef = useRef([]); activeRef.current = activeId; wsRef.current = workspaces; const seedEvents = useCallback(async () => { const log = await getEventLog(); setEvents((existing) => { const byId = new Map(); for (const e of log.events) byId.set(e.id, e); // daemon is authoritative for overlapping ids for (const e of existing) if (!byId.has(e.id)) byId.set(e.id, e); // keep live events not in the snapshot return [...byId.values()].sort((a, b) => b.id - a.id).slice(0, 1000); }); }, []); const refresh = useCallback(async () => { const st = await getStatusFull(); setGroups(st.groups); setWorkspaces(st.workspaces); const run: Record = {}; const stt: Record = {}; st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; stt[id] = sv.state; })); setRunning(run); setStates(stt); if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id); }, []); const loadHealth = useCallback(async () => { try { setHealth(await getHealth()); setConnected(true); } catch { setConnected(false); } }, []); const wsOf = (surfaceId: string): WorkspaceView | undefined => wsRef.current.find((w) => surfaceId in w.surfaces); useEffect(() => { void refresh(); void seedEvents(); void loadHealth(); void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); const unlisten = onDaemonEvent((evt) => { if (evt.evt === "event") { const rec = evt.data.record; setEvents((es) => [rec, ...es].slice(0, 1000)); const w = wsOf(rec.surface_id); if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true }); void maybeNotify(rec.surface_id, rec.agent_label ?? "shell", rec.workspace_name, rec.kind); } else if (evt.evt === "events_read") { const ids = new Set(evt.data.ids); setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e))); } else if (evt.evt === "state") { setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state })); void refresh(); } else if (evt.evt === "exit") { void refresh(); } else if (evt.evt === "config_changed") { const c = evt.data.config; setConfigState(c); applyTheme(c.theme, c.accent); } else { void refresh(); } }); const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { setConnected(false); void refresh(); void seedEvents(); void loadHealth(); void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); }); const reconnected = onDaemonRawEvent("spacesh:reconnected", () => { setConnected(true); setConnEpoch((n) => n + 1); // remount layout → terminals re-attach to the new daemon void refresh(); void seedEvents(); void loadHealth(); void getConfig().then((c) => { setConfigState(c); applyTheme(c.theme, c.accent); }).catch(() => {}); }); return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); void reconnected.then((f) => f()); }; }, [refresh, seedEvents, loadHealth]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "f") { if (activeRef.current && effectiveFocusRef.current) { e.preventDefault(); setSearchSurfaceId(effectiveFocusRef.current); // anchor to the focused panel setSearchNonce((n) => n + 1); } } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); useEffect(() => { saveFlag("spacesh.eventsOpen", eventsOpen); }, [eventsOpen]); useEffect(() => { saveFlag("spacesh.sidebarOpen", sidebarOpen); }, [sidebarOpen]); const unread = useMemo(() => events.filter((e) => !e.read).length, [events]); const active = workspaces.find((w) => w.id === activeId) ?? null; const leaves = active ? leafIds(active.layout) : []; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; effectiveFocusRef.current = effectiveFocus; const termPalette = useMemo(() => (config ? resolvePalette(config.theme, config.accent) : null), [config?.theme, config?.accent]); const termFont = useMemo(() => (config ? { family: config.font_family, size: config.font_size } : null), [config?.font_family, config?.font_size]); function selectWorkspace(id: string) { setActiveId(id); setFocusedId(null); void setWorkspaceMeta(id, { unread: false }); } return (
setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
{sidebarOpen && setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
{active && ( { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} /> )}
{active ? setSearchSurfaceId(null)} font={termFont} palette={termPalette} /> :
No workspace — create one to begin.
}
{eventsOpen && ( { void markEventsRead({ target: "all" }); }} onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }} /> )}
{settingsOpen && config && setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />} {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {deleteTarget && ( s.running).length} onCancel={() => setDeleteTarget(null)} onConfirm={() => { const tgt = deleteTarget; setDeleteTarget(null); void closeWorkspaceCmd(tgt.id).then(() => { if (activeId === tgt.id) { const next = workspaces.find((w) => w.id !== tgt.id); setActiveId(next ? next.id : null); } void refresh(); }); }} /> )}
); }