feat(app): Event Center, native notifications, auto-unread, state wiring in App

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:13:00 +07:00
parent d36548ff39
commit 1ecefdeb80
7 changed files with 114 additions and 10 deletions
+50 -9
View File
@@ -1,40 +1,80 @@
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar";
import { PresetPicker } from "./PresetPicker";
import { Wizard } from "./Wizard";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
import type { Group, WorkspaceView } from "./layoutTypes";
import { EventCenter, type FeedEntry } from "./EventCenter";
import { maybeNotify } from "./notify";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
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 [feed, setFeed] = useState<FeedEntry[]>([]);
const [wizard, setWizard] = useState(false);
const feedId = useRef(0);
const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId;
wsRef.current = workspaces;
const refresh = useCallback(async () => {
const st = await getStatusFull();
setGroups(st.groups);
setWorkspaces(st.workspaces);
const run: Record<string, boolean> = {};
st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; }));
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);
if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id);
}, [activeId]);
setStates(stt);
if (!activeRef.current && st.workspaces.length) setActiveId(st.workspaces[0].id);
}, []);
const wsOf = (surfaceId: string): WorkspaceView | undefined =>
wsRef.current.find((w) => surfaceId in w.surfaces);
useEffect(() => {
void refresh();
const unlisten = onDaemonEvent(() => { void refresh(); });
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);
}
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(); });
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh]);
const active = workspaces.find((w) => w.id === activeId) ?? null;
function selectWorkspace(id: string) {
setActiveId(id);
void setWorkspaceMeta(id, { unread: false });
}
return (
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={setActiveId} 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 }}>
{active && (
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
@@ -43,10 +83,11 @@ export function App() {
)}
<div style={{ flex: 1, minHeight: 0 }}>
{active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} />
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} />
: <div style={{ color: "#666", padding: 24 }}>No workspace create one to begin.</div>}
</div>
</div>
<EventCenter feed={feed} onMarkRead={() => setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
</div>
);
+37
View File
@@ -0,0 +1,37 @@
import type { SurfaceState } from "./layoutTypes";
export interface FeedEntry {
id: number;
surfaceId: string;
workspace: string;
agent: string;
kind: SurfaceState | "exit";
time: string;
}
const ICON: Record<string, string> = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" };
const COLOR: Record<string, string> = { done: "#3FB950", wait: "#F2B84B", error: "#F4544E", work: "#4C8DFF", idle: "#5A6573", exit: "#5A6573" };
export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[]; onMarkRead: () => void; onSelect: (surfaceId: string) => void }) {
return (
<div style={{ width: 300, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", display: "flex", flexDirection: "column", borderLeft: "1px solid #232A33" }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: "Inter", fontSize: 13, fontWeight: 700, color: "#E6EDF3", flex: 1 }}>Event Center</span>
<span onClick={onMarkRead} style={{ fontSize: 11, color: "#4C8DFF", cursor: "pointer" }}>Mark all read</span>
</div>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
{feed.length === 0 && <div style={{ color: "#5A6573", fontSize: 12 }}>No events yet.</div>}
{feed.map((e) => (
<div key={e.id} onClick={() => onSelect(e.surfaceId)}
style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}>
<span style={{ color: COLOR[e.kind] }}>{ICON[e.kind]}</span>
<div style={{ flex: 1 }}>
<div style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{e.workspace} · {e.agent}</div>
<div style={{ fontFamily: "Inter", fontSize: 12, color: "#E6EDF3" }}>{e.kind} <span style={{ color: "#5A6573" }}>{e.time}</span></div>
</div>
</div>
))}
</div>
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { SurfaceState } from "./layoutTypes";
const NOTIFY_STATES: SurfaceState[] = ["done", "wait", "error"];
let lastBySurface: Record<string, SurfaceState> = {};
/// 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> {
if (!NOTIFY_STATES.includes(state)) return;
if (lastBySurface[surfaceId] === state) return; // dedup repeats
lastBySurface[surfaceId] = state;
const focused = await getCurrentWindow().isFocused().catch(() => true);
if (focused) return;
let granted = await isPermissionGranted();
if (!granted) granted = (await requestPermission()) === "granted";
if (!granted) return;
sendNotification({ title: `${workspace} · ${agent}`, body: `${state}` });
}