feat: implement persistent terminal sessions

- Terminal sessions now persist across WebSocket disconnections
- Added circular output buffer (10KB) for replay on reconnect
- Added idle timeout cleanup (30 minutes)
- Added reset functionality via WebSocket message and HTTP endpoint
- Concurrent connections close old WebSocket when new one connects
- Frontend: Added reset button with confirmation dialog
- Frontend: Handle resetting status and reconnection

Refs: persistent-terminal-sessions
This commit is contained in:
2026-05-24 12:35:52 +00:00
parent ecd3ba5918
commit d1c187ab16
5 changed files with 462 additions and 76 deletions
+48 -4
View File
@@ -43,9 +43,10 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalReadyRef = useRef(onTerminalReady);
onTerminalReadyRef.current = onTerminalReady;
const [status, setStatus] = useState<
"connecting" | "connected" | "disconnected" | "error"
"connecting" | "connected" | "disconnected" | "error" | "resetting"
>("connecting");
const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => {
@@ -87,8 +88,13 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
} else if (typeof event.data === "string") {
try {
const msg = JSON.parse(event.data);
if (msg.type === "status" && msg.status === "connected") {
setStatus("connected");
if (msg.type === "status") {
if (msg.status === "connected") {
setStatus("connected");
setError(null);
} else if (msg.status === "resetting") {
setStatus("resetting");
}
}
} catch {
termRef.current?.write(event.data);
@@ -369,7 +375,9 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
aria-label={`Terminal status: ${status}`}
/>
<span className="status-text">
{reconnectAttemptsRef.current > 0 && status !== "connected"
{status === "resetting"
? "Resetting..."
: reconnectAttemptsRef.current > 0 && status !== "connected"
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
: status}
</span>
@@ -412,6 +420,14 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
>
A+
</button>
<button
className="terminal-header-button"
onClick={() => setShowResetConfirm(true)}
type="button"
aria-label="Reset terminal"
>
Reset
</button>
{onClose && (
<button className="terminal-close" onClick={onClose} type="button">
Close
@@ -419,6 +435,34 @@ export const TerminalComponent: React.FC<TerminalProps> = ({
)}
</div>
</div>
{showResetConfirm && (
<div className="terminal-reset-confirm">
<div className="terminal-reset-confirm-content">
<p>Reset terminal? This will kill the current shell session and start fresh.</p>
<div className="terminal-reset-confirm-buttons">
<button
className="terminal-reset-confirm-button cancel"
onClick={() => setShowResetConfirm(false)}
type="button"
>
Cancel
</button>
<button
className="terminal-reset-confirm-button confirm"
onClick={() => {
setShowResetConfirm(false);
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "reset" }));
}
}}
type="button"
>
Reset
</button>
</div>
</div>
</div>
)}
{error && (
<div className="terminal-error">
{error}
+51
View File
@@ -3388,6 +3388,57 @@ a.nav-item,
font-size: 0.75rem;
}
.terminal-reset-confirm {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.terminal-reset-confirm-content {
background: var(--bg-surface);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: var(--space-4);
max-width: 400px;
text-align: center;
}
.terminal-reset-confirm-content p {
margin: 0 0 var(--space-4) 0;
color: var(--text-primary);
}
.terminal-reset-confirm-buttons {
display: flex;
gap: var(--space-2);
justify-content: center;
}
.terminal-reset-confirm-button {
padding: var(--space-2) var(--space-4);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.terminal-reset-confirm-button.cancel {
background: var(--bg-elevated);
color: var(--text-primary);
}
.terminal-reset-confirm-button.confirm {
background: #cd3131;
color: white;
}
.terminal-hidden-input {
position: fixed;
left: -9999px;