diff --git a/app/package.json b/app/package.json index 09594eb..f916090 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index deabc06..139a356 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -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"] } diff --git a/app/src-tauri/capabilities/default.json b/app/src-tauri/capabilities/default.json index 9cecdab..045e0a1 100644 --- a/app/src-tauri/capabilities/default.json +++ b/app/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:app:default", "core:resources:default", "core:menu:default", - "core:tray:default" + "core:tray:default", + "notification:default" ] } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 606a8a2..2e9cf2e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -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. diff --git a/app/src/App.tsx b/app/src/App.tsx index 8e1f0da..0887d81 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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([]); const [workspaces, setWorkspaces] = useState([]); const [activeId, setActiveId] = useState(null); const [running, setRunning] = useState>({}); + const [states, setStates] = useState>({}); + const [feed, setFeed] = useState([]); const [wizard, setWizard] = useState(false); + const feedId = useRef(0); + const activeRef = useRef(null); + const wsRef = useRef([]); + activeRef.current = activeId; + wsRef.current = workspaces; const refresh = useCallback(async () => { const st = await getStatusFull(); setGroups(st.groups); setWorkspaces(st.workspaces); const run: Record = {}; - st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; })); + const stt: Record = {}; + 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 (
- setWizard(true)} /> + setWizard(true)} />
{active && (
@@ -43,10 +83,11 @@ export function App() { )}
{active - ? + ? :
No workspace — create one to begin.
}
+ setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} /> {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
); diff --git a/app/src/EventCenter.tsx b/app/src/EventCenter.tsx new file mode 100644 index 0000000..2415252 --- /dev/null +++ b/app/src/EventCenter.tsx @@ -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 = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" }; +const COLOR: Record = { 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 ( +
+
+ Event Center + Mark all read +
+
+ {feed.length === 0 &&
No events yet.
} + {feed.map((e) => ( +
onSelect(e.surfaceId)} + style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}> + {ICON[e.kind]} +
+
{e.workspace} · {e.agent}
+
{e.kind} {e.time}
+
+
+ ))} +
+
+ ); +} diff --git a/app/src/notify.ts b/app/src/notify.ts new file mode 100644 index 0000000..bb63f7e --- /dev/null +++ b/app/src/notify.ts @@ -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 = {}; + +/// 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 { + 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}` }); +}