feat(app): daemon-sourced Event Center feed, read-model, bell badge

Source Event Center from daemon event_log (seed + live event/events_read push).
Unread/Errors tabs filter real EventRecord flags; bell shows numeric unread badge;
clicking an entry calls focusSurface + markEventsRead(ids). notify.ts param widened
to string so exit kind type-checks without breaking existing NOTIFY_STATES guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:23:26 +07:00
parent d08a446b6f
commit 834d61c69a
5 changed files with 113 additions and 53 deletions
+38 -23
View File
@@ -1,13 +1,14 @@
import { useEffect, useState, useCallback, useRef } from "react";
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 { EventCenter, type FeedEntry } from "./EventCenter";
import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify";
import { COLORS } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge";
import type { EventRecord } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -17,16 +18,25 @@ export function App() {
const [activeId, setActiveId] = useState<string | null>(null);
const [running, setRunning] = useState<Record<string, boolean>>({});
const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [feed, setFeed] = useState<FeedEntry[]>([]);
const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false);
const [eventsOpen, setEventsOpen] = useState(true);
const [focusedId, setFocusedId] = useState<string | null>(null);
const feedId = useRef(0);
const activeRef = 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);
@@ -44,32 +54,31 @@ export function App() {
useEffect(() => {
void refresh();
void seedEvents();
const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "state") {
const { surface_id, state } = evt.data;
setStates((m) => ({ ...m, [surface_id]: state }));
const w = wsOf(surface_id);
const agent = w?.surfaces[surface_id]?.spec.agent_label ?? "shell";
if (["done", "wait", "error"].includes(state)) {
const entry: FeedEntry = { id: feedId.current++, surfaceId: surface_id, workspace: w?.name ?? "?", agent, kind: state, time: "now" };
setFeed((f) => [entry, ...f].slice(0, 200));
if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true });
void maybeNotify(surface_id, agent, w?.name ?? "?", state);
}
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") {
const w = wsOf(evt.data.surface_id);
const exitEntry: FeedEntry = { id: feedId.current++, surfaceId: evt.data.surface_id, workspace: w?.name ?? "?", agent: w?.surfaces[evt.data.surface_id]?.spec.agent_label ?? "shell", kind: "exit", time: "now" };
setFeed((f) => [exitEntry, ...f].slice(0, 200));
void refresh();
} else {
void refresh();
}
});
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); void seedEvents(); });
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh]);
}, [refresh, seedEvents]);
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;
@@ -82,7 +91,7 @@ export function App() {
return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} />
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
@@ -95,7 +104,13 @@ export function App() {
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
{eventsOpen && <EventCenter feed={feed} onMarkRead={() => setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />}
{eventsOpen && (
<EventCenter
events={events}
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
/>
)}
</div>
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
</div>