Merge SP2: UI parity + persistent event log & read-model

- UI parity with Pencil mockup (top bar, panel cards, sidebar/event-center polish)
- Daemon-owned, disk-persisted event log (~/.spacesh/events.json) with read-flags
- Event Center sourced from daemon; real Unread/Errors tabs + bell unread badge
- 112 workspace tests green; frontend build clean

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 08:59:34 +07:00
25 changed files with 1626 additions and 140 deletions
+11 -1
View File
@@ -150,6 +150,16 @@ S shutdown
4. **Нативные уведомления:** сверни/расфокусь окно GUI, доведи панель до `done/wait/error` → придёт macOS-уведомление (первый раз спросит разрешение). Клик по записи в **Event Center** фокусит панель. 4. **Нативные уведомления:** сверни/расфокусь окно GUI, доведи панель до `done/wait/error` → придёт macOS-уведомление (первый раз спросит разрешение). Клик по записи в **Event Center** фокусит панель.
5. **Авто-unread:** событие статуса в **неактивном** воркспейсе ставит ему синюю точку «не забыть»; выбор воркспейса снимает. 5. **Авто-unread:** событие статуса в **неактивном** воркспейсе ставит ему синюю точку «не забыть»; выбор воркспейса снимает.
### SP2 — персистентный журнал событий / read-model
1. Доведи панель до `done`/`error` (или `spacesh notify --surface <s> --state error`). В Event Center появляется запись; бейдж на `bell` (в топ-баре) растёт.
2. **Перезапусти GUI** (демон жив): лента на месте — она берётся из демона, не из памяти GUI.
3. **Холодный рестарт демона** (`spacesh shutdown`, затем снова открой GUI): лента всё ещё на месте — восстановлена из `~/.spacesh/events.json`.
4. Клик по записи (или фокус её панели) помечает её прочитанной — запись тускнеет, бейдж уменьшается. `Mark all read` гасит бейдж полностью.
5. Табы фильтруют по реальным данным: `Unread` — только непрочитанные, `Errors` — только события `error`.
6. **Явное закрытие панели не логируется** (это намеренно — пользователь сам её закрыл); в журнал попадают только сами-завершившиеся/упавшие процессы и переходы статуса `done/wait/error`.
Файл журнала: `~/.spacesh/events.json` (кольцо на 1000 записей, атомарная запись + corrupt-backup, как у `state.json`).
### M4 — CLI ### M4 — CLI
- `spacesh status --json` против живого демона; `spacesh notify` без демона → молча `exit 0`; `spacesh completions zsh` печатает скрипт. - `spacesh status --json` против живого демона; `spacesh notify` без демона → молча `exit 0`; `spacesh completions zsh` печатает скрипт.
@@ -191,7 +201,7 @@ rm -rf ~/.spacesh # сбрасывает сокет, лок, state.json,
- **Playwright/headless-браузер** видит только Vite-фронт (`npm run dev`, :1420) — Tauri-IPC там недоступен, живой daemon-флоу не тестируется. Полный e2e — только `npm run tauri dev` на дисплее. - **Playwright/headless-браузер** видит только Vite-фронт (`npm run dev`, :1420) — Tauri-IPC там недоступен, живой daemon-флоу не тестируется. Полный e2e — только `npm run tauri dev` на дисплее.
- **OSC 133 — только zsh** (через `ZDOTDIR`); bash/fish работают на fallback-эвристике. - **OSC 133 — только zsh** (через `ZDOTDIR`); bash/fish работают на fallback-эвристике.
- **Клик по нативному уведомлению** не фокусит конкретную панель (клик по записи в Event Center — фокусит). - **Клик по нативному уведомлению** не фокусит конкретную панель (клик по записи в Event Center — фокусит).
- **Event Center** — плоская лента (вкладки All/Unread/Errors пока без фильтра); живёт в памяти GUI, очищается при перезапуске GUI. - **Event Center** — лента хранится в демоне и персистируется в `~/.spacesh/events.json` (переживает перезапуск GUI и холодный рестарт демона). Вкладки `Unread`/`Errors` и бейдж `bell` работают по реальным данным (флаги прочтения на уровне события). По-прежнему не реализованы: каналы Telegram/MAX в футере Event Center (SP5), а также `search`/`settings` и меню аккаунта в топ-баре.
- **Статус эфемерен** (work/wait/done/error/idle) — не персистится; после холодного рестарта демона панель `stopped`, статус `idle`. - **Статус эфемерен** (work/wait/done/error/idle) — не персистится; после холодного рестарта демона панель `stopped`, статус `idle`.
- Авторизация / личный кабинет / внешние нотификации (Telegram/MAX) / зум / поиск по скроллбэку / diff-вьюер / remote — **не реализованы** (M5/M6/auth, см. `DOCS/MAIN.md`). - Авторизация / личный кабинет / внешние нотификации (Telegram/MAX) / зум / поиск по скроллбэку / diff-вьюер / remote — **не реализованы** (M5/M6/auth, см. `DOCS/MAIN.md`).
+30
View File
@@ -8,10 +8,13 @@
"name": "spacesh-app", "name": "spacesh-app",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"lucide-react": "^1.17.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@@ -697,6 +700,24 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@fontsource-variable/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1691,6 +1712,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
"integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+4 -1
View File
@@ -9,10 +9,13 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource/inter": "^5.2.8",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-notification": "^2", "@tauri-apps/plugin-notification": "^2",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"lucide-react": "^1.17.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
+13
View File
@@ -298,3 +298,16 @@ pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Va
pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> { pub async fn focus(state: BridgeState<'_>, surface_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) data_of(state.request(Cmd::Focus { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?)
} }
// ---- M3 event log commands ----
#[tauri::command]
pub async fn event_log(state: BridgeState<'_>, limit: Option<u32>) -> Result<Value, String> {
data_of(state.request(Cmd::EventLog { limit }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result<Value, String> {
let target: spacesh_proto::MarkReadTarget = serde_json::from_value(target).map_err(|e| format!("invalid mark_read target: {e}"))?;
data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?)
}
+2
View File
@@ -47,6 +47,8 @@ pub fn run() {
bridge::set_group, bridge::set_group,
bridge::delete_group, bridge::delete_group,
bridge::focus, bridge::focus,
bridge::event_log,
bridge::mark_read,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running spacesh"); .expect("error while running spacesh");
+58 -34
View File
@@ -1,11 +1,15 @@
import { useEffect, useState, useCallback, useRef } from "react"; import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { LayoutEngine } from "./LayoutEngine"; import { LayoutEngine } from "./LayoutEngine";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import { PresetPicker } from "./PresetPicker"; import { TopBar } from "./TopBar";
import { CenterToolbar } from "./CenterToolbar";
import { Wizard } from "./Wizard"; import { Wizard } from "./Wizard";
import { EventCenter, type FeedEntry } from "./EventCenter"; import { EventCenter } from "./EventCenter";
import { maybeNotify } from "./notify"; import { maybeNotify } from "./notify";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge"; import { COLORS } from "./theme";
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface, getEventLog, markEventsRead } from "./socketBridge";
import type { EventRecord } from "./socketBridge";
import { leafIds } from "./layoutTypes";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
export function App() { export function App() {
@@ -14,14 +18,25 @@ export function App() {
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 [states, setStates] = useState<Record<string, SurfaceState>>({});
const [feed, setFeed] = useState<FeedEntry[]>([]); const [events, setEvents] = useState<EventRecord[]>([]);
const [wizard, setWizard] = useState(false); const [wizard, setWizard] = useState(false);
const feedId = useRef(0); const [eventsOpen, setEventsOpen] = useState(true);
const [focusedId, setFocusedId] = useState<string | null>(null);
const activeRef = useRef<string | null>(null); const activeRef = useRef<string | null>(null);
const wsRef = useRef<WorkspaceView[]>([]); const wsRef = useRef<WorkspaceView[]>([]);
activeRef.current = activeId; activeRef.current = activeId;
wsRef.current = workspaces; wsRef.current = workspaces;
const seedEvents = useCallback(async () => {
const log = await getEventLog();
setEvents((existing) => {
const byId = new Map<number, EventRecord>();
for (const e of log.events) byId.set(e.id, e); // daemon is authoritative for overlapping ids
for (const e of existing) if (!byId.has(e.id)) byId.set(e.id, e); // keep live events not in the snapshot
return [...byId.values()].sort((a, b) => b.id - a.id).slice(0, 1000);
});
}, []);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
const st = await getStatusFull(); const st = await getStatusFull();
setGroups(st.groups); setGroups(st.groups);
@@ -39,55 +54,64 @@ export function App() {
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
void seedEvents();
const unlisten = onDaemonEvent((evt) => { const unlisten = onDaemonEvent((evt) => {
if (evt.evt === "state") { if (evt.evt === "event") {
const { surface_id, state } = evt.data; const rec = evt.data.record;
setStates((m) => ({ ...m, [surface_id]: state })); setEvents((es) => [rec, ...es].slice(0, 1000));
const w = wsOf(surface_id); const w = wsOf(rec.surface_id);
const agent = w?.surfaces[surface_id]?.spec.agent_label ?? "shell"; if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true });
if (["done", "wait", "error"].includes(state)) { void maybeNotify(rec.surface_id, rec.agent_label ?? "shell", rec.workspace_name, rec.kind);
const entry: FeedEntry = { id: feedId.current++, surfaceId: surface_id, workspace: w?.name ?? "?", agent, kind: state, time: "now" }; } else if (evt.evt === "events_read") {
setFeed((f) => [entry, ...f].slice(0, 200)); const ids = new Set(evt.data.ids);
if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true }); setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e)));
void maybeNotify(surface_id, agent, w?.name ?? "?", state); } else if (evt.evt === "state") {
} setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state }));
void refresh(); void refresh();
} else if (evt.evt === "exit") { } 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(); void refresh();
} else { } else {
void refresh(); void refresh();
} }
}); });
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); }); const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); void seedEvents(); });
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
}, [refresh]); }, [refresh, seedEvents]);
const unread = useMemo(() => events.filter((e) => !e.read).length, [events]);
const active = workspaces.find((w) => w.id === activeId) ?? null; const active = workspaces.find((w) => w.id === activeId) ?? null;
const leaves = active ? leafIds(active.layout) : [];
const effectiveFocus = focusedId && leaves.includes(focusedId) ? focusedId : leaves[0] ?? null;
function selectWorkspace(id: string) { function selectWorkspace(id: string) {
setActiveId(id); setActiveId(id);
setFocusedId(null);
void setWorkspaceMeta(id, { unread: false }); void setWorkspaceMeta(id, { unread: false });
} }
return ( return (
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}> <div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} /> <TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}> <div style={{ flex: 1, display: "flex", minHeight: 0 }}>
{active && ( <Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}> <div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
<PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} /> {active && (
<CenterToolbar selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
)}
<div style={{ flex: 1, minHeight: 0 }}>
{active
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} states={states} surfaces={active.surfaces} focusedId={effectiveFocus} onFocus={setFocusedId} />
: <div style={{ color: COLORS.textMuted, padding: 24 }}>No workspace create one to begin.</div>}
</div> </div>
)}
<div style={{ flex: 1, minHeight: 0 }}>
{active
? <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>
{eventsOpen && (
<EventCenter
events={events}
onMarkAllRead={() => { void markEventsRead({ target: "all" }); }}
onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }}
/>
)}
</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>
); );
+23
View File
@@ -0,0 +1,23 @@
import { Search } from "lucide-react";
import { COLORS, FONT } from "./theme";
import { PresetPicker } from "./PresetPicker";
/** Top-of-grid toolbar: layout presets on the left, scrollback search on the right (search is a mock). */
export function CenterToolbar({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "0 12px", height: 46, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<PresetPicker selected={selected} onSelect={onSelect} />
<div style={{ flex: 1 }} />
<div
title="Search scrollback (mock)"
style={{
display: "flex", alignItems: "center", gap: 6, height: 24, padding: "0 8px", borderRadius: 6,
background: COLORS.bgPanel, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer",
}}>
<Search size={12} color={COLORS.textMuted} />
<span style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.textMuted }}>Search scrollback</span>
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>F</span>
</div>
</div>
);
}
+83 -23
View File
@@ -1,37 +1,97 @@
import type { SurfaceState } from "./layoutTypes"; import { useState } from "react";
import { Check, Hourglass, X, Power, Send, MessageSquare } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { EventRecord } from "./socketBridge";
export interface FeedEntry { const ICON: Record<string, React.ReactNode> = {
id: number; done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />, exit: <Power size={13} />,
surfaceId: string; };
workspace: string; const COLOR: Record<string, string> = {
agent: string; done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, exit: COLORS.textMuted,
kind: SurfaceState | "exit"; };
time: string;
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`;
} }
const ICON: Record<string, string> = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" }; export function EventCenter({
const COLOR: Record<string, string> = { done: "#3FB950", wait: "#F2B84B", error: "#F4544E", work: "#4C8DFF", idle: "#5A6573", exit: "#5A6573" }; 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;
export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[]; onMarkRead: () => void; onSelect: (surfaceId: string) => void }) {
return ( 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", 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 }}> <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: "Inter", fontSize: 13, fontWeight: 700, color: "#E6EDF3", flex: 1 }}>Event Center</span> <span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkRead} style={{ fontSize: 11, color: "#4C8DFF", cursor: "pointer" }}>Mark all read</span> <span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
</div> </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>} <div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
{feed.map((e) => ( {TABS.map((t) => {
<div key={e.id} onClick={() => onSelect(e.surfaceId)} const on = t.id === tab;
style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}> return (
<span style={{ color: COLOR[e.kind] }}>{ICON[e.kind]}</span> <button key={t.id} onClick={() => setTab(t.id)}
<div style={{ flex: 1 }}> style={{
<div style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{e.workspace} · {e.agent}</div> height: 22, padding: "0 9px", borderRadius: 11, fontFamily: FONT.ui, fontSize: 11, fontWeight: on ? 600 : 400,
<div style={{ fontFamily: "Inter", fontSize: 12, color: "#E6EDF3" }}>{e.kind} <span style={{ color: "#5A6573" }}>{e.time}</span></div> 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> </div>
{!e.read && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, alignSelf: "center", flex: "0 0 7px" }} />}
</div> </div>
))} ))}
</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> </div>
); );
} }
+66 -20
View File
@@ -1,7 +1,9 @@
import { useRef } from "react"; import { useRef } from "react";
import { Maximize2, RotateCw } from "lucide-react";
import { TerminalView } from "./TerminalView"; import { TerminalView } from "./TerminalView";
import { StatusRing } from "./StatusRing"; import { StatusRing } from "./StatusRing";
import type { LayoutNode, SurfaceState } from "./layoutTypes"; import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { LayoutNode, SurfaceState, SurfaceView } from "./layoutTypes";
import { setRatios, restartSurface } from "./socketBridge"; import { setRatios, restartSurface } from "./socketBridge";
interface Props { interface Props {
@@ -10,36 +12,81 @@ interface Props {
/** surface_id -> running flag, from the latest status/events. */ /** surface_id -> running flag, from the latest status/events. */
running: Record<string, boolean>; running: Record<string, boolean>;
states: Record<string, SurfaceState>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>;
focusedId: string | null;
onFocus: (id: string) => void;
} }
export function LayoutEngine({ workspaceId, layout, running, states }: Props) { /** Collapse an absolute cwd into a ~/<leaf> style label for the panel header. */
function shortPath(cwd: string): string {
const leaf = cwd.split("/").filter(Boolean).pop();
return leaf ? `~/${leaf}` : cwd;
}
export function LayoutEngine({ workspaceId, layout, running, states, surfaces, focusedId, onFocus }: Props) {
if (!layout) { if (!layout) {
return <div style={{ color: "#666", padding: 24 }}>Empty workspace apply a preset to add panels.</div>; return <div style={{ color: COLORS.textMuted, padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
} }
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} />; return (
<div style={{ width: "100%", height: "100%", padding: 12, boxSizing: "border-box" }}>
<Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} />
</div>
);
} }
function Node({ workspaceId, node, path, running, states }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean>; states: Record<string, SurfaceState> }) { function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus }: {
workspaceId: string; node: LayoutNode; path: number[];
running: Record<string, boolean>; states: Record<string, SurfaceState>;
surfaces: Record<string, SurfaceView>; focusedId: string | null; onFocus: (id: string) => void;
}) {
if ("leaf" in node) { if ("leaf" in node) {
const id = node.leaf.surface_id; const id = node.leaf.surface_id;
const focused = focusedId === id;
const card = (inner: React.ReactNode) => (
<div
onMouseDown={() => onFocus(id)}
style={{
display: "flex", flexDirection: "column", width: "100%", height: "100%",
background: COLORS.bgPanel, borderRadius: 8, overflow: "hidden",
border: focused ? `2px solid ${COLORS.accent}` : `1px solid ${COLORS.borderSubtle}`,
boxSizing: "border-box",
}}
>
{inner}
</div>
);
if (running[id] === false) { if (running[id] === false) {
return ( return card(
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", background: "#0A0D12", color: "#8B97A6", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
<div style={{ fontFamily: "monospace", fontSize: 13 }}>Process exited</div> <div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
<button onClick={() => void restartSurface(id)} style={{ padding: "6px 14px" }}> Restart</button> <button onClick={() => void restartSurface(id)}
style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 14px", background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7, fontSize: 12 }}>
<RotateCw size={13} /> Restart
</button>
</div> </div>
); );
} }
return (
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%" }}> const spec = surfaces[id]?.spec;
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "3px 8px", background: "#0A0D12", borderBottom: "1px solid #232A33" }}> const agent = spec?.agent_label ?? "shell";
<StatusRing state={states[id] ?? "idle"} running={true} /> const state = states[id] ?? "idle";
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{id}</span> return card(
<>
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, flex: "0 0 30px", padding: "0 10px", background: COLORS.bgElevated, borderBottom: `1px solid ${COLORS.borderSubtle}` }}>
<StatusRing state={state} running={true} />
<span style={{ fontFamily: FONT.mono, fontSize: 12, fontWeight: 600, color: COLORS.textPrimary }}>{agent}</span>
{spec?.cwd && <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{shortPath(spec.cwd)}</span>}
<span style={{ flex: 1 }} />
<span style={{ display: "flex", alignItems: "center", height: 16, padding: "0 7px", borderRadius: 8, background: "#000", fontFamily: FONT.mono, fontSize: 10, fontWeight: 600, color: STATE_COLOR[state] }}>
{state}
</span>
<Maximize2 size={13} color={COLORS.textMuted} style={{ cursor: "pointer" }} aria-label="Zoom (mock)" />
</div> </div>
<div style={{ flex: 1, minHeight: 0 }}> <div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} /> <TerminalView key={id} surfaceId={id} />
</div> </div>
</div> </>
); );
} }
@@ -55,7 +102,7 @@ function Node({ workspaceId, node, path, running, states }: { workspaceId: strin
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac); next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
void setRatios(workspaceId, path, next); void setRatios(workspaceId, path, next);
}}> }}>
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} /> <Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} surfaces={surfaces} focusedId={focusedId} onFocus={onFocus} />
</Pane> </Pane>
))} ))}
</div> </div>
@@ -69,8 +116,7 @@ function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLa
const parent = ref.current?.parentElement; const parent = ref.current?.parentElement;
if (!parent) return; if (!parent) return;
const total = orient === "h" ? parent.clientWidth : parent.clientHeight; const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
const start = orient === "h" ? e.clientX : e.clientY; let last = orient === "h" ? e.clientX : e.clientY;
let last = start;
const move = (ev: MouseEvent) => { const move = (ev: MouseEvent) => {
const cur = orient === "h" ? ev.clientX : ev.clientY; const cur = orient === "h" ? ev.clientX : ev.clientY;
const delta = (cur - last) / total; const delta = (cur - last) / total;
@@ -92,9 +138,9 @@ function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLa
{!isLast && ( {!isLast && (
<div onMouseDown={startDrag} <div onMouseDown={startDrag}
style={{ style={{
flex: "0 0 4px", flex: "0 0 10px",
cursor: orient === "h" ? "col-resize" : "row-resize", cursor: orient === "h" ? "col-resize" : "row-resize",
background: "#232A33", background: "transparent",
}} /> }} />
)} )}
</> </>
+17 -12
View File
@@ -11,20 +11,25 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
{ id: "2x4", label: "2×4", slots: 8 }, { id: "2x4", label: "2×4", slots: 8 },
]; ];
import { COLORS, FONT } from "./theme";
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) { export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
return ( return (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
{PRESETS.map((p) => ( {PRESETS.map((p) => {
<button key={p.id} onClick={() => onSelect(p.id)} const on = p.id === selected;
style={{ return (
padding: "6px 10px", borderRadius: 6, fontFamily: "monospace", fontSize: 12, <button key={p.id} onClick={() => onSelect(p.id)}
background: p.id === selected ? "#1A2029" : "transparent", style={{
border: p.id === selected ? "1px solid #4C8DFF" : "1px solid #232A33", display: "flex", alignItems: "center", height: 24, padding: "0 8px", borderRadius: 6, fontFamily: FONT.mono, fontSize: 12,
color: p.id === selected ? "#E6EDF3" : "#8B97A6", cursor: "pointer", background: on ? COLORS.bgElevated : "transparent",
}}> border: `1px solid ${on ? COLORS.borderStrong : "transparent"}`,
{p.label} color: on ? COLORS.textPrimary : COLORS.textSecondary,
</button> }}>
))} {p.label}
</button>
);
})}
</div> </div>
); );
} }
+59 -29
View File
@@ -1,9 +1,8 @@
import { useState } from "react";
import { Plus, ChevronDown, ChevronRight } from "lucide-react";
import { COLORS, FONT, STATE_COLOR } from "./theme";
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes"; import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
const RING: Record<SurfaceState | "stopped", string> = {
error: "#F4544E", wait: "#F2B84B", work: "#4C8DFF", done: "#3FB950", idle: "#5A6573", stopped: "#5A6573",
};
function aggregate(w: WorkspaceView): SurfaceState | "stopped" { function aggregate(w: WorkspaceView): SurfaceState | "stopped" {
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"]; const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
const running = Object.values(w.surfaces).filter((s) => s.running); const running = Object.values(w.surfaces).filter((s) => s.running);
@@ -23,36 +22,67 @@ export function Sidebar({
onSelect: (id: string) => void; onSelect: (id: string) => void;
onNew: () => void; onNew: () => void;
}) { }) {
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order);
const ungrouped = byGroup(null); const ungrouped = byGroup(null);
const row = (w: WorkspaceView) => ( const row = (w: WorkspaceView) => {
<div key={w.id} onClick={() => onSelect(w.id)} const isActive = w.id === activeId;
style={{ return (
display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer", <div key={w.id} onClick={() => onSelect(w.id)}
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13, style={{
color: w.id === activeId ? "#E6EDF3" : "#8B97A6", display: "flex", alignItems: "center", gap: 10, height: 34, padding: "0 8px", borderRadius: 6, cursor: "pointer",
}}> background: isActive ? COLORS.bgElevated : "transparent", fontFamily: FONT.ui, fontSize: 13,
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${RING[aggregate(w)]}`, boxSizing: "border-box" }} /> color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
<span style={{ flex: 1 }}>{w.name}</span> }}>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />} <span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span> <span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
</div> {w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, flex: "0 0 7px" }} />}
); <span style={{ display: "flex", alignItems: "center", justifyContent: "center", height: 18, minWidth: 18, padding: "0 6px", borderRadius: 9, background: COLORS.bgApp, fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>
{Object.keys(w.surfaces).length}
</span>
</div>
);
};
return ( return (
<div style={{ width: 248, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", overflowY: "auto" }}> <div style={{ display: "flex", flexDirection: "column", width: 248, flex: "0 0 248px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box" }}>
<button onClick={onNew} style={{ width: "100%", padding: 8, marginBottom: 16, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 7 }}>+ New workspace</button> <button onClick={onNew}
{groups.sort((a, b) => a.order - b.order).map((g) => ( style={{
<div key={g.id} style={{ marginBottom: 12 }}> display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "0 4px", marginBottom: 4 }}> background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7,
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} /> fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
<span style={{ fontFamily: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span> }}>
</div> <Plus size={15} />
{byGroup(g.id).map(row)} New workspace
</div> <span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>N</span>
))} </button>
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2, minHeight: 0 }}>
{groups.sort((a, b) => a.order - b.order).map((g) => {
const open = !collapsed[g.id];
return (
<div key={g.id} style={{ marginBottom: 8 }}>
<div onClick={() => setCollapsed((c) => ({ ...c, [g.id]: open }))}
style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}>
{open ? <ChevronDown size={13} color={COLORS.textMuted} /> : <ChevronRight size={13} color={COLORS.textMuted} />}
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>{g.name.toUpperCase()}</span>
</div>
{open && byGroup(g.id).map(row)}
</div>
);
})}
{ungrouped.length > 0 && <div style={{ marginTop: 4, display: "flex", flexDirection: "column", gap: 2 }}>{ungrouped.map(row)}</div>}
</div>
{/* Daemon status footer — uptime is mocked until the daemon reports it. */}
<div style={{ display: "flex", alignItems: "center", gap: 8, height: 30, marginTop: 10, padding: "0 6px", borderRadius: 6, background: COLORS.bgPanel }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.stDone, flex: "0 0 7px" }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary }}>spaceshd · live</span>
<span style={{ flex: 1 }} />
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>3d 4h</span>
</div>
</div> </div>
); );
} }
+1 -1
View File
@@ -11,7 +11,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
useEffect(() => { useEffect(() => {
if (!ref.current) return; if (!ref.current) return;
const term = new Terminal({ fontFamily: "monospace", fontSize: 13, convertEol: false }); const term = new Terminal({ fontFamily: "'JetBrains Mono Variable', 'JetBrains Mono', monospace", fontSize: 13, convertEol: false });
try { try {
term.loadAddon(new WebglAddon()); term.loadAddon(new WebglAddon());
} catch { } catch {
+102
View File
@@ -0,0 +1,102 @@
import { FolderGit2, PanelRight, Search, Bell, Settings, ChevronDown } from "lucide-react";
import { COLORS, FONT } from "./theme";
import type { WorkspaceView } from "./layoutTypes";
import { leafIds } from "./layoutTypes";
/** Human-readable descriptor of the active workspace layout (mock until a real preset id is tracked). */
function describeLayout(w: WorkspaceView | null): string {
if (!w || !w.layout) return "no layout";
const n = leafIds(w.layout).length;
return n === 1 ? "1 pane" : `${n} panes`;
}
function IconBtn({ icon, onClick, active, title }: { icon: React.ReactNode; onClick?: () => void; active?: boolean; title?: string }) {
return (
<button
title={title}
onClick={onClick}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 26, height: 26, borderRadius: 6,
background: active ? COLORS.bgElevated : "transparent",
border: active ? `1px solid ${COLORS.borderSubtle}` : "1px solid transparent",
color: active ? COLORS.textPrimary : COLORS.textSecondary,
}}
>
{icon}
</button>
);
}
export function TopBar({
active, eventsOpen, onToggleEvents, unread,
}: {
active: WorkspaceView | null;
eventsOpen: boolean;
onToggleEvents: () => void;
unread: number;
}) {
return (
<div
style={{
display: "flex", alignItems: "center", height: 40, flex: "0 0 40px",
padding: "0 14px", gap: 12, background: COLORS.bgApp,
borderBottom: `1px solid ${COLORS.borderSubtle}`,
}}
>
{/* macOS traffic-light spacer — real lights are drawn by the window chrome. */}
<div style={{ width: 60, flex: "0 0 60px" }} />
{/* Workspace breadcrumb */}
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<FolderGit2 size={15} color={COLORS.textSecondary} />
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 600, color: COLORS.textPrimary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{active?.name ?? "spacesh"}
</span>
{active && (
<>
<span style={{ fontFamily: FONT.ui, fontSize: 13, color: COLORS.textMuted }}>/</span>
<span style={{ fontFamily: FONT.ui, fontSize: 13, color: COLORS.textSecondary, whiteSpace: "nowrap" }}>
{describeLayout(active)}
</span>
</>
)}
</div>
<div style={{ flex: 1 }} />
{/* Right cluster */}
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<IconBtn icon={<PanelRight size={15} />} onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" />
<IconBtn icon={<Search size={16} />} title="Search (mock)" />
<div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" />
{unread > 0 && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
borderRadius: 7, background: COLORS.stError, color: "#fff",
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box",
}}>
{unread > 99 ? "99+" : unread}
</span>
)}
</div>
<IconBtn icon={<Settings size={16} />} title="Settings (mock)" />
<span style={{ width: 1, height: 18, background: COLORS.borderStrong, margin: "0 2px" }} />
<button
title="Account (mock)"
style={{
display: "flex", alignItems: "center", gap: 6, height: 26, padding: "0 4px 0 4px",
background: "transparent", border: "1px solid transparent", borderRadius: 6, color: COLORS.textSecondary,
}}
>
<span style={{ width: 20, height: 20, borderRadius: "50%", background: COLORS.accent, display: "flex", alignItems: "center", justifyContent: "center", fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, color: "#fff" }}>
V
</span>
<ChevronDown size={13} />
</button>
</div>
</div>
);
}
+6
View File
@@ -1,7 +1,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "@fontsource-variable/jetbrains-mono";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
+3 -4
View File
@@ -1,12 +1,11 @@
import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification"; import { isPermissionGranted, requestPermission, sendNotification } from "@tauri-apps/plugin-notification";
import { getCurrentWindow } from "@tauri-apps/api/window"; import { getCurrentWindow } from "@tauri-apps/api/window";
import type { SurfaceState } from "./layoutTypes";
const NOTIFY_STATES: SurfaceState[] = ["done", "wait", "error"]; const NOTIFY_STATES = ["done", "wait", "error"];
let lastBySurface: Record<string, SurfaceState> = {}; let lastBySurface: Record<string, string> = {};
/// Fire a native notification for a status change when the window is unfocused. /// 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> { export async function maybeNotify(surfaceId: string, agent: string, workspace: string, state: string): Promise<void> {
if (!NOTIFY_STATES.includes(state)) return; if (!NOTIFY_STATES.includes(state)) return;
if (lastBySurface[surfaceId] === state) return; // dedup repeats if (lastBySurface[surfaceId] === state) return; // dedup repeats
lastBySurface[surfaceId] = state; lastBySurface[surfaceId] = state;
+27 -1
View File
@@ -62,6 +62,30 @@ export async function getStatus(): Promise<WorkspaceStatus[]> {
return data.workspaces; return data.workspaces;
} }
export interface EventRecord {
id: number;
surface_id: string;
workspace_id: string;
workspace_name: string;
agent_label: string | null;
kind: "done" | "wait" | "error" | "exit";
ts: number;
read: boolean;
}
export type MarkReadTarget =
| { target: "all" }
| { target: "ids"; value: number[] }
| { target: "surface"; value: string };
export async function getEventLog(limit?: number): Promise<{ events: EventRecord[]; unread: number }> {
return await invoke<{ events: EventRecord[]; unread: number }>("event_log", { limit: limit ?? null });
}
export async function markEventsRead(target: MarkReadTarget): Promise<void> {
await invoke("mark_read", { target });
}
export type DaemonEvt = export type DaemonEvt =
| { evt: "exit"; data: { surface_id: string; code: number } } | { evt: "exit"; data: { surface_id: string; code: number } }
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } } | { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
@@ -69,7 +93,9 @@ export type DaemonEvt =
| { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } } | { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } }
| { evt: "layout_changed"; data: { workspace_id: string } } | { evt: "layout_changed"; data: { workspace_id: string } }
| { evt: "workspace_changed"; data: unknown } | { evt: "workspace_changed"; data: unknown }
| { evt: "groups_changed"; data: unknown }; | { evt: "groups_changed"; data: unknown }
| { evt: "event"; data: { record: EventRecord } }
| { evt: "events_read"; data: { ids: number[] } };
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> { export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload)); return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
+39
View File
@@ -0,0 +1,39 @@
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
height: 100%;
}
body {
font-family: "Inter", system-ui, sans-serif;
background: #0e1116;
color: #e6edf3;
-webkit-font-smoothing: antialiased;
}
button {
font-family: inherit;
cursor: pointer;
}
/* Thin, unobtrusive scrollbars to match the dark chrome. */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: #232a33;
border-radius: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
+36
View File
@@ -0,0 +1,36 @@
import type { SurfaceState } from "./layoutTypes";
/** Design tokens — mirror of DOCS/space-sh.pen variables. Single source for the UI. */
export const COLORS = {
accent: "#4C8DFF",
bgApp: "#0E1116",
bgElevated: "#1A2029",
bgHover: "#222A35",
bgPanel: "#0A0D12",
bgSidebar: "#13171F",
borderStrong: "#323C49",
borderSubtle: "#232A33",
textPrimary: "#E6EDF3",
textSecondary: "#8B97A6",
textMuted: "#5A6573",
stWork: "#4C8DFF",
stWait: "#F2B84B",
stDone: "#3FB950",
stError: "#F4544E",
stIdle: "#5A6573",
} as const;
export const FONT = {
ui: "Inter, system-ui, sans-serif",
mono: "'JetBrains Mono Variable', 'JetBrains Mono', monospace",
} as const;
/** Status color by surface state, plus the stopped pseudo-state. */
export const STATE_COLOR: Record<SurfaceState | "stopped", string> = {
work: COLORS.stWork,
wait: COLORS.stWait,
done: COLORS.stDone,
error: COLORS.stError,
idle: COLORS.stIdle,
stopped: COLORS.stIdle,
};
+76
View File
@@ -0,0 +1,76 @@
use serde::{Deserialize, Serialize};
use crate::ids::{SurfaceId, WorkspaceId};
/// The subset of activity that lands in the event log.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind {
Done,
Wait,
Error,
Exit,
}
/// One logged event. Workspace name and agent label are denormalized so the
/// feed stays displayable after the surface or workspace is closed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventRecord {
pub id: u64,
pub surface_id: SurfaceId,
pub workspace_id: WorkspaceId,
pub workspace_name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_label: Option<String>,
pub kind: EventKind,
pub ts: u64,
pub read: bool,
}
/// What a `mark_read` request targets.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "target", content = "value", rename_all = "snake_case")]
pub enum MarkReadTarget {
All,
Ids(Vec<u64>),
Surface(SurfaceId),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_kind_serializes_lowercase() {
assert_eq!(serde_json::to_string(&EventKind::Done).unwrap(), r#""done""#);
assert_eq!(serde_json::to_string(&EventKind::Exit).unwrap(), r#""exit""#);
}
#[test]
fn event_record_round_trips() {
let r = EventRecord {
id: 7,
surface_id: SurfaceId("s_1".into()),
workspace_id: WorkspaceId("w_1".into()),
workspace_name: "infra".into(),
agent_label: Some("claude".into()),
kind: EventKind::Error,
ts: 1_700_000_000_000,
read: false,
};
let back: EventRecord = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap();
assert_eq!(back, r);
}
#[test]
fn mark_read_target_variants_serialize() {
assert_eq!(serde_json::to_string(&MarkReadTarget::All).unwrap(), r#"{"target":"all"}"#);
assert_eq!(
serde_json::to_string(&MarkReadTarget::Ids(vec![1, 2])).unwrap(),
r#"{"target":"ids","value":[1,2]}"#
);
let s = MarkReadTarget::Surface(SurfaceId("s_9".into()));
assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"target":"surface","value":"s_9"}"#);
let back: MarkReadTarget = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
assert_eq!(back, s);
}
}
+2
View File
@@ -1,10 +1,12 @@
pub mod codec; pub mod codec;
pub mod event;
pub mod ids; pub mod ids;
pub mod layout; pub mod layout;
pub mod message; pub mod message;
pub mod status; pub mod status;
pub mod workspace; pub mod workspace;
pub use event::{EventKind, EventRecord, MarkReadTarget};
pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use ids::{GroupId, SurfaceId, WorkspaceId};
pub use layout::{LayoutNode, Orient}; pub use layout::{LayoutNode, Orient};
pub use message::{Cmd, Envelope, ErrorBody, Evt}; pub use message::{Cmd, Envelope, ErrorBody, Evt};
+81
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::event::{EventRecord, MarkReadTarget};
use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::ids::{GroupId, SurfaceId, WorkspaceId};
use crate::layout::LayoutNode; use crate::layout::LayoutNode;
use crate::status::SurfaceState; use crate::status::SurfaceState;
@@ -116,6 +117,11 @@ pub enum Cmd {
}, },
DeleteGroup { group_id: GroupId }, DeleteGroup { group_id: GroupId },
SetState { surface_id: SurfaceId, state: SurfaceState }, SetState { surface_id: SurfaceId, state: SurfaceState },
EventLog {
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
MarkRead { target: MarkReadTarget },
Status, Status,
Shutdown, Shutdown,
} }
@@ -134,6 +140,8 @@ pub enum Evt {
GroupsChanged { groups: Vec<Group> }, GroupsChanged { groups: Vec<Group> },
SurfaceRestarted { surface_id: SurfaceId }, SurfaceRestarted { surface_id: SurfaceId },
State { surface_id: SurfaceId, state: SurfaceState }, State { surface_id: SurfaceId, state: SurfaceState },
Event { record: EventRecord },
EventsRead { ids: Vec<u64> },
} }
#[cfg(test)] #[cfg(test)]
@@ -262,4 +270,77 @@ mod tests {
let back: Envelope = serde_json::from_str(&j).unwrap(); let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt); assert_eq!(back, evt);
} }
#[test]
fn event_log_cmd_round_trips() {
let env = Envelope::Req { id: 1, cmd: Cmd::EventLog { limit: Some(50) } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_round_trips() {
let env = Envelope::Req {
id: 2,
cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::All },
};
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"mark_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn event_evt_round_trips() {
let evt = Envelope::Evt(Evt::Event {
record: crate::event::EventRecord {
id: 3,
surface_id: SurfaceId("s_1".into()),
workspace_id: WorkspaceId("w_1".into()),
workspace_name: "p".into(),
agent_label: None,
kind: crate::event::EventKind::Done,
ts: 1,
read: false,
},
});
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"event""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn events_read_evt_round_trips() {
let evt = Envelope::Evt(Evt::EventsRead { ids: vec![1, 2, 3] });
let j = serde_json::to_string(&evt).unwrap();
assert!(j.contains(r#""evt":"events_read""#));
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, evt);
}
#[test]
fn event_log_cmd_no_limit_round_trips() {
let env = Envelope::Req { id: 9, cmd: Cmd::EventLog { limit: None } };
let j = serde_json::to_string(&env).unwrap();
assert!(j.contains(r#""cmd":"event_log""#));
assert!(j.contains(r#""args":{}"#), "no-limit serializes to empty args, got: {j}");
let back: Envelope = serde_json::from_str(&j).unwrap();
assert_eq!(back, env);
}
#[test]
fn mark_read_cmd_ids_and_surface_round_trip() {
let ids = Envelope::Req { id: 10, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Ids(vec![1, 2]) } };
let j = serde_json::to_string(&ids).unwrap();
assert!(j.contains(r#""target":"ids""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), ids);
let surf = Envelope::Req { id: 11, cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::Surface(SurfaceId("s_3".into())) } };
let j = serde_json::to_string(&surf).unwrap();
assert!(j.contains(r#""target":"surface""#));
assert_eq!(serde_json::from_str::<Envelope>(&j).unwrap(), surf);
}
} }
+212
View File
@@ -0,0 +1,212 @@
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
use spacesh_proto::event::{EventKind, EventRecord, MarkReadTarget};
use spacesh_proto::ids::{SurfaceId, WorkspaceId};
const SNAPSHOT_VERSION: u32 = 1;
/// Serializable form of the log, used for persistence.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct EventLogState {
pub version: u32,
pub next_id: u64,
#[serde(default)]
pub records: Vec<EventRecord>,
}
/// In-memory event log: a capped ring with monotonic ids.
pub struct EventLog {
records: VecDeque<EventRecord>,
next_id: u64,
cap: usize,
}
impl EventLog {
#[cfg(test)]
pub fn new(cap: usize) -> Self {
Self { records: VecDeque::new(), next_id: 1, cap }
}
/// Rebuild from a persisted snapshot, clamping to `cap` (keeping newest).
pub fn restore(state: EventLogState, cap: usize) -> Self {
let mut records: VecDeque<EventRecord> = state.records.into_iter().collect();
while records.len() > cap {
records.pop_front();
}
let max_record_id = records.iter().map(|r| r.id).max().unwrap_or(0);
let next_id = state.next_id.max(max_record_id + 1).max(1);
Self { records, next_id, cap }
}
/// Append a new event. Evicts the oldest when over capacity. Returns the
/// stored record (with its assigned id) for broadcasting.
#[allow(clippy::too_many_arguments)]
pub fn record(
&mut self,
surface_id: SurfaceId,
workspace_id: WorkspaceId,
workspace_name: String,
agent_label: Option<String>,
kind: EventKind,
ts: u64,
) -> EventRecord {
let rec = EventRecord {
id: self.next_id,
surface_id,
workspace_id,
workspace_name,
agent_label,
kind,
ts,
read: false,
};
self.next_id += 1;
self.records.push_back(rec.clone());
while self.records.len() > self.cap {
self.records.pop_front();
}
rec
}
/// Flip matching records to read. Returns the ids that actually changed.
pub fn mark_read(&mut self, target: &MarkReadTarget) -> Vec<u64> {
let mut changed = Vec::new();
for r in self.records.iter_mut() {
if r.read {
continue;
}
let hit = match target {
MarkReadTarget::All => true,
MarkReadTarget::Ids(ids) => ids.contains(&r.id),
MarkReadTarget::Surface(sid) => &r.surface_id == sid,
};
if hit {
r.read = true;
changed.push(r.id);
}
}
changed
}
pub fn unread_count(&self) -> u32 {
self.records.iter().filter(|r| !r.read).count() as u32
}
/// Most-recent-first, optionally capped to `limit`.
pub fn recent(&self, limit: Option<u32>) -> Vec<EventRecord> {
let iter = self.records.iter().rev().cloned();
match limit {
Some(n) => iter.take(n as usize).collect(),
None => iter.collect(),
}
}
pub fn snapshot(&self) -> EventLogState {
EventLogState {
version: SNAPSHOT_VERSION,
next_id: self.next_id,
records: self.records.iter().cloned().collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(log: &mut EventLog, sid: &str, kind: EventKind) -> EventRecord {
log.record(
SurfaceId(sid.into()),
WorkspaceId("w_1".into()),
"infra".into(),
Some("claude".into()),
kind,
1,
)
}
#[test]
fn record_assigns_monotonic_ids() {
let mut log = EventLog::new(10);
assert_eq!(rec(&mut log, "s_1", EventKind::Done).id, 1);
assert_eq!(rec(&mut log, "s_1", EventKind::Wait).id, 2);
}
#[test]
fn push_beyond_cap_evicts_oldest() {
let mut log = EventLog::new(2);
rec(&mut log, "s_1", EventKind::Done); // id 1
rec(&mut log, "s_2", EventKind::Done); // id 2
rec(&mut log, "s_3", EventKind::Done); // id 3, evicts id 1
let ids: Vec<u64> = log.recent(None).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![3, 2]); // newest first, id 1 gone
}
#[test]
fn mark_read_by_surface_then_ids_then_all() {
let mut log = EventLog::new(10);
rec(&mut log, "s_1", EventKind::Done); // 1
rec(&mut log, "s_2", EventKind::Error); // 2
rec(&mut log, "s_1", EventKind::Wait); // 3
assert_eq!(log.unread_count(), 3);
let changed = log.mark_read(&MarkReadTarget::Surface(SurfaceId("s_1".into())));
assert_eq!(changed, vec![1, 3]);
assert_eq!(log.unread_count(), 1);
// Re-marking the same surface changes nothing.
assert!(log.mark_read(&MarkReadTarget::Surface(SurfaceId("s_1".into()))).is_empty());
let changed = log.mark_read(&MarkReadTarget::Ids(vec![2, 999]));
assert_eq!(changed, vec![2]);
assert_eq!(log.unread_count(), 0);
assert!(log.mark_read(&MarkReadTarget::All).is_empty());
}
#[test]
fn snapshot_restore_preserves_next_id_and_records() {
let mut log = EventLog::new(10);
rec(&mut log, "s_1", EventKind::Done);
rec(&mut log, "s_2", EventKind::Done);
let snap = log.snapshot();
assert_eq!(snap.next_id, 3);
let restored = EventLog::restore(snap, 10);
assert_eq!(restored.recent(None).len(), 2);
// Next recorded id continues from 3, no reuse.
let mut restored = restored;
assert_eq!(rec(&mut restored, "s_3", EventKind::Done).id, 3);
}
#[test]
fn restore_clamps_to_cap_keeping_newest() {
let state = EventLogState {
version: 1,
next_id: 4,
records: vec![
EventRecord { id: 1, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false },
EventRecord { id: 2, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false },
EventRecord { id: 3, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false },
],
};
let log = EventLog::restore(state, 2);
let ids: Vec<u64> = log.recent(None).iter().map(|r| r.id).collect();
assert_eq!(ids, vec![3, 2]);
}
#[test]
fn restore_reconciles_next_id_against_records() {
// Snapshot claims next_id=1 but already holds id=5 → next must jump past 5.
let state = EventLogState {
version: 1,
next_id: 1,
records: vec![EventRecord {
id: 5, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()),
workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false,
}],
};
let mut log = EventLog::restore(state, 10);
assert_eq!(rec(&mut log, "s_1", EventKind::Done).id, 6);
}
}
+200
View File
@@ -0,0 +1,200 @@
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use tokio::sync::mpsc;
use tokio::time::{Duration, Instant};
use crate::event_log::EventLogState;
pub trait EventStore: Send + Sync {
fn load(&self) -> Result<EventLogState>;
fn save(&self, state: &EventLogState) -> Result<()>;
}
/// JSON file store with atomic write (temp + fsync + rename) and corrupt backup.
pub struct JsonEventStore {
path: PathBuf,
}
impl JsonEventStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
fn backup_corrupt(&self, ts: u128) {
let bak = self.path.with_extension(format!("corrupt-{ts}"));
let _ = std::fs::rename(&self.path, bak);
}
}
impl EventStore for JsonEventStore {
fn load(&self) -> Result<EventLogState> {
if !self.path.exists() {
return Ok(EventLogState { version: 1, next_id: 1, records: vec![] });
}
let bytes = std::fs::read(&self.path)?;
match serde_json::from_slice::<EventLogState>(&bytes) {
Ok(state) => Ok(state),
Err(_) => {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
self.backup_corrupt(ts);
Ok(EventLogState { version: 1, next_id: 1, records: vec![] })
}
}
}
fn save(&self, state: &EventLogState) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = self.path.with_extension("json.tmp");
let bytes = serde_json::to_vec_pretty(state)?;
std::fs::write(&tmp, &bytes)?;
let f = std::fs::File::open(&tmp)?;
// fsync the temp file before rename for durability.
f.sync_all()?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
/// Handle the recorder uses to request a debounced persist.
#[derive(Clone)]
pub struct EventPersister {
tx: mpsc::Sender<EventLogState>,
}
impl EventPersister {
pub fn mark_dirty(&self, state: EventLogState) {
// Best-effort; dropping a snapshot is fine because a newer one will arrive.
let _ = self.tx.try_send(state);
}
}
/// Spawn the debounce task; coalesces a burst into one save.
pub fn spawn(store: Arc<dyn EventStore>, debounce: Duration) -> EventPersister {
let (tx, mut rx) = mpsc::channel::<EventLogState>(64);
tokio::spawn(async move {
let mut latest: Option<EventLogState> = None;
let mut deadline: Option<Instant> = None;
loop {
let timer = async {
match deadline {
Some(d) => tokio::time::sleep_until(d).await,
None => std::future::pending::<()>().await,
}
};
tokio::select! {
msg = rx.recv() => {
match msg {
Some(state) => {
latest = Some(state);
deadline = Some(Instant::now() + debounce);
}
None => {
// channel closed: final flush then exit
if let Some(s) = latest.take() { let _ = store.save(&s); }
break;
}
}
}
_ = timer => {
if let Some(s) = latest.take() { let _ = store.save(&s); }
deadline = None;
}
}
}
});
EventPersister { tx }
}
#[cfg(test)]
mod tests {
use super::*;
use spacesh_proto::event::{EventKind, EventRecord};
use spacesh_proto::ids::{SurfaceId, WorkspaceId};
fn tmp_file(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
p.push(format!("spacesh-events-{name}-{n}.json"));
p
}
fn sample() -> EventLogState {
EventLogState {
version: 1,
next_id: 2,
records: vec![EventRecord {
id: 1,
surface_id: SurfaceId("s_1".into()),
workspace_id: WorkspaceId("w_1".into()),
workspace_name: "infra".into(),
agent_label: Some("claude".into()),
kind: EventKind::Done,
ts: 1,
read: false,
}],
}
}
#[test]
fn save_then_load_round_trips() {
let path = tmp_file("roundtrip");
let store = JsonEventStore::new(path.clone());
store.save(&sample()).unwrap();
assert_eq!(store.load().unwrap(), sample());
let _ = std::fs::remove_file(path);
}
#[test]
fn missing_file_loads_empty() {
let store = JsonEventStore::new(tmp_file("missing"));
let s = store.load().unwrap();
assert_eq!(s.next_id, 1);
assert!(s.records.is_empty());
}
#[test]
fn corrupt_file_is_backed_up_and_load_returns_empty() {
let path = tmp_file("corrupt");
std::fs::write(&path, b"{ not valid json").unwrap();
let store = JsonEventStore::new(path.clone());
let s = store.load().unwrap();
assert!(s.records.is_empty());
assert!(!path.exists());
let _ = std::fs::remove_file(path);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn burst_coalesces_to_one_save() {
struct Counting {
saves: std::sync::atomic::AtomicUsize,
last: std::sync::Mutex<Option<EventLogState>>,
}
impl EventStore for Counting {
fn load(&self) -> Result<EventLogState> { Ok(EventLogState::default()) }
fn save(&self, s: &EventLogState) -> Result<()> {
self.saves.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
*self.last.lock().unwrap() = Some(s.clone());
Ok(())
}
}
let store = Arc::new(Counting {
saves: std::sync::atomic::AtomicUsize::new(0),
last: std::sync::Mutex::new(None),
});
let p = spawn(store.clone(), Duration::from_millis(80));
for v in 1..=5u64 {
let mut s = EventLogState::default();
s.next_id = v;
p.mark_dirty(s);
tokio::time::sleep(Duration::from_millis(10)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(store.saves.load(std::sync::atomic::Ordering::SeqCst), 1, "burst should coalesce to one save");
assert_eq!(store.last.lock().unwrap().as_ref().unwrap().next_id, 5, "save uses the latest snapshot");
}
}
+6 -1
View File
@@ -1,3 +1,5 @@
mod event_log;
mod event_store;
mod hooks; mod hooks;
mod launchd; mod launchd;
mod lifecycle; mod lifecycle;
@@ -54,6 +56,9 @@ async fn run_daemon() -> Result<()> {
let state_path = lifecycle::spacesh_dir()?.join("state.json"); let state_path = lifecycle::spacesh_dir()?.join("state.json");
let store: std::sync::Arc<dyn state_store::StateStore> = let store: std::sync::Arc<dyn state_store::StateStore> =
std::sync::Arc::new(state_store::JsonStateStore::new(state_path)); std::sync::Arc::new(state_store::JsonStateStore::new(state_path));
let events_path = lifecycle::spacesh_dir()?.join("events.json");
let event_store: std::sync::Arc<dyn event_store::EventStore> =
std::sync::Arc::new(event_store::JsonEventStore::new(events_path));
eprintln!("spaceshd listening on {}", sock.display()); eprintln!("spaceshd listening on {}", sock.display());
server::serve(&sock, store).await server::serve(&sock, store, event_store).await
} }
+469 -13
View File
@@ -9,6 +9,8 @@ use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId};
use spacesh_proto::status::SurfaceState; use spacesh_proto::status::SurfaceState;
use tokio::net::{UnixListener, UnixStream}; use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use crate::event_log::EventLog;
use crate::event_store::{self, EventPersister, EventStore};
use crate::persist::{self, Persister}; use crate::persist::{self, Persister};
use crate::registry::Registry; use crate::registry::Registry;
use crate::state_store::StateStore; use crate::state_store::StateStore;
@@ -35,7 +37,7 @@ enum ServerMsg {
type ClientId = u64; type ClientId = u64;
pub async fn serve(socket: &Path, store: Arc<dyn StateStore>) -> Result<()> { pub async fn serve(socket: &Path, store: Arc<dyn StateStore>, event_store: Arc<dyn EventStore>) -> Result<()> {
let listener = UnixListener::bind(socket)?; let listener = UnixListener::bind(socket)?;
let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256); let (router_tx, router_rx) = mpsc::channel::<ServerMsg>(256);
@@ -58,7 +60,12 @@ pub async fn serve(socket: &Path, store: Arc<dyn StateStore>) -> Result<()> {
let persister = persist::spawn(store.clone(), Duration::from_millis(500)); let persister = persist::spawn(store.clone(), Duration::from_millis(500));
let initial = store.load().unwrap_or_default(); let initial = store.load().unwrap_or_default();
let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, state_tx, persister, initial)); let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500));
let event_initial = event_store.load().unwrap_or_default();
let shutdown = tokio::spawn(router(
router_rx, router_tx.clone(), exit_tx, state_tx,
persister, initial, event_persister, event_initial,
));
let mut next_client: ClientId = 0; let mut next_client: ClientId = 0;
loop { loop {
@@ -116,9 +123,12 @@ async fn router(
state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
persister: Persister, persister: Persister,
initial: crate::state_store::PersistState, initial: crate::state_store::PersistState,
event_persister: EventPersister,
event_initial: crate::event_log::EventLogState,
) { ) {
let mut reg = Registry::new(); let mut reg = Registry::new();
reg.restore(initial); reg.restore(initial);
let mut event_log = EventLog::restore(event_initial, 1000);
let mut clients: HashMap<ClientId, ClientTx> = HashMap::new(); let mut clients: HashMap<ClientId, ClientTx> = HashMap::new();
// surface_id → set of client ids subscribed (attached). // surface_id → set of client ids subscribed (attached).
let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new(); let mut subs: HashMap<SurfaceId, Vec<ClientId>> = HashMap::new();
@@ -147,17 +157,24 @@ async fn router(
ServerMsg::Exit { surface_id, code } => { ServerMsg::Exit { surface_id, code } => {
reg.mark_stopped(&surface_id); reg.mark_stopped(&surface_id);
reg.drop_state(&surface_id); reg.drop_state(&surface_id);
record_event(&reg, &mut event_log, &event_persister, &clients,
&surface_id, spacesh_proto::event::EventKind::Exit);
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
broadcast_evt(&clients, &evt); broadcast_evt(&clients, &evt);
} }
ServerMsg::StateDetected { surface_id, state } => { ServerMsg::StateDetected { surface_id, state } => {
if reg.is_running(&surface_id) { if reg.is_running(&surface_id) {
reg.set_state(&surface_id, state); reg.set_state(&surface_id, state);
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id, state })); broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
if let Some(kind) = kind_for_state(state) {
record_event(&reg, &mut event_log, &event_persister, &clients, &surface_id, kind);
}
} }
} }
ServerMsg::Request { id, cmd, client, out } => { ServerMsg::Request { id, cmd, client, out } => {
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &state_tx, &persister).await; handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
&router_tx, &exit_tx, &state_tx, &persister,
&mut event_log, &event_persister).await;
} }
} }
} }
@@ -169,6 +186,46 @@ fn broadcast_evt(clients: &HashMap<ClientId, ClientTx>, evt: &Envelope) {
} }
} }
/// Current unix-epoch milliseconds. `as u64` is safe — epoch millis fit u64 for ~584M years.
fn now_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Which state transitions are worth logging. work/idle are noise → None.
fn kind_for_state(state: SurfaceState) -> Option<spacesh_proto::event::EventKind> {
use spacesh_proto::event::EventKind;
match state {
SurfaceState::Done => Some(EventKind::Done),
SurfaceState::Wait => Some(EventKind::Wait),
SurfaceState::Error => Some(EventKind::Error),
SurfaceState::Work | SurfaceState::Idle => None,
}
}
/// Record one event (denormalizing workspace name + agent label), persist, broadcast.
fn record_event(
reg: &Registry,
log: &mut EventLog,
persister: &EventPersister,
clients: &HashMap<ClientId, ClientTx>,
sid: &SurfaceId,
kind: spacesh_proto::event::EventKind,
) {
// No workspace → the surface was already removed (user-initiated Close / ApplyPreset /
// CloseWorkspace remove it synchronously before the async Exit arrives). Such deliberate
// closes are intentionally NOT logged — only spontaneous process exits and status
// transitions become events.
let Some(ws_id) = reg.workspace_of(sid) else { return };
let ws_name = reg.workspace(&ws_id).map(|w| w.name.clone()).unwrap_or_default();
let agent = reg.surface_spec(sid).and_then(|s| s.agent_label);
let rec = log.record(sid.clone(), ws_id, ws_name, agent, kind, now_millis());
persister.mark_dirty(log.snapshot());
broadcast_evt(clients, &Envelope::Evt(Evt::Event { record: rec }));
}
fn ok(id: u64, data: serde_json::Value) -> Envelope { fn ok(id: u64, data: serde_json::Value) -> Envelope {
Envelope::Res { id, ok: true, data, error: None } Envelope::Res { id, ok: true, data, error: None }
} }
@@ -213,6 +270,8 @@ async fn handle_request(
exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>, exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>,
state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, state_tx: &mpsc::UnboundedSender<(SurfaceId, SurfaceState)>,
persister: &Persister, persister: &Persister,
event_log: &mut EventLog,
event_persister: &EventPersister,
) { ) {
use spacesh_proto::message::SplitDir; use spacesh_proto::message::SplitDir;
use spacesh_proto::layout::{LayoutNode, Orient}; use spacesh_proto::layout::{LayoutNode, Orient};
@@ -491,7 +550,14 @@ async fn handle_request(
let _ = out.send(ok(id, serde_json::Value::Null)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await;
} }
Cmd::Focus { surface_id: _ } => { let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::Focus { surface_id } => {
let ids = event_log.mark_read(&spacesh_proto::event::MarkReadTarget::Surface(surface_id));
if !ids.is_empty() {
event_persister.mark_dirty(event_log.snapshot());
broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids }));
}
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Close { surface_id } => { Cmd::Close { surface_id } => {
if reg.surface_spec(&surface_id).is_some() { if reg.surface_spec(&surface_id).is_some() {
@@ -514,6 +580,9 @@ async fn handle_request(
if reg.is_running(&surface_id) { if reg.is_running(&surface_id) {
reg.set_state(&surface_id, state); reg.set_state(&surface_id, state);
broadcast_evt(clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state })); broadcast_evt(clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
if let Some(kind) = kind_for_state(state) {
record_event(reg, event_log, event_persister, clients, &surface_id, kind);
}
let _ = out.send(ok(id, serde_json::Value::Null)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await;
} else { } else {
// unknown or stopped surface — status is only meaningful while running. // unknown or stopped surface — status is only meaningful while running.
@@ -526,6 +595,21 @@ async fn handle_request(
let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await;
} }
Cmd::EventLog { limit } => {
let events = event_log.recent(limit);
let unread = event_log.unread_count();
let _ = out.send(ok(id, serde_json::json!({ "events": events, "unread": unread }))).await;
}
Cmd::MarkRead { target } => {
let ids = event_log.mark_read(&target);
if !ids.is_empty() {
event_persister.mark_dirty(event_log.snapshot());
broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids }));
}
let _ = out.send(ok(id, serde_json::Value::Null)).await;
}
Cmd::Shutdown => { Cmd::Shutdown => {
let _ = out.send(ok(id, serde_json::Value::Null)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await;
std::process::exit(0); std::process::exit(0);
@@ -589,6 +673,12 @@ mod tests {
p p
} }
/// Build an event store whose file lives inside the per-test temp dir so it is
/// cleaned up with the rest of the test fixtures (not left in the global temp root).
fn make_event_store(dir: &Path) -> std::sync::Arc<dyn crate::event_store::EventStore> {
std::sync::Arc::new(crate::event_store::JsonEventStore::new(dir.join("events.json")))
}
async fn wait_for_socket(sock: &Path) { async fn wait_for_socket(sock: &Path) {
for _ in 0..300 { for _ in 0..300 {
if UnixStream::connect(sock).await.is_ok() { return; } if UnixStream::connect(sock).await.is_ok() { return; }
@@ -604,9 +694,10 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone(); let sock_for_task = sock.clone();
let store2 = store.clone(); let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -645,9 +736,10 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone(); let sock_for_task = sock.clone();
let store2 = store.clone(); let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Input { let r = req(&mut s, 1, Cmd::Input {
@@ -670,9 +762,10 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone(); let sock_for_task = sock.clone();
let store2 = store.clone(); let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
// First client: open, new surface that prints a marker, attach, then disconnect. // First client: open, new surface that prints a marker, attach, then disconnect.
@@ -709,8 +802,9 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone(); let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; }); tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -738,8 +832,9 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone(); let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; }); tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -779,8 +874,11 @@ mod tests {
{ {
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
// Both daemon instances in this test share ONE event-store file under the
// per-test dir so instance B reads from disk what instance A persisted.
let event_store = make_event_store(&dir);
let sock2 = sock.clone(); let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; }); tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
@@ -795,8 +893,9 @@ mod tests {
let sock_b = dir.join("sock2"); let sock_b = dir.join("sock2");
let store_b: std::sync::Arc<dyn crate::state_store::StateStore> = let store_b: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store_b = make_event_store(&dir);
let sb2 = sock_b.clone(); let sb2 = sock_b.clone();
tokio::spawn(async move { let _ = serve(&sock_b, store_b).await; }); tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
wait_for_socket(&sb2).await; wait_for_socket(&sb2).await;
let mut s2 = UnixStream::connect(&sb2).await.unwrap(); let mut s2 = UnixStream::connect(&sb2).await.unwrap();
let r = req(&mut s2, 1, Cmd::Status).await; let r = req(&mut s2, 1, Cmd::Status).await;
@@ -816,8 +915,9 @@ mod tests {
let sock = dir.join("sock"); let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> = let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone(); let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store).await; }); tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await; wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap(); let mut s = UnixStream::connect(&sock).await.unwrap();
@@ -844,4 +944,360 @@ mod tests {
} }
assert!(saw_done, "expected a Done state event from OSC 133"); assert!(saw_done, "expected a Done state event from OSC 133");
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn set_state_done_emits_event_record() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn surface.
let mut ctrl = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut ctrl, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut ctrl, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "sleep 5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Observer connection: a second client that receives all broadcast events
// without its read loop consuming them via req().
let mut observer = UnixStream::connect(&sock).await.unwrap();
// Trigger Done via SetState on the control connection.
let _ = req(&mut ctrl, 3, Cmd::SetState {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
state: spacesh_proto::status::SurfaceState::Done,
}).await;
// Expect an Evt::Event for this surface on the observer within a short window.
let mut found = None;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
if let Envelope::Evt(Evt::Event { record }) = env {
if record.surface_id.0 == sid { found = Some(record); break; }
}
}
}
let rec = found.expect("expected an Evt::Event for the surface");
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
assert!(!rec.read);
assert_eq!(rec.workspace_id.0, ws);
assert!(!rec.workspace_name.is_empty(), "workspace name should be denormalized into the record");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn close_does_not_emit_event_record() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn surface.
let mut ctrl = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut ctrl, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut ctrl, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "sleep 5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Observer connection: receives all broadcast events.
let mut observer = UnixStream::connect(&sock).await.unwrap();
// User-initiated Close on the control connection.
let _ = req(&mut ctrl, 3, Cmd::Close {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
}).await;
// A deliberate Close must surface an Evt::Exit but NEVER an Evt::Event for it.
let mut saw_exit = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
match env {
Envelope::Evt(Evt::Event { record }) if record.surface_id.0 == sid => {
panic!("user-initiated Close must not produce an Evt::Event");
}
Envelope::Evt(Evt::Exit { surface_id, .. }) if surface_id.0 == sid => {
saw_exit = true;
}
_ => {}
}
}
}
assert!(saw_exit, "expected an Evt::Exit for the closed surface");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn osc133_detected_state_emits_event_record() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await;
// Control connection: open workspace and spawn a surface that emits OSC 133.
let mut ctrl = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut ctrl, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut ctrl, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws.clone()),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "printf '\\033]133;C\\007'; printf hi; printf '\\033]133;D;0\\007'; sleep 1".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Observer connection: receives all broadcast events (the detected-state path
// flows through ServerMsg::StateDetected → record_event → Evt::Event).
let mut observer = UnixStream::connect(&sock).await.unwrap();
// Drive the PTY output by attaching the control connection.
let _ = req(&mut ctrl, 3, Cmd::Attach {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
}).await;
// Expect an Evt::Event (kind=done) for this surface from the OSC 133 Done detection.
let mut found = None;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut observer)).await {
if let Envelope::Evt(Evt::Event { record }) = env {
if record.surface_id.0 == sid { found = Some(record); break; }
}
}
}
let rec = found.expect("expected an Evt::Event from the OSC 133 detected state");
assert_eq!(rec.kind, spacesh_proto::event::EventKind::Done);
assert!(!rec.read);
assert_eq!(rec.workspace_id.0, ws);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn event_log_query_and_mark_read() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await;
// Observer connection to catch the EventsRead broadcast.
let mut obs = UnixStream::connect(&sock).await.unwrap();
// Control connection.
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "sleep 5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
let _ = req(&mut s, 3, Cmd::SetState {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
state: spacesh_proto::status::SurfaceState::Error,
}).await;
// Query the log.
let log = req(&mut s, 4, Cmd::EventLog { limit: None }).await;
let data = res_data(&log);
assert_eq!(data["unread"].as_u64().unwrap(), 1);
let first_id = data["events"][0]["id"].as_u64().unwrap();
// Mark it read by id.
let _ = req(&mut s, 5, Cmd::MarkRead {
target: spacesh_proto::event::MarkReadTarget::Ids(vec![first_id]),
}).await;
// Observer should see EventsRead { ids: [first_id] }.
let mut saw_read = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut obs)).await {
if let Envelope::Evt(Evt::EventsRead { ids }) = env {
if ids == vec![first_id] { saw_read = true; break; }
}
}
}
assert!(saw_read, "expected an EventsRead broadcast for the marked id");
// Unread is now 0.
let log = req(&mut s, 6, Cmd::EventLog { limit: None }).await;
assert_eq!(res_data(&log)["unread"].as_u64().unwrap(), 0);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn event_log_persists_across_daemon_restart() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let state_path = dir.join("state.json");
let sock = dir.join("sock");
let event_id: u64;
let ws_id: String;
// ── Instance A ────────────────────────────────────────────────────────
{
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store = make_event_store(&dir);
let sock2 = sock.clone();
tokio::spawn(async move { let _ = serve(&sock2, store, event_store).await; });
wait_for_socket(&sock).await;
let mut s = UnixStream::connect(&sock).await.unwrap();
// Open workspace, spawn surface.
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
ws_id = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws_id.clone()),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "sleep 5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Drive an Error state → one unread event is logged.
let _ = req(&mut s, 3, Cmd::SetState {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
state: spacesh_proto::status::SurfaceState::Error,
}).await;
// Query and assert unread == 1 before restart.
let log = req(&mut s, 4, Cmd::EventLog { limit: None }).await;
let data = res_data(&log);
assert_eq!(data["unread"].as_u64().unwrap(), 1, "instance A: expected 1 unread event");
assert_eq!(data["events"][0]["kind"].as_str().unwrap(), "error");
assert_eq!(data["events"][0]["workspace_id"].as_str().unwrap(), ws_id);
event_id = data["events"][0]["id"].as_u64().unwrap();
// Wait comfortably longer than the 500 ms debounce so events.json is flushed.
tokio::time::sleep(tokio::time::Duration::from_millis(900)).await;
// Drop `s` (and instance A's task) by falling out of scope.
}
// ── Instance B (same dir, fresh socket path) ──────────────────────────
let sock_b = dir.join("sock2");
let store_b: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone()));
let event_store_b = make_event_store(&dir);
let sb2 = sock_b.clone();
tokio::spawn(async move { let _ = serve(&sock_b, store_b, event_store_b).await; });
wait_for_socket(&sb2).await;
let mut s2 = UnixStream::connect(&sb2).await.unwrap();
// Query event log on instance B — the persisted event must survive the restart.
let log = req(&mut s2, 1, Cmd::EventLog { limit: None }).await;
let data = res_data(&log);
assert_eq!(data["unread"].as_u64().unwrap(), 1,
"instance B: event log unread count must survive cold restart");
assert_eq!(data["events"][0]["id"].as_u64().unwrap(), event_id,
"instance B: event id must match");
assert_eq!(data["events"][0]["kind"].as_str().unwrap(), "error",
"instance B: event kind must be 'error'");
assert_eq!(data["events"][0]["workspace_id"].as_str().unwrap(), ws_id,
"instance B: workspace_id must match");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn focus_marks_surface_events_read() {
let _serial = crate::test_support::serial();
let dir = tempdir_path();
let sock = dir.join("sock");
let store: std::sync::Arc<dyn crate::state_store::StateStore> =
std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json")));
let event_store = make_event_store(&dir);
let sock_for_task = sock.clone();
let store2 = store.clone();
tokio::spawn(async move { let _ = serve(&sock_for_task, store2, event_store).await; });
wait_for_socket(&sock).await;
// Observer connection.
let mut obs = UnixStream::connect(&sock).await.unwrap();
// Control connection.
let mut s = UnixStream::connect(&sock).await.unwrap();
let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await;
let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string();
let r = req(&mut s, 2, Cmd::NewSurface {
workspace_id: spacesh_proto::WorkspaceId(ws),
command: Some("/bin/sh".into()),
args: vec!["-c".into(), "sleep 5".into()],
cols: 80, rows: 24,
}).await;
let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string();
// Drive an Error event for this surface.
let _ = req(&mut s, 3, Cmd::SetState {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
state: spacesh_proto::status::SurfaceState::Error,
}).await;
// Verify unread == 1 before Focus.
let log = req(&mut s, 4, Cmd::EventLog { limit: None }).await;
assert_eq!(res_data(&log)["unread"].as_u64().unwrap(), 1);
let first_id = res_data(&log)["events"][0]["id"].as_u64().unwrap();
// Focus that surface — should mark its events read.
let _ = req(&mut s, 5, Cmd::Focus {
surface_id: spacesh_proto::SurfaceId(sid.clone()),
}).await;
// Observer should receive EventsRead with the id from the Error event.
let mut saw_read = false;
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(3);
while tokio::time::Instant::now() < deadline {
if let Ok(Ok(Some(env))) =
tokio::time::timeout(tokio::time::Duration::from_millis(200), read_frame(&mut obs)).await {
if let Envelope::Evt(Evt::EventsRead { ids }) = env {
if ids.contains(&first_id) { saw_read = true; break; }
}
}
}
assert!(saw_read, "expected an EventsRead broadcast after Focus");
// Unread drops to 0.
let log = req(&mut s, 6, Cmd::EventLog { limit: None }).await;
assert_eq!(res_data(&log)["unread"].as_u64().unwrap(), 0);
}
} }