feat(app): UI parity with Pencil mockup — top bar, panel cards, sidebar/event-center polish
Top bar (breadcrumb + actions + account), rounded panel cards with active accent + rich headers, sidebar count pills/collapsible groups/daemon footer, preset chips + scrollback pill, Event Center tabs + external-notify footer, JetBrains Mono + Inter via @fontsource, shared theme tokens. Backend-absent pieces are mocked (search, zoom, uptime, channels) pending SP1–SP5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+30
@@ -8,10 +8,13 @@
|
||||
"name": "spacesh-app",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
@@ -697,6 +700,24 @@
|
||||
"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": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1691,6 +1712,15 @@
|
||||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
||||
+4
-1
@@ -9,10 +9,13 @@
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
+17
-8
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { LayoutEngine } from "./LayoutEngine";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { PresetPicker } from "./PresetPicker";
|
||||
import { TopBar } from "./TopBar";
|
||||
import { CenterToolbar } from "./CenterToolbar";
|
||||
import { Wizard } from "./Wizard";
|
||||
import { EventCenter, type FeedEntry } from "./EventCenter";
|
||||
import { maybeNotify } from "./notify";
|
||||
import { COLORS } from "./theme";
|
||||
import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent, setWorkspaceMeta, focusSurface } from "./socketBridge";
|
||||
import { leafIds } from "./layoutTypes";
|
||||
import type { Group, WorkspaceView, SurfaceState } from "./layoutTypes";
|
||||
|
||||
export function App() {
|
||||
@@ -16,6 +19,8 @@ export function App() {
|
||||
const [states, setStates] = useState<Record<string, SurfaceState>>({});
|
||||
const [feed, setFeed] = useState<FeedEntry[]>([]);
|
||||
const [wizard, setWizard] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(true);
|
||||
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||
const feedId = useRef(0);
|
||||
const activeRef = useRef<string | null>(null);
|
||||
const wsRef = useRef<WorkspaceView[]>([]);
|
||||
@@ -66,28 +71,32 @@ export function App() {
|
||||
}, [refresh]);
|
||||
|
||||
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) {
|
||||
setActiveId(id);
|
||||
setFocusedId(null);
|
||||
void setWorkspaceMeta(id, { unread: false });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", background: "#0E1116" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: COLORS.bgApp }}>
|
||||
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} />
|
||||
<div style={{ flex: 1, display: "flex", minHeight: 0 }}>
|
||||
<Sidebar groups={groups} workspaces={workspaces} activeId={activeId} onSelect={selectWorkspace} onNew={() => setWizard(true)} />
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
|
||||
{active && (
|
||||
<div style={{ padding: 8, borderBottom: "1px solid #232A33" }}>
|
||||
<PresetPicker selected="" onSelect={(p) => { if (active) void applyPreset(active.id, p, []); }} />
|
||||
</div>
|
||||
<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} />
|
||||
: <div style={{ color: "#666", padding: 24 }}>No workspace — create one to begin.</div>}
|
||||
? <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>
|
||||
<EventCenter feed={feed} onMarkRead={() => setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />
|
||||
{eventsOpen && <EventCenter feed={feed} onMarkRead={() => setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />}
|
||||
</div>
|
||||
{wizard && <Wizard onDone={(id) => { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+66
-13
@@ -1,3 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Hourglass, X, CircleDot, Power, Send, MessageSquare } from "lucide-react";
|
||||
import { COLORS, FONT } from "./theme";
|
||||
import type { SurfaceState } from "./layoutTypes";
|
||||
|
||||
export interface FeedEntry {
|
||||
@@ -9,29 +12,79 @@ export interface FeedEntry {
|
||||
time: string;
|
||||
}
|
||||
|
||||
const ICON: Record<string, string> = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" };
|
||||
const COLOR: Record<string, string> = { done: "#3FB950", wait: "#F2B84B", error: "#F4544E", work: "#4C8DFF", idle: "#5A6573", exit: "#5A6573" };
|
||||
const ICON: Record<string, React.ReactNode> = {
|
||||
done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />,
|
||||
work: <CircleDot size={13} />, idle: <CircleDot size={13} />, exit: <Power size={13} />,
|
||||
};
|
||||
const COLOR: Record<string, string> = {
|
||||
done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, work: COLORS.stWork, idle: COLORS.stIdle, exit: COLORS.textMuted,
|
||||
};
|
||||
|
||||
type Tab = "all" | "unread" | "errors";
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: "all", label: "All" },
|
||||
{ id: "unread", label: "Unread" },
|
||||
{ id: "errors", label: "Errors" },
|
||||
];
|
||||
|
||||
export function EventCenter({ feed, onMarkRead, onSelect }: { feed: FeedEntry[]; onMarkRead: () => void; onSelect: (surfaceId: string) => void }) {
|
||||
const [tab, setTab] = useState<Tab>("all");
|
||||
const shown = tab === "errors" ? feed.filter((e) => e.kind === "error") : feed;
|
||||
|
||||
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 }}>
|
||||
<span style={{ fontFamily: "Inter", fontSize: 13, fontWeight: 700, color: "#E6EDF3", flex: 1 }}>Event Center</span>
|
||||
<span onClick={onMarkRead} style={{ fontSize: 11, color: "#4C8DFF", cursor: "pointer" }}>Mark all read</span>
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
|
||||
<span onClick={onMarkRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{feed.length === 0 && <div style={{ color: "#5A6573", fontSize: 12 }}>No events yet.</div>}
|
||||
{feed.map((e) => (
|
||||
|
||||
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
|
||||
{TABS.map((t) => {
|
||||
const on = t.id === tab;
|
||||
return (
|
||||
<button key={t.id} onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
height: 22, padding: "0 9px", borderRadius: 11, fontFamily: FONT.ui, fontSize: 11, fontWeight: on ? 600 : 400,
|
||||
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.surfaceId)}
|
||||
style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}>
|
||||
<span style={{ color: COLOR[e.kind] }}>{ICON[e.kind]}</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: "monospace", fontSize: 11, color: "#8B97A6" }}>{e.workspace} · {e.agent}</div>
|
||||
<div style={{ fontFamily: "Inter", fontSize: 12, color: "#E6EDF3" }}>{e.kind} <span style={{ color: "#5A6573" }}>{e.time}</span></div>
|
||||
style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer" }}>
|
||||
<span style={{ color: COLOR[e.kind], display: "flex", alignItems: "center" }}>{ICON[e.kind]}</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} · {e.agent}</div>
|
||||
<div style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>{e.kind} <span style={{ color: COLORS.textMuted }}>{e.time}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* External notification channels — mocked until the daemon subscriber lands (M5). */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+66
-20
@@ -1,7 +1,9 @@
|
||||
import { useRef } from "react";
|
||||
import { Maximize2, RotateCw } from "lucide-react";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -10,36 +12,81 @@ interface Props {
|
||||
/** surface_id -> running flag, from the latest status/events. */
|
||||
running: Record<string, boolean>;
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", background: "#0A0D12", color: "#8B97A6", flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: "monospace", fontSize: 13 }}>Process exited</div>
|
||||
<button onClick={() => void restartSurface(id)} style={{ padding: "6px 14px" }}>⏎ Restart</button>
|
||||
return card(
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", width: "100%", color: COLORS.textSecondary, flexDirection: "column", gap: 10 }}>
|
||||
<div style={{ fontFamily: FONT.mono, fontSize: 13 }}>Process exited</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
|
||||
const spec = surfaces[id]?.spec;
|
||||
const agent = spec?.agent_label ?? "shell";
|
||||
const state = states[id] ?? "idle";
|
||||
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 style={{ flex: 1, minHeight: 0 }}>
|
||||
<TerminalView key={id} surfaceId={id} />
|
||||
</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);
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
@@ -69,8 +116,7 @@ function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLa
|
||||
const parent = ref.current?.parentElement;
|
||||
if (!parent) return;
|
||||
const total = orient === "h" ? parent.clientWidth : parent.clientHeight;
|
||||
const start = orient === "h" ? e.clientX : e.clientY;
|
||||
let last = start;
|
||||
let last = orient === "h" ? e.clientX : e.clientY;
|
||||
const move = (ev: MouseEvent) => {
|
||||
const cur = orient === "h" ? ev.clientX : ev.clientY;
|
||||
const delta = (cur - last) / total;
|
||||
@@ -92,9 +138,9 @@ function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLa
|
||||
{!isLast && (
|
||||
<div onMouseDown={startDrag}
|
||||
style={{
|
||||
flex: "0 0 4px",
|
||||
flex: "0 0 10px",
|
||||
cursor: orient === "h" ? "col-resize" : "row-resize",
|
||||
background: "#232A33",
|
||||
background: "transparent",
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -11,20 +11,25 @@ export const PRESETS: { id: string; label: string; slots: number }[] = [
|
||||
{ id: "2x4", label: "2×4", slots: 8 },
|
||||
];
|
||||
|
||||
import { COLORS, FONT } from "./theme";
|
||||
|
||||
export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) {
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{PRESETS.map((p) => (
|
||||
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
{PRESETS.map((p) => {
|
||||
const on = p.id === selected;
|
||||
return (
|
||||
<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",
|
||||
display: "flex", alignItems: "center", height: 24, padding: "0 8px", borderRadius: 6, fontFamily: FONT.mono, fontSize: 12,
|
||||
background: on ? COLORS.bgElevated : "transparent",
|
||||
border: `1px solid ${on ? COLORS.borderStrong : "transparent"}`,
|
||||
color: on ? COLORS.textPrimary : COLORS.textSecondary,
|
||||
}}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+51
-21
@@ -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";
|
||||
|
||||
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);
|
||||
@@ -23,36 +22,67 @@ export function Sidebar({
|
||||
onSelect: (id: string) => 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 ungrouped = byGroup(null);
|
||||
|
||||
const row = (w: WorkspaceView) => (
|
||||
const row = (w: WorkspaceView) => {
|
||||
const isActive = w.id === activeId;
|
||||
return (
|
||||
<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",
|
||||
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,
|
||||
color: isActive ? COLORS.textPrimary : COLORS.textSecondary,
|
||||
}}>
|
||||
<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>
|
||||
<span style={{ width: 10, height: 10, borderRadius: "50%", border: `2px solid ${STATE_COLOR[aggregate(w)]}`, boxSizing: "border-box", flex: "0 0 10px" }} />
|
||||
<span style={{ flex: 1, fontWeight: isActive ? 600 : 400, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{w.name}</span>
|
||||
{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 (
|
||||
<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 }}>
|
||||
<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={{
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8, width: "100%", height: 34, marginBottom: 16,
|
||||
background: COLORS.bgElevated, color: COLORS.textPrimary, border: `1px solid ${COLORS.borderStrong}`, borderRadius: 7,
|
||||
fontFamily: FONT.ui, fontSize: 13, fontWeight: 600,
|
||||
}}>
|
||||
<Plus size={15} />
|
||||
New workspace
|
||||
<span style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textMuted }}>⌘N</span>
|
||||
</button>
|
||||
|
||||
<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: "Inter", fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: "#8B97A6" }}>{g.name.toUpperCase()}</span>
|
||||
<span style={{ fontFamily: FONT.ui, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textSecondary }}>{g.name.toUpperCase()}</span>
|
||||
</div>
|
||||
{byGroup(g.id).map(row)}
|
||||
{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>
|
||||
))}
|
||||
{ungrouped.length > 0 && <div style={{ marginTop: 8 }}>{ungrouped.map(row)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function TerminalView({ surfaceId }: { surfaceId: string }) {
|
||||
|
||||
useEffect(() => {
|
||||
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 {
|
||||
term.loadAddon(new WebglAddon());
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
}: {
|
||||
active: WorkspaceView | null;
|
||||
eventsOpen: boolean;
|
||||
onToggleEvents: () => void;
|
||||
}) {
|
||||
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)" />
|
||||
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
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 "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user