feat: responsive web terminal with auto-reconnect, heartbeat, and local echo

Implements a resilient, responsive web terminal that survives network blips,
provides instant typing feedback, and restores scrollback on reconnect.

Backend changes:
- Add heartbeat tracking (15s ping interval, 60s idle timeout)
- Add message batching (16ms flush window) for efficient I/O
- Add termios echo detection and set_echo_state control messages
- Add graceful session_ended notification before close
- Add ping/pong protocol support

Frontend changes:
- Rewrite TerminalComponent with status bar, connection indicator,
  session-ended overlay, reconnect banner, and ResizeObserver
- Add useTerminalConnection hook with:
  - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts)
  - Heartbeat/ping-pong with latency tracking
  - Local echo for printable ASCII with server deduplication
  - Resize debounce (200ms) + throttle (500ms)
  - Scrollback serialization via xterm-addon-serialize
  - Ctrl+Shift+R manual reconnect shortcut
- Add WebSocket protocol types and encoding utilities
- Add xterm-addon-serialize dependency

Tests:
- 16 backend unit tests (TerminalSession + TerminalManager)
- 13 frontend hook tests (connection lifecycle, reconnect, resize,
  scrollback, callbacks)

Quality gates:
- Frontend typecheck: clean
- Frontend lint: clean
- Frontend tests: 48 passed
- Backend unit tests: 101 passed
- Backend ruff: clean

SDD artifacts: openspec/changes/responsive-terminal/
This commit is contained in:
2026-05-27 21:27:49 +02:00
parent 48fa858090
commit 6c8cfe9157
18 changed files with 2776 additions and 350 deletions
+164 -131
View File
@@ -2479,137 +2479,6 @@ a.nav-item,
Terminal Styles
============================================ */
.terminal-page {
display: flex;
flex-direction: column;
height: 100vh;
padding: var(--space-4);
gap: var(--space-4);
}
.terminal-page-header {
display: flex;
align-items: center;
gap: var(--space-4);
flex-shrink: 0;
}
.terminal-page-header h1 {
margin: 0;
}
.terminal-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-3) var(--space-4);
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
}
.terminal-status {
display: flex;
align-items: center;
gap: var(--space-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
}
.status-dot.connecting {
background: #f5f543;
animation: pulse 1.5s infinite;
}
.status-dot.connected {
background: #0dbc79;
}
.status-dot.disconnected,
.status-dot.error {
background: #cd3131;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-text {
font-size: 0.875rem;
color: #d4d4d4;
text-transform: capitalize;
}
.terminal-close {
padding: var(--space-1) var(--space-3);
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-error {
padding: var(--space-3) var(--space-4);
background: #cd3131;
color: white;
font-size: 0.875rem;
flex-shrink: 0;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: var(--space-2);
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
}
/* ============================================
Sessions Page Styles
============================================ */
@@ -2805,3 +2674,167 @@ a.nav-item,
background: var(--danger-light, #fee2e2);
color: var(--danger, #dc2626);
}
/* ============================================
Responsive Terminal — Updated
============================================ */
.terminal-wrapper {
position: relative;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: #1e1e1e;
}
.terminal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #2d2d2d;
border-bottom: 1px solid #3e3e3e;
flex-shrink: 0;
gap: 0.5rem;
}
.terminal-status {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.terminal-status .status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.terminal-status .status-text {
font-size: 0.8rem;
color: #d4d4d4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.terminal-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.terminal-close {
padding: 0.25rem 0.6rem;
background: transparent;
border: 1px solid #666;
border-radius: 6px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.8rem;
}
.terminal-close:hover {
background: #3e3e3e;
}
.terminal-container {
flex: 1;
min-height: 0;
padding: 0.25rem;
}
.terminal-container .xterm {
height: 100%;
}
.terminal-container .xterm-viewport {
background: #1e1e1e !important;
}
/* Terminal overlay for session ended */
.terminal-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: grid;
place-content: center;
z-index: 10;
}
.terminal-overlay-content {
background: #2d2d2d;
border: 1px solid #3e3e3e;
border-radius: 10px;
padding: 1.5rem;
text-align: center;
max-width: 400px;
color: #d4d4d4;
}
.terminal-overlay-content h3 {
margin: 0 0 0.5rem;
color: #f14c4c;
}
.terminal-overlay-content p {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.terminal-overlay-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
}
/* Reconnect banner */
.terminal-reconnect-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
background: #3e3e3e;
color: #f5f543;
font-size: 0.8rem;
flex-shrink: 0;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.75s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive terminal */
@media (max-width: 767px) {
.terminal-page {
padding: var(--space-2);
gap: var(--space-2);
}
.terminal-page-header h1 {
font-size: 1.25rem;
}
.terminal-overlay-content {
margin: 0 1rem;
}
}