feat: reconnect all disconnected terminal tabs (#1523)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:48:15 -05:00
1 parent 5860921cbe
commit e161ad22e0
12 files changed
+265 -17

No files matched your search

+6
View File
@@ -52,3 +52,9 @@ npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
## Reconnecting a workspace
Use **Reconnect all disconnected terminals** from a tab's context menu or the command palette after a network interruption. It retries mounted SSH terminal tabs, including hidden tabs and split panes, through their existing manual reconnect flow. Connected terminals and terminals already connecting or waiting for an automatic retry are skipped. Invoking the action again does not interrupt an in-flight connection; failures in one tab do not stop the others. The notification counts reconnect attempts started, not successful connections.
This is a manual workspace action. It does not add automatic SSH-error retries, resume/network listeners, tmux-session restoration or scrollback preservation. Existing authentication prompts, host-key validation and manual-reconnect behavior still apply. Other session plugins participate only if they expose the SDK's optional `reconnectIfDisconnected` handle method.
@@ -1357,6 +1357,24 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 15000);
}
function reconnectTerminal() {
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
setShowDisconnectedOverlay(false);
if (terminal) {
terminal.clear();
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}
}
useImperativeHandle(
ref,
() => ({
@@ -1396,22 +1414,19 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnected(false);
setIsConnecting(false);
},
reconnect: () => {
isUnmountingRef.current = false;
shouldNotReconnectRef.current = false;
isReconnectingRef.current = false;
isConnectingRef.current = false;
reconnectAttempts.current = 0;
wasDisconnectedBySSH.current = false;
wasConnectedRef.current = false;
updateConnectionError(null);
setShowDisconnectedOverlay(false);
if (terminal) {
terminal.clear();
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}
reconnect: reconnectTerminal,
reconnectIfDisconnected: () => {
if (
!terminal ||
isConnected ||
isUnmountingRef.current ||
isConnectingRef.current ||
isReconnectingRef.current ||
reconnectTimeoutRef.current !== null
)
return false;
reconnectTerminal();
return true;
},
isConnected: () => isConnected,
fit: () => {
@@ -24,6 +24,7 @@ export interface TerminalHostConfig {
export interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
reconnectIfDisconnected: () => boolean;
isConnected: () => boolean;
fit: () => void;
focus: () => void;
@@ -0,0 +1,72 @@
// @vitest-environment node
import fs from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const source = fs.readFileSync(
new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url),
"utf8",
);
const reconnect = source.slice(
source.indexOf(" function reconnectTerminal()"),
source.indexOf(" useImperativeHandle("),
);
const methods = source.slice(
source.indexOf(" reconnect: reconnectTerminal,"),
source.indexOf(" isConnected: () => isConnected,"),
);
function createHandle(overrides: Record<string, unknown> = {}) {
const context = {
terminal: { cols: 80, rows: 24, clear: vi.fn() },
isConnected: false,
isUnmountingRef: { current: false },
shouldNotReconnectRef: { current: true },
isReconnectingRef: { current: false },
isConnectingRef: { current: false },
reconnectTimeoutRef: { current: null },
reconnectAttempts: { current: 8 },
wasDisconnectedBySSH: { current: true },
wasConnectedRef: { current: false },
updateConnectionError: vi.fn(),
setShowDisconnectedOverlay: vi.fn(),
connectToHost: vi.fn(() => {
context.isConnectingRef.current = true;
}),
...overrides,
};
const handle = vm.runInNewContext(
reconnect + `\n({${methods}})`,
context,
) as { reconnectIfDisconnected: () => boolean; reconnect: () => void };
return { handle, context };
}
describe("terminal bulk reconnect handle", () => {
it("uses the manual reconnect flow after a dropped SSH session and rejects duplicate requests", () => {
const { handle, context } = createHandle();
expect(handle.reconnectIfDisconnected()).toBe(true);
expect(context.connectToHost).toHaveBeenCalledWith(80, 24);
expect(context.shouldNotReconnectRef.current).toBe(false);
expect(context.reconnectAttempts.current).toBe(0);
expect(context.wasDisconnectedBySSH.current).toBe(false);
expect(handle.reconnectIfDisconnected()).toBe(false);
expect(context.connectToHost).toHaveBeenCalledOnce();
});
it.each([
{ isConnected: true },
{ isConnectingRef: { current: true } },
{ isReconnectingRef: { current: true } },
{ reconnectTimeoutRef: { current: 1 } },
{ isUnmountingRef: { current: true } },
{ terminal: null },
])(
"does not interrupt a connected, busy or unavailable terminal: %j",
(overrides) => {
const { handle, context } = createHandle(overrides);
expect(handle.reconnectIfDisconnected()).toBe(false);
expect(context.connectToHost).not.toHaveBeenCalled();
expect(context.shouldNotReconnectRef.current).toBe(true);
},
);
});