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
+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>
);