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 { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { TopBar } from "./TopBar"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar"; import { CenterToolbar } from "./CenterToolbar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { EventCenter, type FeedEntry } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
import { COLORS } from "./theme"; 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 { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
@@ -17,16 +18,25 @@ export function App() {
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [running, setRunning] = useState<Record<string, boolean>>({}); const [running, setRunning] = useState<Record<string, boolean>>({});
const [states, setStates] = useState<Record<string, SurfaceState>>({}); const [states, setStates] = useState<Record<string, SurfaceState>>({});
const [feed, setFeed] = useState<FeedEntry[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
const [eventsOpen, setEventsOpen] = useState(true); const [eventsOpen, setEventsOpen] = useState(true);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const feedId = useRef(0);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId; activeRef.current = activeId;
wsRef.current = workspaces; 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 refresh = useCallback(async () => {
const st = await getStatusFull(); const st = await getStatusFull();
setGroups(st.groups); setGroups(st.groups);
@@ -44,32 +54,31 @@ export function App() {
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
void seedEvents();
const unlisten = onDaemonEvent((evt) => { const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "state") { if (evt.evt === "event") {
const { surface_id, state } = evt.data; const rec = evt.data.record;
setStates((m) => ({ ...m, [surface_id]: state })); setEvents((es) => [rec, ...es].slice(0, 1000));
const w = wsOf(surface_id); const w = wsOf(rec.surface_id);
const agent = w?.surfaces[surface_id]?.spec.agent_label ?? "shell"; if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true });
if (["done", "wait", "error"].includes(state)) { void maybeNotify(rec.surface_id, rec.agent_label ?? "shell", rec.workspace_name, rec.kind);
const entry: FeedEntry = { id: feedId.current++, surfaceId: surface_id, workspace: w?.name ?? "?", agent, kind: state, time: "now" }; } else if (evt.evt === "events_read") {
setFeed((f) => [entry, ...f].slice(0, 200)); const ids = new Set(evt.data.ids);
if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true }); setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
void maybeNotify(surface_id, agent, w?.name ?? "?", state); } else if (evt.evt === "state") {
} setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
void refresh(); void refresh();
} else if (evt.evt === "exit") { } 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(); void refresh();
} else { } else {
void refresh(); 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()); }; 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 active = workspaces.find((w) => w.id === activeId) ?? null;
const leaves = active ? leafIds(active.layout) : []; const leaves = active ? leafIds(active.layout) : [];
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null; const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
@@ -82,7 +91,7 @@ export function App() {
return ( return (
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}> <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 }}> <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} /> <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <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 style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div> </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> </div>
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />} {wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
</div> </div>
+30 -23
View File
@@ -1,23 +1,13 @@
import { useState } from "react"; 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 { COLORS, FONT } from "./theme";
import type { SurfaceState } from "./layoutTypes"; import type { EventRecord } from "./socketBridge";
export interface FeedEntry {
id: number;
surfaceId: string;
workspace: string;
agent: string;
kind: SurfaceState | "exit";
time: string;
}
const ICON: Record<string, React.ReactNode> = { const ICON: Record<string, React.ReactNode> = {
done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />, done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />, exit: <Power size={13} />,
work: <CircleDot size={13} />, idle: <CircleDot size={13} />, exit: <Power size={13} />,
}; };
const COLOR: Record<string, string> = { const COLOR: Record<string, string> = {
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"; type Tab = "all" | "unread" | "errors";
@@ -27,15 +17,31 @@ const TABS: { id: Tab; label: string }[] = [
{ id: "errors", label: "Errors" }, { 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<Tab>("all"); const [tab, setTab] = useState<Tab>("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 ( return (
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}> <div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}> <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span> <span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span> <span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
</div> </div>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}> <div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
@@ -58,18 +64,19 @@ export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[];
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}> <div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
{shown.length === 0 && <div style={{ color: COLORS.textMuted, fontSize: 12 }}>No events yet.</div>} {shown.length === 0 && <div style={{ color: COLORS.textMuted, fontSize: 12 }}>No events yet.</div>}
{shown.map((e) => ( {shown.map((e) => (
<div key={e.id} onClick={() => onSelect(e.surfaceId)} <div key={e.id} onClick={() => onSelect(e.surface_id, e.id)}
style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer" }}> style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer", opacity: e.read ? 0.55 : 1 }}>
<span style={{ color: COLOR[e.kind], display: "flex", alignItems: "center" }}>{ICON[e.kind]}</span> <span style={{ color: COLOR[e.kind] ?? COLORS.textMuted, display: "flex", alignItems: "center" }}>{ICON[e.kind] ?? null}</span>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{e.workspace} · {e.agent}</div> <div style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{e.workspace_name} · {e.agent_label ?? "shell"}</div>
<div style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>{e.kind} <span style={{ color: COLORS.textMuted }}>{e.time}</span></div> <div style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>{e.kind} <span style={{ color: COLORS.textMuted }}>{rel(e.ts)}</span></div>
</div> </div>
{!e.read && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, alignSelf: "center", flex: "0 0 7px" }} />}
</div> </div>
))} ))}
</div> </div>
{/* External notification channels — mocked until the daemon subscriber lands (M5). */} {/* External notification channels — mocked until the daemon subscriber lands (SP5). */}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10, paddingTop: 10, borderTop: `1px solid ${COLORS.borderSubtle}` }}> <div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10, paddingTop: 10, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
<span style={{ fontFamily: FONT.ui, fontSize: 10, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textMuted }}>EXTERNAL NOTIFY</span> <span style={{ fontFamily: FONT.ui, fontSize: 10, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textMuted }}>EXTERNAL NOTIFY</span>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
+15 -2
View File
@@ -29,11 +29,12 @@ function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onCl
} }
export function TopBar({ export function TopBar({
active, eventsOpen, onToggleEvents, active, eventsOpen, onToggleEvents, unread,
}: { }: {
active: WorkspaceView | null; active: WorkspaceView | null;
eventsOpen: boolean; eventsOpen: boolean;
onToggleEvents: () => void; onToggleEvents: () => void;
unread: number;
}) { }) {
return ( return (
<div <div
@@ -68,7 +69,19 @@ export function TopBar({
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> <IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
<IconBtn icon={<Search size={16} />} title="Search (mock)" /> <IconBtn icon={<Search size={16} />} title="Search (mock)" />
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" /> <div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" />
{unread > 0 && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
borderRadius: 7, background: COLORS.stError, color: "#fff",
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box",
}}>
{unread > 99 ? "99+" : unread}
</span>
)}
</div>
<IconBtn icon={<Settings size={16} />} title="Settings (mock)" /> <IconBtn icon={<Settings size={16} />} title="Settings (mock)" />
<span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} /> <span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} />
<button <button
+3 -4
View File
@@ -1,12 +1,11 @@
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import type { SurfaceState } from "./layoutTypes";
const NOTIFY_STATES: SurfaceState[] = ["done", "wait", "error"]; const NOTIFY_STATES = ["done", "wait", "error"];
let lastBySurface: Record<string, SurfaceState> = {}; let lastBySurface: Record<string, string> = {};
/// Fire a native notification for a status change when the window is unfocused. /// Fire a native notification for a status change when the window is unfocused.
export async function maybeNotify(surfaceId: string, agent: string, workspace: string, state: SurfaceState): Promise<void> { export async function maybeNotify(surfaceId: string, agent: string, workspace: string, state: string): Promise<void> {
if (!NOTIFY_STATES.includes(state)) return; if (!NOTIFY_STATES.includes(state)) return;
if (lastBySurface[surfaceId] === state) return; // dedup repeats if (lastBySurface[surfaceId] === state) return; // dedup repeats
lastBySurface[surfaceId] = state; lastBySurface[surfaceId] = state;
+27 -1
View File
@@ -62,6 +62,30 @@ export async function getStatus(): Promise<WorkspaceStatus[]> {
return data.workspaces; return data.workspaces;
} }
export interface EventRecord {
id: number;
surface_id: string;
workspace_id: string;
workspace_name: string;
agent_label: string | null;
kind: "done" | "wait" | "error" | "exit";
ts: number;
read: boolean;
}
export type MarkReadTarget =
| { target: "all" }
| { target: "ids"; value: number[] }
| { target: "surface"; value: string };
export async function getEventLog(limit?: number): Promise<{ events: EventRecord[]; unread: number }> {
return await invoke<{ events: EventRecord[]; unread: number }>("event_log", { limit: limit ?? null });
}
export async function markEventsRead(target: MarkReadTarget): Promise<void> {
await invoke("mark_read", { target });
}
export type DaemonEvt = export type DaemonEvt =
| { evt: "exit"; data: { surface_id: string; code: number } } | { evt: "exit"; data: { surface_id: string; code: number } }
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } } | { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
@@ -69,7 +93,9 @@ export type DaemonEvt =
| { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } } | { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } }
| { evt: "layout_changed"; data: { workspace_id: string } } | { evt: "layout_changed"; data: { workspace_id: string } }
| { evt: "workspace_changed"; data: unknown } | { evt: "workspace_changed"; data: unknown }
| { evt: "groups_changed"; data: unknown }; | { evt: "groups_changed"; data: unknown }
| { evt: "event"; data: { record: EventRecord } }
| { evt: "events_read"; data: { ids: number[] } };
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> { export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload)); return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));