feat(terminal): switch terminal transport to pure websocket with fallback (#762)

* feat(terminal): add resumable websocket transport

Unify terminal input and stream traffic on `/api/terminal/ws` with a v2 control-frame protocol and advertised transport capabilities.

Buffer recent PTY output on the server so rebinding clients can replay missed chunks after reconnects or startup races, while keeping SSE as a fallback stream path.

Update the web terminal client and store to negotiate the new transport, track tab lifecycle, and avoid reopening exited sessions when restoring tabs.

* fix(terminal): retry rehydrated websocket reconnects

* fix(terminal): keep reconnect retries silent

* fix(terminal): wire ws stream output and replay in runtime

* fix(web): enable ws proxy for /api in dev

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Yifan
2026-04-01 19:31:21 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent d68bec491c
commit 2b70ec6f3a
14 changed files with 1012 additions and 462 deletions
@@ -54,7 +54,7 @@ const STREAM_OPTIONS = {
const REHYDRATED_STREAM_OPTIONS = {
retry: {
maxRetries: 0,
...STREAM_OPTIONS.retry,
initialDelayMs: 200,
maxDelayMs: 500,
},
@@ -106,6 +106,7 @@ export const TerminalView: React.FC = () => {
const setActiveTab = terminalStore.setActiveTab;
const closeTab = terminalStore.closeTab;
const setTabSessionId = terminalStore.setTabSessionId;
const setTabLifecycle = terminalStore.setTabLifecycle;
const setConnecting = terminalStore.setConnecting;
const appendToBuffer = terminalStore.appendToBuffer;
@@ -132,11 +133,13 @@ export const TerminalView: React.FC = () => {
}, [directoryTerminalState, activeTabId]);
const terminalSessionId = activeTab?.terminalSessionId ?? null;
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
const bufferChunks = activeTab?.bufferChunks ?? [];
const isConnecting = activeTab?.isConnecting ?? false;
const [connectionError, setConnectionError] = React.useState<string | null>(null);
const [isFatalError, setIsFatalError] = React.useState(false);
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
@@ -234,6 +237,7 @@ export const TerminalView: React.FC = () => {
streamCleanupRef.current?.();
streamCleanupRef.current = null;
activeTerminalIdRef.current = null;
setIsReconnectPending(false);
}, []);
React.useEffect(
@@ -273,6 +277,7 @@ export const TerminalView: React.FC = () => {
setConnecting(directory, tabId, false);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
focusTerminalWhenWindowActive();
// After a reload, buffer is empty and a reused PTY can look "stuck"
@@ -286,10 +291,10 @@ export const TerminalView: React.FC = () => {
break;
}
case 'reconnecting': {
const attempt = event.attempt ?? 0;
const maxAttempts = event.maxAttempts ?? 3;
setConnectionError(`Reconnecting (${attempt}/${maxAttempts})...`);
void event;
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(true);
break;
}
case 'data': {
@@ -313,10 +318,12 @@ export const TerminalView: React.FC = () => {
exitCode !== null ? ` with code ${exitCode}` : ''
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
setConnecting(directory, tabId, false);
setConnectionError(isActionTab ? null : 'Terminal session ended');
setIsFatalError(false);
setIsReconnectPending(false);
disconnectStream();
break;
}
@@ -327,18 +334,19 @@ export const TerminalView: React.FC = () => {
return;
}
const errorMsg = fatal
? `Connection failed: ${error.message}`
: error.message || 'Terminal stream connection error';
setConnectionError(errorMsg);
setIsFatalError(!!fatal);
if (fatal) {
setConnecting(directory, tabId, false);
setTabSessionId(directory, tabId, null);
disconnectStream();
if (!fatal) {
setConnectionError(null);
setIsFatalError(false);
return;
}
setIsReconnectPending(false);
setConnectionError(`Connection failed: ${error.message}`);
setIsFatalError(true);
setConnecting(directory, tabId, false);
setTabLifecycle(directory, tabId, 'exited');
setTabSessionId(directory, tabId, null);
disconnectStream();
},
},
streamOptions
@@ -349,7 +357,7 @@ export const TerminalView: React.FC = () => {
activeTerminalIdRef.current = null;
};
},
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
);
React.useEffect(() => {
@@ -389,6 +397,7 @@ export const TerminalView: React.FC = () => {
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
let terminalId = tab?.terminalSessionId ?? null;
const terminalLifecycle = tab?.lifecycle ?? 'idle';
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
@@ -402,6 +411,11 @@ export const TerminalView: React.FC = () => {
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
if (!terminalId) {
if (terminalLifecycle === 'exited') {
setConnecting(directory, tabId, false);
return;
}
if (isActionTab && hasBufferedOutput) {
setConnecting(directory, tabId, false);
return;
@@ -409,6 +423,7 @@ export const TerminalView: React.FC = () => {
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
setConnecting(directory, tabId, true);
try {
const size = lastViewportSizeRef.current;
@@ -440,6 +455,7 @@ export const TerminalView: React.FC = () => {
: 'Failed to start terminal session'
);
setIsFatalError(true);
setIsReconnectPending(false);
setConnecting(directory, tabId, false);
}
return;
@@ -476,12 +492,14 @@ export const TerminalView: React.FC = () => {
hasActiveContext,
effectiveDirectory,
terminalSessionId,
terminalLifecycle,
activeTabId,
hasOpenedTerminalViewport,
enableTabs,
terminalHydrated,
ensureDirectory,
setConnecting,
setTabLifecycle,
setTabSessionId,
startStream,
disconnectStream,
@@ -520,6 +538,7 @@ export const TerminalView: React.FC = () => {
setIsRestarting(true);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
disconnectStream();
@@ -528,6 +547,7 @@ export const TerminalView: React.FC = () => {
} catch (error) {
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
setIsFatalError(true);
setIsReconnectPending(false);
} finally {
setIsRestarting(false);
}
@@ -544,6 +564,7 @@ export const TerminalView: React.FC = () => {
setActiveTab(effectiveDirectory, tabId);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
disconnectStream();
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
@@ -553,6 +574,7 @@ export const TerminalView: React.FC = () => {
setActiveTab(effectiveDirectory, tabId);
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
disconnectStream();
},
[disconnectStream, effectiveDirectory, setActiveTab]
@@ -568,6 +590,7 @@ export const TerminalView: React.FC = () => {
setConnectionError(null);
setIsFatalError(false);
setIsReconnectPending(false);
void closeTab(effectiveDirectory, tabId);
},
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
@@ -576,7 +599,7 @@ export const TerminalView: React.FC = () => {
const handleViewportInput = React.useCallback(
(data: string) => {
if (!data) {
if (!data || isReconnectPending) {
return;
}
@@ -602,7 +625,9 @@ export const TerminalView: React.FC = () => {
if (!terminalId) return;
void terminal.sendInput(terminalId, payload).catch((error) => {
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
if (!isReconnectPending) {
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
}
});
if (modifierConsumed) {
@@ -610,7 +635,7 @@ export const TerminalView: React.FC = () => {
terminalControllerRef.current?.focus();
}
},
[activeModifier, setActiveModifier, terminal]
[activeModifier, isReconnectPending, setActiveModifier, terminal]
);
const handleViewportResize = React.useCallback(
@@ -855,7 +880,7 @@ export const TerminalView: React.FC = () => {
);
}
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
const quickKeysControls = (
<>
@@ -1075,7 +1100,7 @@ export const TerminalView: React.FC = () => {
/>
) : null}
</div>
{connectionError && (
{!isReconnectPending && connectionError && (
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
<span>{connectionError}</span>
{isFatalError && isMobile && (