From 36964c9f21e656418a1e29ebe31de4e3de484b76 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 10 Jun 2026 06:47:38 +0700 Subject: [PATCH] =?UTF-8?q?feat(app):=20UI=20parity=20with=20Pencil=20mock?= =?UTF-8?q?up=20=E2=80=94=20top=20bar,=20panel=20cards,=20sidebar/event-ce?= =?UTF-8?q?nter=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/package-lock.json | 30 +++++++++++++ app/package.json | 5 ++- app/src/App.tsx | 35 +++++++++------ app/src/CenterToolbar.tsx | 23 ++++++++++ app/src/EventCenter.tsx | 79 ++++++++++++++++++++++++++++------ app/src/LayoutEngine.tsx | 86 ++++++++++++++++++++++++++++--------- app/src/PresetPicker.tsx | 29 +++++++------ app/src/Sidebar.tsx | 88 +++++++++++++++++++++++++------------- app/src/TerminalView.tsx | 2 +- app/src/TopBar.tsx | 89 +++++++++++++++++++++++++++++++++++++++ app/src/main.tsx | 6 +++ app/src/styles.css | 39 +++++++++++++++++ app/src/theme.ts | 36 ++++++++++++++++ 13 files changed, 458 insertions(+), 89 deletions(-) create mode 100644 app/src/CenterToolbar.tsx create mode 100644 app/src/TopBar.tsx create mode 100644 app/src/styles.css create mode 100644 app/src/theme.ts diff --git a/app/package-lock.json b/app/package-lock.json index ce96560..3ed1d59 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index f916090..62e4672 100644 --- a/app/package.json +++ b/app/package.json @@ -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" }, diff --git a/app/src/App.tsx b/app/src/App.tsx index 0887d81..12809e4 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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>({}); const [feed, setFeed] = useState([]); const [wizard, setWizard] = useState(false); + const [eventsOpen, setEventsOpen] = useState(true); + const [focusedId, setFocusedId] = useState(null); const feedId = useRef(0); const activeRef = useRef(null); const wsRef = useRef([]); @@ -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 ( -
- setWizard(true)} /> -
- {active && ( -
- { if (active) void applyPreset(active.id, p, []); }} /> +
+ setEventsOpen((v) => !v)} /> +
+ setWizard(true)} /> +
+ {active && ( + { if (active) void applyPreset(active.id, p, []); }} /> + )} +
+ {active + ? + :
No workspace — create one to begin.
}
- )} -
- {active - ? - :
No workspace — create one to begin.
}
+ {eventsOpen && setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} />}
- setFeed([])} onSelect={(sid) => { void focusSurface(sid); }} /> {wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
); diff --git a/app/src/CenterToolbar.tsx b/app/src/CenterToolbar.tsx new file mode 100644 index 0000000..699e5ca --- /dev/null +++ b/app/src/CenterToolbar.tsx @@ -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 ( +
+ +
+
+ + Search scrollback + ⌘F +
+
+ ); +} diff --git a/app/src/EventCenter.tsx b/app/src/EventCenter.tsx index 2415252..d202c4d 100644 --- a/app/src/EventCenter.tsx +++ b/app/src/EventCenter.tsx @@ -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 = { done: "✓", wait: "⌛", error: "✕", work: "●", idle: "·", exit: "⏻" }; -const COLOR: Record = { done: "#3FB950", wait: "#F2B84B", error: "#F4544E", work: "#4C8DFF", idle: "#5A6573", exit: "#5A6573" }; +const ICON: Record = { + done: , wait: , error: , + work: , idle: , exit: , +}; +const COLOR: Record = { + 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("all"); + const shown = tab === "errors" ? feed.filter((e) => e.kind === "error") : feed; + return ( -
+
- Event Center - Mark all read + Event Center + Mark all read
-
- {feed.length === 0 &&
No events yet.
} - {feed.map((e) => ( + +
+ {TABS.map((t) => { + const on = t.id === tab; + return ( + + ); + })} +
+ +
+ {shown.length === 0 &&
No events yet.
} + {shown.map((e) => (
onSelect(e.surfaceId)} - style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: "1px solid #232A33", cursor: "pointer" }}> - {ICON[e.kind]} -
-
{e.workspace} · {e.agent}
-
{e.kind} {e.time}
+ style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer" }}> + {ICON[e.kind]} +
+
{e.workspace} · {e.agent}
+
{e.kind} {e.time}
))}
+ + {/* External notification channels — mocked until the daemon subscriber lands (M5). */} +
+ EXTERNAL NOTIFY +
+ {[ + { name: "Telegram", icon: }, + { name: "MAX", icon: }, + ].map((c) => ( +
+ {c.icon} + {c.name} + +
+ ))} +
+
); } diff --git a/app/src/LayoutEngine.tsx b/app/src/LayoutEngine.tsx index af8c6b5..b528116 100644 --- a/app/src/LayoutEngine.tsx +++ b/app/src/LayoutEngine.tsx @@ -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; states: Record; + surfaces: Record; + focusedId: string | null; + onFocus: (id: string) => void; } -export function LayoutEngine({ workspaceId, layout, running, states }: Props) { +/** Collapse an absolute cwd into a ~/ 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
Empty workspace — apply a preset to add panels.
; + return
Empty workspace — apply a preset to add panels.
; } - return ; + return ( +
+ +
+ ); } -function Node({ workspaceId, node, path, running, states }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record; states: Record }) { +function Node({ workspaceId, node, path, running, states, surfaces, focusedId, onFocus }: { + workspaceId: string; node: LayoutNode; path: number[]; + running: Record; states: Record; + surfaces: Record; 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) => ( +
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} +
+ ); + if (running[id] === false) { - return ( -
-
Process exited
- + return card( +
+
Process exited
+
); } - return ( -
-
- - {id} + + const spec = surfaces[id]?.spec; + const agent = spec?.agent_label ?? "shell"; + const state = states[id] ?? "idle"; + return card( + <> +
+ + {agent} + {spec?.cwd && {shortPath(spec.cwd)}} + + + {state} + +
-
+ ); } @@ -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); }}> - + ))}
@@ -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 && (
)} diff --git a/app/src/PresetPicker.tsx b/app/src/PresetPicker.tsx index 5b25e13..040642d 100644 --- a/app/src/PresetPicker.tsx +++ b/app/src/PresetPicker.tsx @@ -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 ( -
- {PRESETS.map((p) => ( - - ))} +
+ {PRESETS.map((p) => { + const on = p.id === selected; + return ( + + ); + })}
); } diff --git a/app/src/Sidebar.tsx b/app/src/Sidebar.tsx index 6f8794a..ce3954a 100644 --- a/app/src/Sidebar.tsx +++ b/app/src/Sidebar.tsx @@ -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 = { - 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>({}); 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) => ( -
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", - }}> - - {w.name} - {w.unread && } - {Object.keys(w.surfaces).length} -
- ); + const row = (w: WorkspaceView) => { + const isActive = w.id === activeId; + return ( +
onSelect(w.id)} + style={{ + 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, + }}> + + {w.name} + {w.unread && } + + {Object.keys(w.surfaces).length} + +
+ ); + }; return ( -
- - {groups.sort((a, b) => a.order - b.order).map((g) => ( -
-
- - {g.name.toUpperCase()} -
- {byGroup(g.id).map(row)} -
- ))} - {ungrouped.length > 0 &&
{ungrouped.map(row)}
} +
+ + +
+ {groups.sort((a, b) => a.order - b.order).map((g) => { + const open = !collapsed[g.id]; + return ( +
+
setCollapsed((c) => ({ ...c, [g.id]: open }))} + style={{ display: "flex", alignItems: "center", gap: 7, height: 24, padding: "0 4px", cursor: "pointer" }}> + {open ? : } + + {g.name.toUpperCase()} +
+ {open && byGroup(g.id).map(row)} +
+ ); + })} + {ungrouped.length > 0 &&
{ungrouped.map(row)}
} +
+ + {/* Daemon status footer — uptime is mocked until the daemon reports it. */} +
+ + spaceshd · live + + 3d 4h +
); } diff --git a/app/src/TerminalView.tsx b/app/src/TerminalView.tsx index f6172f8..fd070de 100644 --- a/app/src/TerminalView.tsx +++ b/app/src/TerminalView.tsx @@ -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 { diff --git a/app/src/TopBar.tsx b/app/src/TopBar.tsx new file mode 100644 index 0000000..6086f19 --- /dev/null +++ b/app/src/TopBar.tsx @@ -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 ( + + ); +} + +export function TopBar({ + active, eventsOpen, onToggleEvents, +}: { + active: WorkspaceView | null; + eventsOpen: boolean; + onToggleEvents: () => void; +}) { + return ( +
+ {/* macOS traffic-light spacer — real lights are drawn by the window chrome. */} +
+ + {/* Workspace breadcrumb */} +
+ + + {active?.name ?? "spacesh"} + + {active && ( + <> + / + + {describeLayout(active)} + + + )} +
+ +
+ + {/* Right cluster */} +
+ } onClick={onToggleEvents} active={eventsOpen} title="Toggle Event Center" /> + } title="Search (mock)" /> + } title="Notifications (mock)" /> + } title="Settings (mock)" /> + + +
+
+ ); +} diff --git a/app/src/main.tsx b/app/src/main.tsx index 5d115fc..4340d68 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -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( diff --git a/app/src/styles.css b/app/src/styles.css new file mode 100644 index 0000000..5851bf2 --- /dev/null +++ b/app/src/styles.css @@ -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; +} diff --git a/app/src/theme.ts b/app/src/theme.ts new file mode 100644 index 0000000..700b335 --- /dev/null +++ b/app/src/theme.ts @@ -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 = { + work: COLORS.stWork, + wait: COLORS.stWait, + done: COLORS.stDone, + error: COLORS.stError, + idle: COLORS.stIdle, + stopped: COLORS.stIdle, +};