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:
@@ -0,0 +1,153 @@
|
||||
# Spec: Responsive Web Terminal
|
||||
|
||||
## Overview
|
||||
|
||||
Upgrade the web terminal from a fragile single-shot WebSocket into a resilient, responsive terminal that survives network blips, provides instant typing feedback, restores scrollback on reconnect, and gives users clear visibility into connection health.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### AC-1: WebSocket Auto-Reconnection
|
||||
|
||||
**GIVEN** a terminal is connected to a running instance
|
||||
**WHEN** the WebSocket disconnects (network hiccup, server restart, proxy timeout)
|
||||
**THEN** the client automatically reconnects with exponential backoff (1s, 2s, 4s, 8s, max 30s)
|
||||
**AND** the user sees a reconnection indicator showing attempt count and next retry time
|
||||
**AND** after successful reconnection, the terminal scrollback is restored
|
||||
**AND** a new `docker exec` session is spawned transparently
|
||||
|
||||
**Test:** Disconnect WiFi for 5s, verify reconnect and scrollback intact.
|
||||
|
||||
### AC-2: Heartbeat / Ping-Pong Protocol
|
||||
|
||||
**GIVEN** a terminal connection is established
|
||||
**WHEN** 15 seconds pass with no data exchanged
|
||||
**THEN** the client sends a `ping` control message
|
||||
**AND** the server responds with a `pong` within 5 seconds
|
||||
**AND** if no `pong` is received within 5 seconds, the client treats the connection as dead and begins reconnection
|
||||
**AND** the server closes WebSockets that have not sent any message (including ping) for 60 seconds
|
||||
|
||||
**Test:** Block server responses with firewall rule, verify connection declared dead within 20s and reconnection starts.
|
||||
|
||||
### AC-3: Local Echo for Reduced Typing Latency
|
||||
|
||||
**GIVEN** the terminal is in a normal interactive shell
|
||||
**WHEN** the user types printable ASCII characters
|
||||
**THEN** they appear on screen immediately (local echo) without waiting for the server round-trip
|
||||
**AND** when the server sends the authoritative echo back, the client reconciles (deduplicates)
|
||||
**AND** when the server sends a `set_echo_state` control message with `enabled: false` (e.g., for password prompts), local echo is disabled
|
||||
**AND** when `set_echo_state` with `enabled: true` is received, local echo is re-enabled
|
||||
|
||||
**Test:** Type `echo hello` — characters appear instantly. Run `sudo` — local echo stops during password prompt.
|
||||
|
||||
### AC-4: Resize Debouncing
|
||||
|
||||
**GIVEN** the user is resizing the browser window
|
||||
**WHEN** the terminal dimensions change
|
||||
**THEN** resize events are debounced by 200ms
|
||||
**AND** only the final dimensions after the user stops resizing are sent to the server
|
||||
**AND** at most one resize message is sent per 500ms
|
||||
|
||||
**Test:** Rapidly resize window 10 times in 1s — verify only 1-2 resize messages sent.
|
||||
|
||||
### AC-5: Scrollback Serialization and Restoration
|
||||
|
||||
**GIVEN** a terminal has been in use with output history
|
||||
**WHEN** a disconnect occurs
|
||||
**THEN** the client serializes the terminal buffer (via xterm-addon-serialize, capped at 10,000 lines)
|
||||
**AND** stores it in `sessionStorage` under key `hq-terminal-{instance_id}`
|
||||
**AND** on successful reconnection, the serialized content is written back into the terminal before new output
|
||||
**AND** a visual divider line indicates "--- Reconnected ---" between old and new output
|
||||
|
||||
**Test:** Run `ls -la` 50 times, disconnect, reconnect — verify all output visible with divider.
|
||||
|
||||
### AC-6: Connection Quality Indicator
|
||||
|
||||
**GIVEN** the terminal is connected
|
||||
**THEN** the status bar shows:
|
||||
- Green dot + "Connected" when healthy (latency < 100ms)
|
||||
- Yellow dot + "Slow" when latency is 100-500ms
|
||||
- Red dot + "Reconnecting (N)" during reconnection attempts
|
||||
- Gray dot + "Disconnected" when permanently disconnected (max retries exceeded)
|
||||
**AND** hovering the status dot shows a tooltip with round-trip latency (ms) and jitter
|
||||
**AND** the indicator updates every 5 seconds
|
||||
|
||||
**Test:** Use network throttling in dev tools to simulate slow connection, verify indicator changes.
|
||||
|
||||
### AC-7: Graceful Container Exit
|
||||
|
||||
**GIVEN** a terminal session is active
|
||||
**WHEN** the container process exits (shell terminates, container stops)
|
||||
**THEN** the terminal shows a clear message: "Session ended. The container process has exited."
|
||||
**AND** a "Reconnect" button is shown to spawn a new session
|
||||
**AND** a "Go Back" button navigates to the previous page
|
||||
**AND** the WebSocket closes with code 1000 (normal) instead of an error code
|
||||
|
||||
**Test:** Run `exit` in the terminal, verify friendly message and buttons appear.
|
||||
|
||||
### AC-8: Backend Message Batching
|
||||
|
||||
**GIVEN** a container process is producing output rapidly
|
||||
**WHEN** the backend PTY produces multiple small reads within a single event loop tick
|
||||
**THEN** the backend batches them into a single WebSocket binary frame
|
||||
**AND** batching does not add more than 16ms of latency
|
||||
**AND** the batch is flushed immediately when no new data is available
|
||||
|
||||
**Test:** Run `yes | head -n 10000` and measure WebSocket frame count vs. current implementation.
|
||||
|
||||
### AC-9: Keyboard Shortcut for Reconnect
|
||||
|
||||
**GIVEN** the terminal is disconnected
|
||||
**WHEN** the user presses `Ctrl+Shift+R`
|
||||
**THEN** an immediate reconnection attempt is triggered (bypassing backoff)
|
||||
|
||||
**Test:** Disconnect terminal, press `Ctrl+Shift+R`, verify immediate reconnect attempt.
|
||||
|
||||
## API / Protocol Changes
|
||||
|
||||
### WebSocket Control Messages (JSON)
|
||||
|
||||
```typescript
|
||||
// Client → Server
|
||||
type ClientMessage =
|
||||
| { type: "ping"; id: number }
|
||||
| { type: "pong"; id: number }
|
||||
| { type: "resize"; cols: number; rows: number }
|
||||
| { type: "input"; data: string } // base64-encoded bytes
|
||||
|
||||
// Server → Client
|
||||
type ServerMessage =
|
||||
| { type: "pong"; id: number }
|
||||
| { type: "status"; status: "connected" | "reconnected" }
|
||||
| { type: "set_echo_state"; enabled: boolean }
|
||||
| { type: "session_ended"; reason: "process_exit" | "container_stop" | "timeout" }
|
||||
```
|
||||
|
||||
### Binary Frames
|
||||
|
||||
- Raw terminal output from server → client: binary WebSocket frame (no wrapping)
|
||||
- Raw terminal input from client → server: binary WebSocket frame (no wrapping)
|
||||
- Control messages (resize, ping, etc.): text JSON frames
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Frontend
|
||||
- `xterm-addon-serialize` — scrollback serialization
|
||||
- `xterm-addon-webgl` (optional) — GPU rendering for smoother feel
|
||||
|
||||
### Backend
|
||||
- No new Python dependencies required
|
||||
- Uses existing `asyncio`, `fastapi`, `websockets`
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- **Latency:** Perceived typing latency < 50ms for local echo characters
|
||||
- **Reconnection time:** < 5 seconds for transient disconnects
|
||||
- **Memory:** Scrollback serialization capped at 10,000 lines (~2-5MB worst case)
|
||||
- **Server load:** Heartbeat interval 15s; max 4 pings/minute per terminal
|
||||
- **Browser support:** Chrome 90+, Firefox 88+, Safari 14+ (all support required WebSocket features)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we add a "full screen" button to the terminal chrome? (Nice-to-have, out of scope for this change)
|
||||
2. Should scrollback be persisted across full page reloads (via `localStorage`) or only during session (`sessionStorage`)? — **Decision:** Use `sessionStorage` to avoid leaking sensitive data.
|
||||
3. Should the server echo-state detection be automatic (TIOCGWINSZ / stty inspection) or manual (client tells server)? — **Decision:** Server detects via PTY state inspection; sends `set_echo_state` to client.
|
||||
Reference in New Issue
Block a user