feat(app): status rings on panels + sidebar aggregate badge from state events

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:11:46 +07:00
parent c35585755e
commit d36548ff39
7 changed files with 79 additions and 9 deletions
+5
View File
@@ -255,3 +255,8 @@ pub async fn set_group(state: BridgeState<'_>, group_id: String, name: Option<St
pub async fn delete_group(state: BridgeState<'_>, group_id: String) -> Result<Value, String> {
data_of(state.request(Cmd::DeleteGroup { group_id: GroupId(group_id) }).await.map_err(|e| e.to_string())?)
}
#[tauri::command]
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())?)
}
+1
View File
@@ -35,6 +35,7 @@ pub fn run() {
bridge::create_group,
bridge::set_group,
bridge::delete_group,
bridge::focus,
])
.run(tauri::generate_context!())
.expect("error while running spacesh");
+18 -6
View File
@@ -1,6 +1,7 @@
import { useRef } from "react";
import { TerminalView } from "./TerminalView";
import type { LayoutNode } from "./layoutTypes";
import { StatusRing } from "./StatusRing";
import type { LayoutNode, SurfaceState } from "./layoutTypes";
import { setRatios, restartSurface } from "./socketBridge";
interface Props {
@@ -8,16 +9,17 @@ interface Props {
layout: LayoutNode | null;
/** surface_id -> running flag, from the latest status/events. */
running: Record<string, boolean>;
states: Record<string, SurfaceState>;
}
export function LayoutEngine({ workspaceId, layout, running }: Props) {
export function LayoutEngine({ workspaceId, layout, running, states }: Props) {
if (!layout) {
return <div style={{ color: "#666", padding: 24 }}>Empty workspace apply a preset to add panels.</div>;
}
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} />;
return <Node workspaceId={workspaceId} node={layout} path={[]} running={running} states={states} />;
}
function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean> }) {
function Node({ workspaceId, node, path, running, states }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record<string, boolean>; states: Record<string, SurfaceState> }) {
if ("leaf" in node) {
const id = node.leaf.surface_id;
if (running[id] === false) {
@@ -28,7 +30,17 @@ function Node({ workspaceId, node, path, running }: { workspaceId: string; node:
</div>
);
}
return <TerminalView key={id} surfaceId={id} />;
return (
<div style={{ display: "flex", flexDirection: "column", width: "100%", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "3px 8px", background: "#0A0D12", borderBottom: "1px solid #232A33" }}>
<StatusRing state={states[id] ?? "idle"} running={true} />
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{id}</span>
</div>
<div style={{ flex: 1, minHeight: 0 }}>
<TerminalView key={id} surfaceId={id} />
</div>
</div>
);
}
const { orient, ratios, children } = node.split;
@@ -43,7 +55,7 @@ function Node({ workspaceId, node, path, running }: { workspaceId: string; node:
next[i + 1] = Math.max(0.05, (next[i + 1] ?? 1) - deltaFrac);
void setRatios(workspaceId, path, next);
}}>
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} />
<Node workspaceId={workspaceId} node={child} path={[...path, i]} running={running} states={states} />
</Pane>
))}
</div>
+16 -2
View File
@@ -1,4 +1,18 @@
import type { Group, WorkspaceView } 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" {
const order: SurfaceState[] = ["error", "wait", "work", "done", "idle"];
const running = Object.values(w.surfaces).filter((s) => s.running);
if (running.length === 0) return "stopped";
for (const st of order) {
if (running.some((s) => s.state === st)) return st;
}
return "idle";
}
export function Sidebar({
groups, workspaces, activeId, onSelect, onNew,
@@ -19,7 +33,7 @@ export function Sidebar({
background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13,
color: w.id === activeId ? "#E6EDF3" : "#8B97A6",
}}>
<span style={{ width: 10, height: 10, borderRadius: "50%", border: "2px solid #5A6573" }} />
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${RING[aggregate(w)]}`, boxSizing: "border-box" }} />
<span style={{ flex: 1 }}>{w.name}</span>
{w.unread && <span style={{ width: 7, height: 7, borderRadius: "50%", background: "#4C8DFF" }} />}
<span style={{ fontFamily: "monospace", fontSize: 11, color: "#5A6573" }}>{Object.keys(w.surfaces).length}</span>
+27
View File
@@ -0,0 +1,27 @@
import type { SurfaceState } from "./layoutTypes";
const COLOR: Record<SurfaceState, string> = {
work: "#4C8DFF",
wait: "#F2B84B",
done: "#3FB950",
error: "#F4544E",
idle: "#5A6573",
};
export function StatusRing({ state, running }: { state: SurfaceState; running: boolean }) {
const color = running ? COLOR[state] : "#5A6573";
return (
<span
title={running ? state : "stopped"}
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
border: `2px solid ${color}`,
boxSizing: "border-box",
opacity: running ? 1 : 0.5,
}}
/>
);
}
+3
View File
@@ -1,5 +1,7 @@
export type Orient = "h" | "v";
export type SurfaceState = "work" | "wait" | "done" | "error" | "idle";
export type LayoutNode =
| { leaf: { surface_id: string } }
| { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } };
@@ -15,6 +17,7 @@ export interface SurfaceView {
autostart: boolean;
};
running: boolean;
state: SurfaceState;
}
export interface Group {
+9 -1
View File
@@ -65,12 +65,20 @@ export async function getStatus(): Promise<WorkspaceStatus[]> {
export type DaemonEvt =
| { evt: "exit"; data: { surface_id: string; code: number } }
| { evt: "surface_created"; data: { surface_id: string; workspace_id: string } }
| { evt: "surface_closed"; data: { surface_id: string } };
| { evt: "surface_closed"; data: { surface_id: string } }
| { evt: "state"; data: { surface_id: string; state: import("./layoutTypes").SurfaceState } }
| { evt: "layout_changed"; data: { workspace_id: string } }
| { evt: "workspace_changed"; data: unknown }
| { evt: "groups_changed"; data: unknown };
export function onDaemonEvent(handler: (evt: DaemonEvt) => void): Promise<() => void> {
return listen<DaemonEvt>("spacesh:evt", (e) => handler(e.payload));
}
export async function focusSurface(surfaceId: string): Promise<void> {
await invoke("focus", { surfaceId });
}
export function onDaemonRawEvent(name: string, handler: () => void): Promise<() => void> {
return listen(name, () => handler());
}