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 && (
+12 -9
View File
@@ -29,20 +29,23 @@ export interface RetryPolicy {
maxDelayMs: number;
}
export interface TerminalTransportCapability {
preferred?: 'ws' | 'http' | 'sse';
transports?: Array<'ws' | 'http' | 'sse'>;
ws?: {
path: string;
v?: number;
enc?: string;
};
}
export interface TerminalSession {
sessionId: string;
cols: number;
rows: number;
capabilities?: {
input?: {
preferred?: 'ws' | 'http';
transports?: Array<'ws' | 'http'>;
ws?: {
path: string;
v?: number;
enc?: string;
};
};
input?: TerminalTransportCapability;
stream?: TerminalTransportCapability;
};
}
File diff suppressed because it is too large Load Diff
+45 -5
View File
@@ -9,9 +9,12 @@ export interface TerminalChunk {
data: string;
}
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
export type TerminalTab = {
id: string;
terminalSessionId: string | null;
lifecycle: TerminalTabLifecycle;
label: string;
bufferChunks: TerminalChunk[];
bufferLength: number;
@@ -40,6 +43,7 @@ interface TerminalStore {
closeTab: (directory: string, tabId: string) => Promise<void>;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
clearBuffer: (directory: string, tabId: string) => void;
@@ -52,7 +56,7 @@ const TERMINAL_BUFFER_LIMIT = 1_000_000;
const TERMINAL_STORE_NAME = 'terminal-store';
let hydrationListenerAttached = false;
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
type PersistedDirectoryTerminalState = {
tabs: PersistedTerminalTab[];
@@ -85,6 +89,7 @@ function normalizeDirectory(dir: string): string {
const createEmptyTab = (id: string, label: string): TerminalTab => ({
id,
terminalSessionId: null,
lifecycle: 'idle',
label,
bufferChunks: [],
bufferLength: 0,
@@ -298,9 +303,14 @@ export const useTerminalStore = create<TerminalStore>()(
const tab = existing.tabs[idx];
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
const nextLifecycle = sessionId
? 'running'
: (tab.terminalSessionId ? 'exited' : tab.lifecycle);
const nextTab: TerminalTab = {
...tab,
terminalSessionId: sessionId,
lifecycle: nextLifecycle,
isConnecting: false,
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
};
@@ -312,6 +322,27 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => {
const key = normalizeDirectory(directory);
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
newSessions.set(key, { ...existing, tabs: nextTabs });
return { sessions: newSessions };
});
},
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
const key = normalizeDirectory(directory);
set((state) => {
@@ -428,6 +459,7 @@ export const useTerminalStore = create<TerminalStore>()(
id: tab.id,
label: tab.label,
terminalSessionId: tab.terminalSessionId,
lifecycle: tab.lifecycle,
createdAt: tab.createdAt,
})),
},
@@ -474,13 +506,21 @@ export const useTerminalStore = create<TerminalStore>()(
maxTabNum = Math.max(maxTabNum, num);
}
const terminalSessionId =
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
? (rawTab.terminalSessionId as string | null)
: null;
const lifecycleRaw = rawTab.lifecycle;
const lifecycle =
lifecycleRaw === 'idle' || lifecycleRaw === 'running' || lifecycleRaw === 'exited'
? lifecycleRaw
: (terminalSessionId ? 'running' : 'idle');
tabs.push({
id,
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
terminalSessionId:
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
? (rawTab.terminalSessionId as string | null)
: null,
terminalSessionId,
lifecycle,
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
bufferChunks: [],
bufferLength: 0,