Files
spaceshell/app/src/App.tsx
T
vasyansk 9ca0164d0b fix(app): bridge auto-reconnect so daemon restart no longer bricks the GUI
The Tauri bridge connected to the daemon once at startup and held a single
stream with no recovery: when the daemon exited (Restart/Stop, crash, or an
update), the reader emitted spacesh:disconnected and died, and every later
request went through the dead writer forever — the GUI was permanently stuck
(settings frozen, offline). Since the bridge is Rust-side state that survives
a webview reload, even Cmd+R didn't recover it.

- bridge.rs: requests now reconnect-and-retry on failure with a single-flight
  guard (generation counter) so concurrent failures collapse into one
  reconnect and never open duplicate connections; a 5s reply timeout catches
  silently-dropped connections. ensure_daemon respawns the daemon if it
  exited. On success the bridge emits spacesh:reconnected.
- App.tsx: on spacesh:reconnected, bump a connection epoch that keys
  LayoutEngine, remounting terminals so they re-attach (snapshot + live stream)
  to the restarted daemon; also reload health/config/status.
- Settings: drop the Stop button — with lazy daemon spawn any GUI request
  resurrects the daemon, so an in-GUI "stop" is contradictory. Restart now
  works end to end (shutdown → reconnect respawns → panels re-attach).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:22:24 +07:00

207 lines
10 KiB
TypeScript

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<Group[]>([]);
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
const [running, setRunning] = useState<Record<string, boolean>>({});
const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<WorkspaceView | null>(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<DaemonHealth | null>(null);
const [config, setConfigState] = useState<ConfigView | null>(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<string | null>(null);
const [searchSurfaceId, setSearchSurfaceId] = useState<string | null>(null);
const [searchNonce, setSearchNonce] = useState(0);
const activeRef = useRef<string | null>(null);
const effectiveFocusRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId;
wsRef.current = workspaces;
const seedEvents = useCallback(async () => {
const log = await getEventLog();
setEvents((existing) => {
const byId = new Map<number, EventRecord>();
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<string, boolean> = {};
const stt: Record<string, SurfaceState> = {};
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} onShowEvents={() => setEventsOpen(true)} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((v) => !v)} unread={unread} onOpenSettings={() => { if (config) setSettingsOpen(true); }} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{sidebarOpen && <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} onDelete={setDeleteTarget} health={health} connected={connected} />}
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} onOpenSearch={() => { if (effectiveFocus) { setSearchSurfaceId(effectiveFocus); setSearchNonce((n) => n + 1); } }} />
)}
<div style={{ flex: 1, minHeight: 0, position: "relative" }}>
{active
? <LayoutEngine key={connEpoch} workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} zoomed={active.zoomed} searchSurfaceId={searchSurfaceId} searchNonce={searchNonce} onCloseSearch={() => setSearchSurfaceId(null)} font={termFont} palette={termPalette} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
{eventsOpen && (
<EventCenter
events={events}
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
/>
)}
</div>
{settingsOpen && config && <Settings config={config} health={health} onClose={() => setSettingsOpen(false)} onReload={() => { void loadHealth(); void refresh(); }} />}
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
{deleteTarget && (
<ConfirmDelete
name={deleteTarget.name}
activeCount={Object.values(deleteTarget.surfaces).filter((s) => 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();
});
}}
/>
)}
</div>
);
}