feat(app): sidebar, preset picker, wizard, App rewired around workspaces + LayoutEngine
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+42
-57
@@ -1,68 +1,53 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { LayoutEngine } from "./LayoutEngine";
|
||||||
import { SurfaceList } from "./SurfaceList";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { openWorkspace, newSurface, getStatus, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
import { PresetPicker } from "./PresetPicker";
|
||||||
|
import { Wizard } from "./Wizard";
|
||||||
|
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge";
|
||||||
|
import type { Group, WorkspaceView } from "./layoutTypes";
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [surfaces, setSurfaces] = useState<string[]>([]);
|
const [groups, setGroups] = useState<Group[]>([]);
|
||||||
const [active, setActive] = useState<string | null>(null);
|
const [workspaces, setWorkspaces] = useState<WorkspaceView[]>([]);
|
||||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState<Record<string, boolean>>({});
|
||||||
|
const [wizard, setWizard] = useState(false);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const st = await getStatusFull();
|
||||||
|
setGroups(st.groups);
|
||||||
|
setWorkspaces(st.workspaces);
|
||||||
|
const run: Record<string, boolean> = {};
|
||||||
|
st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; }));
|
||||||
|
setRunning(run);
|
||||||
|
if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id);
|
||||||
|
}, [activeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void refresh();
|
||||||
const ws = await getStatus();
|
const unlisten = onDaemonEvent(() => { void refresh(); });
|
||||||
const flat = ws.flatMap((w) => w.surfaces);
|
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); });
|
||||||
setSurfaces(flat);
|
return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); };
|
||||||
if (flat.length) setActive(flat[0]);
|
}, [refresh]);
|
||||||
})();
|
|
||||||
|
|
||||||
const unlisten = onDaemonEvent((evt) => {
|
const active = workspaces.find((w) => w.id === activeId) ?? null;
|
||||||
if (evt.evt === "surface_created") {
|
|
||||||
setSurfaces((s) => [...s, evt.data.surface_id]);
|
|
||||||
} else if (evt.evt === "surface_closed" || evt.evt === "exit") {
|
|
||||||
// exit leaves the surface visible; surface_closed removes it.
|
|
||||||
if (evt.evt === "surface_closed") {
|
|
||||||
setSurfaces((s) => s.filter((id) => id !== evt.data.surface_id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const reconnect = onDaemonRawEvent("spacesh:disconnected", () => {
|
|
||||||
// Force a remount of the active TerminalView by toggling the key.
|
|
||||||
setActive((cur) => cur);
|
|
||||||
void getStatus().then((ws) => {
|
|
||||||
const flat = ws.flatMap((w) => w.surfaces);
|
|
||||||
setSurfaces(flat);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
void unlisten.then((f) => f());
|
|
||||||
void reconnect.then((f) => f());
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleNewSurface() {
|
|
||||||
let ws = workspaceId;
|
|
||||||
if (!ws) {
|
|
||||||
ws = await openWorkspace(".");
|
|
||||||
setWorkspaceId(ws);
|
|
||||||
}
|
|
||||||
const id = await newSurface(ws, 80, 24);
|
|
||||||
setActive(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", height: "100vh", background: "#000" }}>
|
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
|
||||||
<div style={{ display: "flex", flexDirection: "column", width: 160 }}>
|
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={setActiveId} onNew={() => setWizard(true)} />
|
||||||
<button onClick={handleNewSurface} style={{ margin: 8 }}>
|
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||||
+ surface
|
{active && (
|
||||||
</button>
|
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
|
||||||
<SurfaceList surfaces={surfaces} active={active} onSelect={setActive} />
|
<PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1 }}>
|
)}
|
||||||
{active ? <TerminalView key={active} surfaceId={active} /> : <div style={{ color: "#666", padding: 16 }}>no surface</div>}
|
<div style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
{active
|
||||||
|
? <LayoutEngine workspaceId={active.id} layout={active.layout} running={running} />
|
||||||
|
: <div style={{ color: "#666", padding: 24 }}>No workspace — create one to begin.</div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const PRESETS: { id: string; label: string; slots: number }[] = [
|
||||||
|
{ id: "1", label: "1", slots: 1 },
|
||||||
|
{ id: "2lr", label: "2↔", slots: 2 },
|
||||||
|
{ id: "2tb", label: "2↕", slots: 2 },
|
||||||
|
{ id: "2+1", label: "2+1", slots: 3 },
|
||||||
|
{ id: "1+2", label: "1+2", slots: 3 },
|
||||||
|
{ id: "3", label: "3", slots: 3 },
|
||||||
|
{ id: "2x2", label: "2×2", slots: 4 },
|
||||||
|
{ id: "4", label: "4", slots: 4 },
|
||||||
|
{ id: "2x3", label: "2×3", slots: 6 },
|
||||||
|
{ id: "2x4", label: "2×4", slots: 8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button key={p.id} onClick={() => onSelect(p.id)}
|
||||||
|
style={{
|
||||||
|
padding: "6px 10px", borderRadius: 6, fontFamily: "monospace", fontSize: 12,
|
||||||
|
background: p.id === selected ? "#1A2029" : "transparent",
|
||||||
|
border: p.id === selected ? "1px solid #4C8DFF" : "1px solid #232A33",
|
||||||
|
color: p.id === selected ? "#E6EDF3" : "#8B97A6", cursor: "pointer",
|
||||||
|
}}>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Group, WorkspaceView } from "./layoutTypes";
|
||||||
|
|
||||||
|
export function Sidebar({
|
||||||
|
groups, workspaces, activeId, onSelect, onNew,
|
||||||
|
}: {
|
||||||
|
groups: Group[];
|
||||||
|
workspaces: WorkspaceView[];
|
||||||
|
activeId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
}) {
|
||||||
|
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 row = (w: WorkspaceView) => (
|
||||||
|
<div key={w.id} onClick={() => onSelect(w.id)}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer",
|
||||||
|
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={{ 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 248, background: "#13171F", height: "100%", padding: 14, boxSizing: "border-box", overflowY: "auto" }}>
|
||||||
|
<button onClick={onNew} style={{ width: "100%", padding: 8, marginBottom: 16, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 7 }}>+ New workspace</button>
|
||||||
|
{groups.sort((a, b) => a.order - b.order).map((g) => (
|
||||||
|
<div key={g.id} style={{ marginBottom: 12 }}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 7, padding: "0 4px", marginBottom: 4 }}>
|
||||||
|
<span style={{ width: 8, height: 8, borderRadius: 2, background: g.color }} />
|
||||||
|
<span style={{ fontFamily: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
{byGroup(g.id).map(row)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { PresetPicker, PRESETS } from "./PresetPicker";
|
||||||
|
import { openWorkspace, applyPreset } from "./socketBridge";
|
||||||
|
|
||||||
|
export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) {
|
||||||
|
const [path, setPath] = useState(".");
|
||||||
|
const [preset, setPreset] = useState("2x2");
|
||||||
|
const [agents, setAgents] = useState<string[]>([]);
|
||||||
|
const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1;
|
||||||
|
const agentChoices = ["shell", "claude", "codex", "gemini"];
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
const ws = await openWorkspace(path);
|
||||||
|
const slotSpecs = Array.from({ length: slots }, (_, i) => {
|
||||||
|
const a = agents[i] ?? "shell";
|
||||||
|
return a === "shell" ? {} : { command: a };
|
||||||
|
});
|
||||||
|
await applyPreset(ws, preset, slotSpecs);
|
||||||
|
onDone(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "fixed", inset: 0, background: "#000A", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<div style={{ width: 480, background: "#0E1116", border: "1px solid #323C49", borderRadius: 14, padding: 24, color: "#E6EDF3" }}>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 16 }}>New workspace</div>
|
||||||
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Project folder</label>
|
||||||
|
<input value={path} onChange={(e) => setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
|
||||||
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Layout</label>
|
||||||
|
<div style={{ margin: "8px 0 16px" }}><PresetPicker selected={preset} onSelect={setPreset} /></div>
|
||||||
|
<label style={{ fontSize: 12, color: "#8B97A6" }}>Agents</label>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8, margin: "8px 0 20px" }}>
|
||||||
|
{Array.from({ length: slots }, (_, i) => (
|
||||||
|
<select key={i} value={agents[i] ?? "shell"} onChange={(e) => setAgents((a) => { const n = [...a]; n[i] = e.target.value; return n; })}
|
||||||
|
style={{ padding: 8, background: "#1A2029", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 6 }}>
|
||||||
|
{agentChoices.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 10 }}>
|
||||||
|
<button onClick={onCancel} style={{ padding: "8px 16px" }}>Cancel</button>
|
||||||
|
<button onClick={() => void create()} style={{ padding: "8px 16px", background: "#4C8DFF", color: "#0A0D12", border: "none", borderRadius: 8, fontWeight: 700 }}>Create workspace</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user