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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user