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:
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-notification = "2"
|
||||
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:app:default",
|
||||
"core:resources:default",
|
||||
"core:menu:default",
|
||||
"core:tray:default"
|
||||
"core:tray:default",
|
||||
"notification:default"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use tauri::Manager;
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
// Connect the bridge on a tokio runtime, then manage it.
|
||||
|
||||
+50
-9
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
Reference in New Issue
Block a user