docs: SP1+SP3+SP4 design — observability, scrollback search, panel zoom
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
# spacesh SP1 + SP3 + SP4 — Design
|
||||
|
||||
**Status:** Design (approved for plan authoring)
|
||||
**Date:** 2026-06-10
|
||||
**Scope:** Three independent sub-projects from the "frontend mocks → backend" decomposition, batched into one design doc. Each section is independently implementable and gets its own task group in the plan.
|
||||
|
||||
- **SP1 — Daemon observability:** real `spaceshd · live · <uptime>` sidebar footer (replaces the hardcoded mock).
|
||||
- **SP3 — Scrollback search:** working `⌘F` find-in-terminal for the focused panel (replaces the mock pill).
|
||||
- **SP4 — Panel zoom:** maximize one panel to fill the grid, persisted across restarts (replaces the mock zoom icon).
|
||||
|
||||
## Architecture invariants honored
|
||||
- **Daemon is the single source of truth.** SP1 health and SP4 zoom state live in `spaceshd`; the GUI mirrors them. SP3 search is a *display* concern (operates on the xterm.js buffer the user sees) and deliberately stays client-side.
|
||||
- **One socket, one protocol.** SP1/SP4 add new `Cmd` variants in `spacesh-proto`; no new transport.
|
||||
- **Display (xterm.js) vs analysis (alacritty grid) split.** SP3 uses xterm.js's own buffer + search addon — searching what is displayed is a display task. The daemon's authoritative grid is untouched; a daemon-side grid search can be added later as a separate command if headless/CLI/cross-panel search is ever needed.
|
||||
|
||||
---
|
||||
|
||||
## SP1 — Daemon observability
|
||||
|
||||
### Problem
|
||||
The sidebar footer shows a hardcoded `spaceshd · live` / `3d 4h`. There is no command to learn the daemon's version or how long it has been running.
|
||||
|
||||
### Proto
|
||||
New command (in `spacesh-proto/src/message.rs`):
|
||||
- `Cmd::Health` → response data `{ "version": String, "pid": u32, "started_at_ms": u64 }`.
|
||||
|
||||
`version` is the daemon crate version; `pid` is the daemon process id; `started_at_ms` is the unix-epoch-millis timestamp captured when the daemon began serving. Uptime is derived GUI-side (`now - started_at_ms`) so it can tick without polling.
|
||||
|
||||
### Daemon
|
||||
- `serve()` captures `started_at_ms` once (`SystemTime::now()` → millis) and threads it into `router`, which holds it for the lifetime of the process.
|
||||
- `handle_request` gains a `Cmd::Health` arm returning `{ version: env!("CARGO_PKG_VERSION").to_string(), pid: std::process::id(), started_at_ms }`.
|
||||
|
||||
### GUI
|
||||
- `socketBridge.ts`: `getHealth(): Promise<{ version: string; pid: number; started_at_ms: number }>`.
|
||||
- `App.tsx`: fetch health on connect (and on reconnect); track a `connected` boolean (true after a successful `getStatusFull`/`getHealth`, false on `spacesh:disconnected`). Pass `health` + `connected` to `Sidebar`.
|
||||
- `Sidebar.tsx` footer: the `live` dot is green when `connected`, grey otherwise; the label reads `spaceshd · live` when connected, `spaceshd · offline` otherwise; the right-hand value shows uptime formatted from `started_at_ms` (e.g. `3d 4h`, `5h 12m`, `47s`), recomputed on a ~30s interval; the daemon version is shown via the element's `title` tooltip. When `health` is null (not yet fetched / offline) the uptime slot is blank.
|
||||
|
||||
### Edge cases
|
||||
- Health requested before connect → the bridge call rejects; GUI keeps `connected=false` and shows offline.
|
||||
- Uptime formatting: `<1m` → `Ns`; `<1h` → `Nm`; `<1d` → `Nh Mm`; else `Nd Mh`.
|
||||
|
||||
### Tests
|
||||
- proto: `Cmd::Health` serde round-trip.
|
||||
- daemon: integration test — `Cmd::Health` returns a non-empty `version`, a plausible `pid`, and a `started_at_ms` ≤ now.
|
||||
|
||||
---
|
||||
|
||||
## SP3 — Scrollback search (xterm addon-search)
|
||||
|
||||
### Problem
|
||||
The center toolbar's `Search scrollback ⌘F` pill is a mock. Users need to find text in a panel's output.
|
||||
|
||||
### Approach
|
||||
Use xterm.js's official `@xterm/addon-search` on the **focused** panel's terminal. Searching the displayed buffer (including its scrollback) is a display concern, so this is entirely client-side — no daemon changes. The xterm scrollback is raised so "full scrollback" is meaningful.
|
||||
|
||||
### Components
|
||||
- **Dependency:** `@xterm/addon-search`.
|
||||
- **`TerminalView.tsx`:** construct the `Terminal` with `scrollback: 10000`; load a `SearchAddon`; register it in a module-level `Map<string, SearchAddon>` keyed by `surfaceId` on mount and delete the entry on unmount. This registry lets the search bar reach the focused panel's addon without prop-drilling through the layout tree.
|
||||
- **`SearchBar.tsx` (new):** a small overlay input. Given the focused `surfaceId`, it looks up the addon in the registry and drives it:
|
||||
- typing / Enter → `addon.findNext(term, opts)`; Shift+Enter → `addon.findPrevious(term, opts)`.
|
||||
- match count + current index from `addon.onDidChangeResults(({ resultIndex, resultCount }) => …)` rendered as `i/N` (or `0/0`).
|
||||
- `opts` sets decoration colors (match / active-match) from the theme (`COLORS.stWait` active, a dim variant for others).
|
||||
- `Esc` closes the bar and calls `addon.clearDecorations()`.
|
||||
- **`App.tsx`:** owns `searchOpen` state and the focused surface (`effectiveFocus`, already tracked). A global `keydown` handler opens the bar on `⌘F`/`Ctrl+F` (preventing the browser default) when a workspace is active; clicking the toolbar pill also opens it. The bar targets `effectiveFocus`; if focus changes while open, the bar re-targets and clears the previous panel's decorations.
|
||||
|
||||
### Edge cases
|
||||
- No focused panel / focused panel has no registered addon (e.g. stopped) → bar shows `0/0` and is a no-op.
|
||||
- Empty query → clear decorations, `0/0`.
|
||||
- Closing the bar always clears decorations on the targeted addon.
|
||||
|
||||
### Tests
|
||||
- Frontend type-check + build (`npm run build`). No headless smoke for xterm search (xterm needs a real DOM/canvas; covered by manual testing). Manual scenario added to `RUNNING.md`.
|
||||
|
||||
---
|
||||
|
||||
## SP4 — Panel zoom (persisted)
|
||||
|
||||
### Problem
|
||||
The panel-header zoom icon (`maximize-2`) is a mock. Users want to maximize one panel to fill the grid, and have that survive restarts.
|
||||
|
||||
### Proto
|
||||
- `Workspace` (persisted) and `WorkspaceView` (status) gain `zoomed: Option<SurfaceId>` (at most one zoomed panel per workspace; `None` = normal grid).
|
||||
- New `Cmd::SetZoom { workspace_id: WorkspaceId, surface_id: Option<SurfaceId> }` — `Some(sid)` zooms that panel, `None` clears zoom.
|
||||
|
||||
### Daemon
|
||||
- The registry stores `zoomed` on each workspace; it is included when building `WorkspaceView` and persisted in `state.json`.
|
||||
- `Cmd::SetZoom` handler: if `Some(sid)`, validate the surface belongs to the workspace (else `NOT_FOUND`); set `zoomed`; if `None`, clear it. Persist (`mark_dirty`) and broadcast `Evt::WorkspaceChanged { workspace: view }` so all clients re-render. Respond `ok`.
|
||||
- **Auto-clear:** when a surface is removed (`remove_surface`, used by Close/ApplyPreset/CloseWorkspace) or a workspace's structure changes such that the zoomed surface no longer exists, clear `zoomed` if it pointed at the removed surface. (Applying a new preset replaces surfaces, so it must also clear zoom for that workspace.)
|
||||
|
||||
### GUI
|
||||
- `WorkspaceView` type gains `zoomed: string | null`.
|
||||
- `socketBridge.ts`: `setZoom(workspaceId: string, surfaceId: string | null)`.
|
||||
- `LayoutEngine.tsx`: accept the workspace's `zoomed`. When `zoomed` is set and that surface is present & running, render ONLY that leaf at full size (the split tree and splitters are bypassed). Otherwise render the normal tree.
|
||||
- Panel header zoom control: when not zoomed, `Maximize2` → `setZoom(workspaceId, sid)`; when this panel is the zoomed one, show `Minimize2` → `setZoom(workspaceId, null)`. The control stops propagation so it doesn't also trigger focus.
|
||||
- `App.tsx` passes `active.zoomed` into `LayoutEngine`.
|
||||
|
||||
### Edge cases
|
||||
- Zoomed surface stopped (process exited but still in tree): still render it zoomed, showing the "Process exited / Restart" state full-screen (consistent with normal panes). Auto-clear only on actual removal from the tree.
|
||||
- Zoom set on a workspace, then a different workspace selected: zoom is per-workspace, so switching away and back preserves it.
|
||||
|
||||
### Tests
|
||||
- proto: `Cmd::SetZoom` serde round-trip; `Workspace`/`WorkspaceView` round-trip with `zoomed` set.
|
||||
- daemon: integration — SetZoom(Some) reflects in `status` (`zoomed == sid`) and broadcasts WorkspaceChanged; SetZoom(None) clears; closing the zoomed surface auto-clears `zoomed`.
|
||||
- frontend: build clean; manual scenario in `RUNNING.md`.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
- Daemon-side grid search / CLI search (SP3 stays client-side).
|
||||
- Multiple simultaneous zoomed panels (one per workspace only).
|
||||
- Health metrics beyond version/pid/uptime (no CPU/mem/session counts).
|
||||
- The top-bar `search`/`settings`/account menu and Telegram/MAX channels remain mocked (other sub-projects / out of v1).
|
||||
|
||||
## Build order within the plan
|
||||
SP1 (smallest, proto+daemon+sidebar) → SP4 (proto+daemon+layout) → SP3 (frontend-only). Each is independent; this order front-loads the cheap daemon work and ends with the isolated frontend feature.
|
||||
Reference in New Issue
Block a user