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": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-notification": "^2",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] }
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-notification = "2"
|
||||||
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
spacesh-proto = { path = "../../crates/spacesh-proto" }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"core:app:default",
|
"core:app:default",
|
||||||
"core:resources:default",
|
"core:resources:default",
|
||||||
"core:menu: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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_notification::init())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
// Connect the bridge on a tokio runtime, then manage it.
|
// 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 { LayoutEngine } from "./LayoutEngine";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { PresetPicker } from "./PresetPicker";
|
import { PresetPicker } from "./PresetPicker";
|
||||||
import { Wizard } from "./Wizard";
|
import { Wizard } from "./Wizard";
|
||||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
import { EventCenter, type FeedEntry } from "./EventCenter";
|
||||||
import type { Group, WorkspaceView } from "./layoutTypes";
|
import { maybeNotify } from "./notify";
|
||||||
|
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge";
|
||||||
|
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [groups, setGroups] = useState<Group[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
||||||
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 [feed, setFeed] = useState<FeedEntry[]>([]);
|
||||||
const [wizard, setWizard] = useState(false);
|
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 refresh = useCallback(async () => {
|
||||||
const st = await getStatusFull();
|
const st = await getStatusFull();
|
||||||
setGroups(st.groups);
|
setGroups(st.groups);
|
||||||
setWorkspaces(st.workspaces);
|
setWorkspaces(st.workspaces);
|
||||||
const run: Record<string, boolean> = {};
|
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);
|
setRunning(run);
|
||||||
if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id);
|
setStates(stt);
|
||||||
}, [activeId]);
|
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(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
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(); });
|
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
|
||||||
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||||
|
|
||||||
|
function selectWorkspace(id: string) {
|
||||||
|
setActiveId(id);
|
||||||
|
void setWorkspaceMeta(id, { unread: false });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
|
<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 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
{active && (
|
{active && (
|
||||||
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
|
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
|
||||||
@@ -43,10 +83,11 @@ export function App() {
|
|||||||
)}
|
)}
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
{active
|
{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 style={{ color: "#666", padding: 24 }}>No workspace — create one to begin.</div>}
|
||||||
</div>
|
</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)} />}
|
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||||
</div>
|
</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