Files
spaceshell/app/src/EventCenter.tsx
T
vasyansk 834d61c69a 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>
2026-06-10 08:28:35 +07:00

98 lines
5.0 KiB
TypeScript

import { useState } from "react";
import { Check, Hourglass, X, Power, Send, MessageSquare } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { EventRecord } from "./socketBridge";
const ICON: Record<string, React.ReactNode> = {
done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />, exit: <Power size={13} />,
};
const COLOR: Record<string, string> = {
done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, exit: COLORS.textMuted,
};
type Tab = "all" | "unread" | "errors";
const TABS: { id: Tab; label: string }[] = [
{ id: "all", label: "All" },
{ id: "unread", label: "Unread" },
{ id: "errors", label: "Errors" },
];
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 shown = tab === "unread" ? events.filter((e) => !e.read)
: tab === "errors" ? events.filter((e) => e.kind === "error")
: events;
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", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
</div>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
{TABS.map((t) => {
const on = t.id === tab;
return (
<button key={t.id} onClick={() => setTab(t.id)}
style={{
height: 22, padding: "0 9px", borderRadius: 11, fontFamily: FONT.ui, fontSize: 11, fontWeight: on ? 600 : 400,
background: on ? COLORS.bgElevated : "transparent",
border: `1px solid ${on ? COLORS.borderStrong : "transparent"}`,
color: on ? COLORS.textPrimary : COLORS.textMuted,
}}>
{t.label}
</button>
);
})}
</div>
<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.map((e) => (
<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", opacity: e.read ? 0.55 : 1 }}>
<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={{ 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 }}>{rel(e.ts)}</span></div>
</div>
{!e.read && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, alignSelf: "center", flex: "0 0 7px" }} />}
</div>
))}
</div>
{/* 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}` }}>
<span style={{ fontFamily: FONT.ui, fontSize: 10, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textMuted }}>EXTERNAL NOTIFY</span>
<div style={{ display: "flex", gap: 8 }}>
{[
{ name: "Telegram", icon: <Send size={13} /> },
{ name: "MAX", icon: <MessageSquare size={13} /> },
].map((c) => (
<div key={c.name} style={{ display: "flex", alignItems: "center", gap: 7, flex: 1, height: 30, padding: "0 10px", borderRadius: 7, background: COLORS.bgPanel }}>
<span style={{ color: COLORS.textMuted, display: "flex" }}>{c.icon}</span>
<span style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textSecondary, flex: 1 }}>{c.name}</span>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: COLORS.textMuted }} />
</div>
))}
</div>
</div>
</div>
);
}