feat(ui): redesign host console as cockpit surface (#701)

* feat(ui): redesign host console as cockpit surface

Rework the Console view into the cockpit language introduced in #699.
The page is now a PageMasthead strip plus a terminal well plus a
floating chip strip.

The masthead shows connection state (`Connected`, `Reconnecting`,
`Disconnected`) with a cyan rail, a pulsing status dot while the
session is live, an italic state word, and a tracked-mono kicker of
`HOST CONSOLE · {node}`. The right side surfaces three metadata tiles:
shell, current viewport dimensions, and session uptime. When the
console was opened from a stack, a small back link sits beside the
state word so operators can return to the stack editor without
leaving the cockpit.

The vestigial Close Console button in the header is gone. The
floating chip strip replaces it with four controls that are more
useful in context: Copy (current selection), Clear (scrollback),
Download (full scrollback via SerializeAddon), and Reconnect (close
and reopen the WebSocket in place).

* docs(host-console): refresh screenshots for cockpit redesign

Adds masthead and chip-strip close-up screenshots to the Host Console doc
and refreshes the overview screenshot to match the new cockpit surface.
This commit is contained in:
Anso
2026-04-19 17:14:25 -04:00
committed by GitHub
parent b95442c8f7
commit 490c89c049
5 changed files with 197 additions and 54 deletions
+16 -7
View File
@@ -27,15 +27,24 @@ The Host Console gives you a real terminal session on the Sencho host, streamed
Click **Console** in the top navigation bar. The session starts immediately in the `COMPOSE_DIR` root. If a stack is selected in the sidebar, the terminal opens directly inside that stack's directory instead.
The header bar shows:
## Cockpit layout
- **Host Console** label with a terminal icon
- The active **node name** (e.g. "Local" or a remote node)
- The **stack name** in parentheses, if one is selected
- A green **Connected** badge while the WebSocket session is active
- A **Close Console** button on the right to end the session
The Console page is a vertical stack of two surfaces:
Clicking **Close Console** terminates the shell process on the host and disconnects the WebSocket.
<Frame>
<img src="/images/host-console/host-console-masthead.png" alt="Masthead with cyan rail, Connected state, and shell, viewport, and session uptime metadata" />
</Frame>
| Surface | What it shows |
|---------|---------------|
| **Masthead** | Connection state (`Connected`, `Reconnecting`, or `Disconnected`) with a live status dot that pulses cyan while the session is active. The kicker reads `HOST CONSOLE · {node}`. When the console was opened from a stack, a small `← {stack-name}` link sits beside the state word so you can return to the stack editor. |
| **Metadata** | Right side of the masthead: the active **shell** (e.g. `BASH`), the current **viewport** (`{cols}×{rows}` as reported by the fit addon), and **session uptime** since the shell opened. |
| **Terminal well** | The interactive xterm session, sized to fill the remaining height. |
| **Chip strip** | Floating bottom-right controls: **Copy** (copies the current selection), **Clear** (wipes the visible scrollback without killing the shell), **Download** (exports the full scrollback as a timestamped `.txt`), and **Reconnect** (closes and reopens the WebSocket without leaving the page). |
<Frame>
<img src="/images/host-console/host-console-chip-strip.png" alt="Floating chip strip with Copy, Clear, Download, and Reconnect buttons" />
</Frame>
## Shell type
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 62 KiB

+181 -47
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal as TerminalIcon, X } from 'lucide-react';
import { SerializeAddon } from '@xterm/addon-serialize';
import { ArrowLeft, Copy, Trash2, Download, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';
import { PageMasthead, type MastheadTone } from './ui/PageMasthead';
import '@xterm/xterm/css/xterm.css';
import { useNodes } from '@/context/NodeContext';
@@ -11,6 +13,9 @@ interface HostConsoleProps {
onClose: () => void;
}
// Window considered "live" for the masthead pulsing dot.
const LIVE_WINDOW_MS = 5_000;
/** Build the xterm theme from CSS custom properties (resolved once per call). */
function getTerminalTheme() {
const s = getComputedStyle(document.documentElement);
@@ -23,25 +28,41 @@ function getTerminalTheme() {
};
}
function formatUptime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
if (h > 0) return `${h}H ${m.toString().padStart(2, '0')}M`;
return `${m}:${s.toString().padStart(2, '0')} UP`;
}
type ConnState = 'reconnecting' | 'connected' | 'disconnected';
export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
const { activeNode } = useNodes();
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const serializeRef = useRef<SerializeAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const cleanup = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
setIsConnected(false);
const [connState, setConnState] = useState<ConnState>('reconnecting');
const [lastActivityAt, setLastActivityAt] = useState<number | null>(null);
const [dims, setDims] = useState<{ cols: number; rows: number }>({ cols: 0, rows: 0 });
const [mountedAt, setMountedAt] = useState<number | null>(null);
const [tick, setTick] = useState(0);
const [reconnectNonce, setReconnectNonce] = useState(0);
useEffect(() => {
const run = () => {
const now = Date.now();
setTick(now);
setMountedAt(prev => prev ?? now);
};
const init = setTimeout(run, 0);
const id = setInterval(run, 1000);
return () => { clearTimeout(init); clearInterval(id); };
}, []);
useEffect(() => {
@@ -58,15 +79,21 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
});
const fitAddon = new FitAddon();
const serializeAddon = new SerializeAddon();
term.loadAddon(fitAddon);
term.loadAddon(serializeAddon);
term.open(container);
xtermRef.current = term;
fitAddonRef.current = fitAddon;
serializeRef.current = serializeAddon;
requestAnimationFrame(() => {
try {
if (mounted) fitAddon.fit();
if (mounted) {
fitAddon.fit();
setDims({ cols: term.cols, rows: term.rows });
}
} catch {
// Ignore fit errors during initial render
}
@@ -83,12 +110,16 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
ws.onopen = () => {
if (!mounted) return;
setIsConnected(true);
setConnState('connected');
setLastActivityAt(Date.now());
term.focus();
setTimeout(() => {
try {
if (mounted) fitAddon.fit();
if (mounted) {
fitAddon.fit();
setDims({ cols: term.cols, rows: term.rows });
}
} catch {
// Ignore
}
@@ -106,18 +137,19 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
if (!mounted) return;
const text = typeof event.data === 'string' ? event.data : event.data.toString();
term.write(text);
setLastActivityAt(Date.now());
};
ws.onerror = () => {
if (!mounted) return;
term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
setIsConnected(false);
setConnState('disconnected');
};
ws.onclose = () => {
if (!mounted) return;
term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
setIsConnected(false);
setConnState('disconnected');
};
term.onData((data) => {
@@ -136,6 +168,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
if (!mounted || !fitAddonRef.current || !wsRef.current) return;
try {
fitAddonRef.current.fit();
setDims({ cols: term.cols, rows: term.rows });
} catch {
return;
}
@@ -155,42 +188,143 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) {
mounted = false;
resizeObserver.disconnect();
clearTimeout(resizeTimeout);
cleanup();
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
serializeRef.current = null;
};
}, [stackName, cleanup]);
}, [stackName, reconnectNonce]);
const handleCopy = useCallback(() => {
const term = xtermRef.current;
if (!term) return;
const selection = term.getSelection();
if (!selection) return;
navigator.clipboard?.writeText(selection).catch(() => { /* ignore */ });
}, []);
const handleClear = useCallback(() => {
xtermRef.current?.clear();
}, []);
const handleDownload = useCallback(() => {
const content = serializeRef.current?.serialize();
if (!content) return;
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sencho-console-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
const handleReconnect = useCallback(() => {
setConnState('reconnecting');
setReconnectNonce(n => n + 1);
}, []);
const isLive = connState === 'connected' && lastActivityAt != null && (tick - lastActivityAt) < LIVE_WINDOW_MS;
const tone: MastheadTone = connState === 'disconnected'
? 'error'
: connState === 'reconnecting'
? 'warn'
: isLive ? 'live' : 'idle';
const stateWord = connState === 'disconnected'
? 'Disconnected'
: connState === 'reconnecting' ? 'Reconnecting' : 'Connected';
const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL';
const kicker = `HOST CONSOLE · ${nodeLabel}`;
const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—';
const viewport = dims.cols > 0 && dims.rows > 0 ? `${dims.cols}×${dims.rows}` : '—';
const metadata = [
{ label: 'SHELL', value: 'BASH', tone: 'subtitle' as const },
{ label: 'VIEWPORT', value: viewport, tone: 'subtitle' as const },
{ label: 'SESSION', value: uptime, tone: 'subtitle' as const },
];
return (
<div className="flex flex-col h-full w-full rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel overflow-hidden transition-colors hover:border-t-card-border-hover">
<div className="flex items-center justify-between px-4 py-2 border-b border-card-border bg-muted/40 shrink-0">
<div className="flex items-center gap-2 font-medium">
<TerminalIcon className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
<span>Host Console</span>
{activeNode && (
<span className="text-muted-foreground font-normal text-sm">
- {activeNode.name}
</span>
)}
{stackName && (
<span className="text-muted-foreground font-normal text-sm">
({stackName})
</span>
)}
{isConnected && (
<span className="ml-2 text-xs bg-success-muted text-success px-2 py-0.5 rounded-full border border-success/20">
Connected
</span>
)}
</div>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 gap-1.5 text-muted-foreground hover:text-foreground">
<X className="w-4 h-4" strokeWidth={1.5} />
Close Console
</Button>
</div>
<div className="relative flex h-full w-full flex-col bg-background text-foreground">
<PageMasthead
kicker={kicker}
state={stateWord}
tone={tone}
pulsing={tone === 'live' || tone === 'warn'}
metadata={metadata}
>
{stackName ? (
<button
type="button"
onClick={onClose}
className="inline-flex items-center gap-1.5 rounded-md border border-card-border bg-card px-2 py-1 font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle shadow-btn-glow transition-colors hover:text-stat-value focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50"
>
<ArrowLeft className="h-3 w-3" strokeWidth={1.5} />
<span className="max-w-[160px] truncate">{stackName}</span>
</button>
) : null}
</PageMasthead>
<div
className="flex-1 p-2 min-h-0 relative shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]"
className="relative min-h-0 flex-1 p-2 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]"
style={{ backgroundColor: 'var(--terminal-bg)', overflow: 'hidden' }}
>
<div ref={terminalRef} style={{ width: '100%', height: '100%' }} />
<div className="pointer-events-none absolute bottom-4 right-6 z-10 flex items-center gap-2">
<div className="pointer-events-auto flex items-center gap-1 rounded-md border border-glass-border bg-popover/95 p-1 shadow-md backdrop-blur-[10px] backdrop-saturate-[1.15]">
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value"
aria-label="Copy selection"
>
<Copy className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Copy</span>
</Button>
<div className="h-5 w-px bg-card-border" />
<Button
variant="ghost"
size="sm"
onClick={handleClear}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value"
aria-label="Clear terminal"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Clear</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDownload}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value"
aria-label="Download scrollback"
>
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Download</span>
</Button>
<div className="h-5 w-px bg-card-border" />
<Button
variant="ghost"
size="sm"
onClick={handleReconnect}
className="h-7 gap-2 px-2 text-xs text-stat-subtitle hover:text-stat-value"
aria-label="Reconnect session"
>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">Reconnect</span>
</Button>
</div>
</div>
</div>
</div>
);