Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 1367a67680 fix(terminal): reconnect on Enter while the compose bar is open
An open compose bar no longer hides the disconnected Enter-reconnect hint
or blocks the terminal and dialog paths. An empty compose draft reconnects
the same way; a non-empty draft still sends.

Fixes #3767

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 02:27:57 +00:00
9 changed files with 211 additions and 19 deletions

No files matched your search

@@ -56,6 +56,28 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
unsubscribe();
});
test('enter reconnect stays off until the session allows it and does not queue', () => {
const registry = createTerminalReconnectRegistry();
const requests: string[] = [];
assert.equal(registry.requestEnterReconnect('session-1'), false);
registry.register('session-1', () => requests.push('session-1'));
assert.deepEqual(requests, []);
registry.setEnterReconnectEnabled('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), true);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', false);
registry.setEnterReconnectEnabled('session-1', false);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
});
test('unregistering the current handler clears active reconnect state', () => {
const registry = createTerminalReconnectRegistry();
const unregister = registry.register('session-1', () => {});
+22 -1
View File
@@ -4,6 +4,7 @@ export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
const listeners = new Set<() => void>();
const emit = () => {
@@ -51,7 +52,27 @@ export const createTerminalReconnectRegistry = () => {
return true;
};
return { register, request, setActive, isActive, subscribe };
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use
// request(); this path stays off during auth, host-key, and blocking overlays.
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
if (enabled) enterReconnectEnabledIds.add(sessionId);
else enterReconnectEnabledIds.delete(sessionId);
};
const requestEnterReconnect = (sessionId: string): boolean => {
if (!enterReconnectEnabledIds.has(sessionId)) return false;
return request(sessionId);
};
return {
register,
request,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
isActive,
subscribe,
};
};
export const terminalReconnectRegistry = createTerminalReconnectRegistry();
@@ -6,6 +6,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { TerminalComposeBar } from './TerminalComposeBar';
import { TooltipProvider } from '../ui/tooltip';
import { getComposeBarHistory, pruneComposeBarHistory, recordComposeBarHistory } from '../../application/state/composeBarHistoryStore';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
test('compose bar preserves a draft on pane focus and records only successful async sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
@@ -102,3 +103,95 @@ test('compose bar preserves a draft on pane focus and records only successful as
await act(async () => { root.unmount(); });
pruneComposeBarHistory([]);
});
test('empty compose draft Enter reconnects; a typed draft still sends', async (t) => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const originals = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'localStorage']) {
originals.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name as keyof Window] });
}
const sessionId = 'compose-enter-reconnect';
const reconnects: string[] = [];
const unregister = terminalReconnectRegistry.register(sessionId, () => {
reconnects.push(sessionId);
});
t.after(() => {
unregister();
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
dom.window.close();
for (const [name, descriptor] of originals) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const createTextarea = () => ({
value: '', selectionStart: 0, selectionEnd: 0,
focus() {},
setSelectionRange() {},
});
let textarea = createTextarea();
const sent: string[] = [];
let root!: ReactTestRenderer;
await act(async () => {
root = create(
<TooltipProvider>
<TerminalComposeBar sessionId={sessionId} onSend={(text) => { sent.push(text); return true; }} onClose={() => {}} />
</TooltipProvider>,
{ createNodeMock: (el) => {
if (el.type !== 'textarea') return null;
textarea = createTextarea();
return textarea;
} },
);
});
const key = (name: string, extra: Record<string, unknown> = {}) => {
let prevented = false;
root.root.findByType('textarea').props.onKeyDown({
key: name,
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
nativeEvent: {},
currentTarget: textarea,
preventDefault() { prevented = true; },
...extra,
});
return prevented;
};
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, true);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, []);
assert.equal(textarea.value, '');
textarea.value = 'echo hi';
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
assert.equal(textarea.value, '');
textarea.value = 'keep me';
assert.equal(key('Enter', { shiftKey: true }), false);
assert.equal(textarea.value, 'keep me');
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
textarea.value = '';
assert.equal(key('Enter', { ctrlKey: true }), true);
assert.deepEqual(sent, ['echo hi']);
assert.deepEqual(reconnects, [sessionId]);
assert.equal(key('Enter', { nativeEvent: { isComposing: true, keyCode: 229 } }), false);
assert.deepEqual(reconnects, [sessionId]);
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
assert.equal(key('Enter'), true);
assert.deepEqual(reconnects, [sessionId]);
assert.deepEqual(sent, ['echo hi']);
await act(async () => { root.unmount(); });
});
+12 -1
View File
@@ -5,6 +5,7 @@
*/
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useComposeBarHistory } from '../../application/state/useComposeBarHistory';
import { canNavigateComposeBarHistory } from '../../domain/composeBarHistory';
import { STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS } from '../../infrastructure/config/storageKeys';
@@ -496,12 +497,22 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
}
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
// Plain Enter on an empty draft reconnects a disconnected session.
// Modified Enter and a non-empty draft keep the existing send path (#3767).
const plainEnter = !e.altKey && !e.ctrlKey && !e.metaKey;
if (
plainEnter
&& !e.currentTarget.value
&& terminalReconnectRegistry.requestEnterReconnect(sessionId)
) {
return;
}
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, navigate, onClose]);
}, [handleSend, navigate, onClose, sessionId]);
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
+4 -4
View File
@@ -220,7 +220,6 @@ test("disconnected terminal reconnects on plain Enter when input is not claimed
key: "Enter",
status: "disconnected",
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -234,7 +233,6 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
key: "Enter",
status: "disconnected" as const,
hasRetryHandler: true,
isComposeBarOpen: false,
needsAuth: false,
needsHostKeyVerification: false,
hasBlockingOverlay: false,
@@ -243,14 +241,16 @@ test("terminal enter reconnect ignores active controls and non-disconnected stat
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
// Open search must not globally suppress Enter reconnect / the hint (#2546).
// Open search and an open compose bar must not globally suppress Enter
// reconnect / the hint (#2546 / #3767).
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, shiftKey: true }), false);
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposing: true }), false);
});
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
+15 -11
View File
@@ -7,6 +7,7 @@ import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuCon
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { classifyDistroId } from '../../domain/host';
import type { HostInfoBarTitleMode } from '../../domain/models';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import { useNetworkDeviceModeSuggestion } from '../../application/state/useNetworkDeviceModeSuggestion';
import { isPluginHostProtocol } from '../../domain/pluginConnection';
import { OSC7_SETUP_TARGETS } from './osc7Setup';
@@ -184,7 +185,6 @@ export function shouldReconnectTerminalOnEnterKey({
key,
status,
hasRetryHandler,
isComposeBarOpen,
needsAuth,
needsHostKeyVerification,
hasBlockingOverlay,
@@ -198,7 +198,6 @@ export function shouldReconnectTerminalOnEnterKey({
key: string;
status?: string;
hasRetryHandler: boolean;
isComposeBarOpen: boolean;
needsAuth: boolean;
needsHostKeyVerification: boolean;
hasBlockingOverlay: boolean;
@@ -209,15 +208,15 @@ export function shouldReconnectTerminalOnEnterKey({
shiftKey?: boolean;
isComposing?: boolean;
}): boolean {
// Search-bar open state is intentionally not a global gate. While disconnected,
// find-next is less useful than reconnect; the capture handler still refuses
// Enter only when a real interactive control outside xterm owns the event
// (compose/auth/buttons). The terminal search input is allow-listed so an open
// search bar cannot hide the hint or swallow reconnect (#2544 / #2546).
// Search and compose bars are not global gates. While disconnected, plain
// Enter still reconnects from the terminal and the disconnected dialog.
// Capture still skips real controls outside xterm (compose textarea, auth,
// buttons). Search input is allow-listed so an open search bar cannot hide
// the hint or swallow reconnect (#2544 / #2546). An empty compose draft
// reconnects from TerminalComposeBar; a non-empty draft keeps send (#3767).
return key === "Enter"
&& status === "disconnected"
&& hasRetryHandler
&& !isComposeBarOpen
&& !needsAuth
&& !needsHostKeyVerification
&& !hasBlockingOverlay
@@ -529,18 +528,24 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
key: "Enter",
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
isReconnectActive,
});
// Publish Enter-reconnect so an empty compose draft can reconnect this
// session, including the workspace compose bar (#3767).
useEffect(() => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, showEnterReconnectHint);
return () => {
terminalReconnectRegistry.setEnterReconnectEnabled(sessionId, false);
};
}, [sessionId, showEnterReconnectHint]);
const handleTerminalKeyDownCapture = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
if (!shouldReconnectTerminalOnEnterKey({
key: event.key,
status,
hasRetryHandler: Boolean(handleRetry) && terminalReconnectAvailable !== false,
isComposeBarOpen,
needsAuth: Boolean(auth.needsAuth),
needsHostKeyVerification: Boolean(needsHostKeyVerification),
hasBlockingOverlay: hasBlockingReconnectOverlay,
@@ -563,7 +568,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
auth.needsAuth,
handleRetry,
hasBlockingReconnectOverlay,
isComposeBarOpen,
isReconnectActive,
needsHostKeyVerification,
status,
@@ -389,6 +389,23 @@ test("disconnected connection dialog keeps an Enter-reconnect focus sink", () =>
assert.match(source, /restoreTerminalFocusFromDisconnectedDialog\(\{[\s\S]*isFocusedPane/);
});
test("open compose bar does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
const bodyEnd = viewSource.indexOf("export function shouldBlockTerminalReconnectForTarget", gateIndex);
const gateBody = viewSource.slice(gateIndex, bodyEnd);
assert.equal(gateBody.includes("!isComposeBarOpen"), false);
assert.match(viewSource, /setEnterReconnectEnabled\(sessionId, showEnterReconnectHint\)/);
assert.match(
readFileSync(new URL("./TerminalComposeBar.tsx", import.meta.url), "utf8"),
/requestEnterReconnect\(sessionId\)/,
);
assert.match(
readFileSync(new URL("./terminalHelpers.ts", import.meta.url), "utf8"),
/data-section='terminal-compose-bar'/,
);
});
test("open terminal search does not globally gate enter reconnect", () => {
const viewSource = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
const gateIndex = viewSource.indexOf("export function shouldReconnectTerminalOnEnterKey");
@@ -402,7 +419,7 @@ test("open terminal search does not globally gate enter reconnect", () => {
);
});
test("terminal view receives the effective compose bar state for enter reconnect gating", () => {
test("terminal view receives the effective compose bar state", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const effectiveDefinitionIndex = source.indexOf("const effectiveComposeBarOpen =");
const viewIndex = source.indexOf("<TerminalView ctx={{");
@@ -413,7 +430,7 @@ test("terminal view receives the effective compose bar state for enter reconnect
assert.notEqual(passEffectiveIndex, -1);
assert.ok(
effectiveDefinitionIndex < passEffectiveIndex,
"TerminalView must use the visible workspace compose state before deciding whether Enter can reconnect",
"TerminalView must receive the visible workspace compose state",
);
});
+5
View File
@@ -448,6 +448,11 @@ export function shouldClaimDisconnectedDialogFocus({
// Sink already focused.
return active !== dialogNode;
}
// An open compose draft owns Enter: empty reconnects from the bar, and a
// non-empty draft must keep send behavior (#3767). Do not park focus here.
if (active.closest("[data-section='terminal-compose-bar']")) {
return false;
}
const terminalRoot = resolveDisconnectedDialogTerminalRoot(dialogNode, sessionRoot);
if (terminalRoot?.contains(active)) {
return true;
+19
View File
@@ -98,6 +98,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
id: string;
parent?: FakeNode | null;
interactive?: boolean;
composeBar?: boolean;
children?: FakeNode[];
},
) {
@@ -118,6 +119,7 @@ test("disconnected dialog focus claim never steals from other panes", () => {
return false;
}
closest(selector: string) {
if (selector.includes("terminal-compose-bar") && this.opts.composeBar) return this;
if (selector.includes("button") && this.opts.interactive) return this;
return null;
}
@@ -227,6 +229,23 @@ test("disconnected dialog focus claim never steals from other panes", () => {
}),
false,
);
// Compose-bar focus stays put so an empty draft can reconnect and a
// non-empty draft is not stolen by the disconnected dialog (#3767).
const composeInput = new FakeNode({ id: "compose", composeBar: true });
const composeDialog = new FakeNode({ id: "compose-dialog" });
const composeSession = new FakeNode({
id: "compose-session",
children: [composeInput, composeDialog],
});
assert.equal(
shouldClaimDisconnectedDialogFocus({
activeElement: composeInput as unknown as Element,
dialogNode: composeDialog as unknown as HTMLElement,
sessionRoot: composeSession as unknown as Element,
documentBody: body as unknown as Element,
}),
false,
);
assert.equal(
restoreTerminalFocusFromDisconnectedDialog({