From 834d61c69acaf3d817b77b4bb4c5bc777ff42c29 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 10 Jun 2026 08:23:26 +0700 Subject: [PATCH] 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 --- app/src/App.tsx | 61 +++++++++++++++++++++++++---------------- app/src/EventCenter.tsx | 53 +++++++++++++++++++---------------- app/src/TopBar.tsx | 17 ++++++++++-- app/src/notify.ts | 7 ++--- app/src/socketBridge.ts | 28 ++++++++++++++++++- 5 files changed, 113 insertions(+), 53 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 12809e4..5aaac1f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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(null); const [running, setRunning] = useState>({}); const [states, setStates] = useState>({}); - const [feed, setFeed] = useState([]); + const [events, setEvents] = useState([]); const [wizard, setWizard] = useState(false); const [eventsOpen, setEventsOpen] = useState(true); const [focusedId, setFocusedId] = useState(null); - const feedId = useRef(0); const activeRef = 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); @@ -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 (
- setEventsOpen((v) => !v)} /> + setEventsOpen((v) => !v)} unread={unread} />
setWizard(true)} />
@@ -95,7 +104,13 @@ export function App() { :
No workspace — create one to begin.
}
- {eventsOpen && setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />} + {eventsOpen && ( + { void markEventsRead({ target: "all" }); }} + onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }} + /> + )}
{wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} diff --git a/app/src/EventCenter.tsx b/app/src/EventCenter.tsx index d202c4d..561d348 100644 --- a/app/src/EventCenter.tsx +++ b/app/src/EventCenter.tsx @@ -1,23 +1,13 @@ import { useState } from "react"; -import { Check, Hourglass, X, CircleDot, Power, Send, MessageSquare } from "lucide-react"; +import { Check, Hourglass, X, Power, Send, MessageSquare } from "lucide-react"; import { COLORS, FONT } from "./theme"; -import type { SurfaceState } from "./layoutTypes"; - -export interface FeedEntry { - id: number; - surfaceId: string; - workspace: string; - agent: string; - kind: SurfaceState | "exit"; - time: string; -} +import type { EventRecord } from "./socketBridge"; const ICON: Record = { - done: , wait: , error: , - work: , idle: , exit: , + done: , wait: , error: , exit: , }; const COLOR: Record = { - done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, work: COLORS.stWork, idle: COLORS.stIdle, exit: COLORS.textMuted, + done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, exit: COLORS.textMuted, }; type Tab = "all" | "unread" | "errors"; @@ -27,15 +17,31 @@ const TABS: { id: Tab; label: string }[] = [ { id: "errors", label: "Errors" }, ]; -export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[]; onMarkRead: () => void; onSelect: (surfaceId: string) => void }) { +function rel(ts: number): string { + const s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +export function EventCenter({ + events, onMarkAllRead, onSelect, +}: { + events: EventRecord[]; + onMarkAllRead: () => void; + onSelect: (surfaceId: string, id: number) => void; +}) { const [tab, setTab] = useState("all"); - const shown = tab === "errors" ? feed.filter((e) => e.kind === "error") : feed; + const shown = tab === "unread" ? events.filter((e) => !e.read) + : tab === "errors" ? events.filter((e) => e.kind === "error") + : events; return (
Event Center - Mark all read + Mark all read
@@ -58,18 +64,19 @@ export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[];
{shown.length === 0 &&
No events yet.
} {shown.map((e) => ( -
onSelect(e.surfaceId)} - style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer" }}> - {ICON[e.kind]} +
onSelect(e.surface_id, e.id)} + style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer", opacity: e.read ? 0.55 : 1 }}> + {ICON[e.kind] ?? null}
-
{e.workspace} · {e.agent}
-
{e.kind} {e.time}
+
{e.workspace_name} · {e.agent_label ?? "shell"}
+
{e.kind} {rel(e.ts)}
+ {!e.read && }
))}
- {/* External notification channels — mocked until the daemon subscriber lands (M5). */} + {/* External notification channels — mocked until the daemon subscriber lands (SP5). */}
EXTERNAL NOTIFY
diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx index 6086f19..72e3342 100644 --- a/app/src/TopBar.tsx +++ b/app/src/TopBar.tsx @@ -29,11 +29,12 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl } export function TopBar({ - active, eventsOpen, onToggleEvents, + active, eventsOpen, onToggleEvents, unread, }: { active: WorkspaceView | null; eventsOpen: boolean; onToggleEvents: () => void; + unread: number; }) { return (
} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> } title="Search (mock)" /> - } title="Notifications (mock)" /> +
+ } title="Notifications (mock)" /> + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} +
} title="Settings (mock)" />