fix(dashboard): terminal manual-start on Windows clients and single-delivery Ctrl/Cmd+V paste
GitHub #2121/#2307: Windows browser clients intentionally skip first-tab auto-create, but TerminalModal showed an endless 'Starting terminal...' spinner whose only escape was the tab-strip '+'. useTerminalSessions now exposes autoCreateDisabled and the modal renders an explicit 'Start terminal' action instead. All-inactive persisted tab payloads are also normalized on restore so the spinner can't wedge on activeTab=null. Paste: attachCustomKeyEventHandler returning false does not cancel the browser's default paste, so Ctrl/Cmd+V delivered the payload twice (custom clipboard read + xterm helper-textarea paste event). preventDefault() makes the custom read the single path; when the async clipboard API is missing (non-HTTPS remote access, older Firefox) the handler returns true so the native paste path works instead of paste being dead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/terminal-manual-start-and-single-paste.md
Normal file
7
.changeset/terminal-manual-start-and-single-paste.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Terminal no longer sticks on "Starting terminal..." on Windows and Ctrl/Cmd+V paste is delivered exactly once.
|
||||
category: fix
|
||||
dev: TerminalModal Cmd/Ctrl+V now calls preventDefault so the browser's native paste cannot double-deliver, and returns true (native xterm paste) when the async clipboard API is unavailable (non-HTTPS remote, older Firefox). useTerminalSessions exposes `autoCreateDisabled` (Windows browser clients) so the modal renders a "Start terminal" action instead of an endless spinner, and normalizes all-inactive persisted tab payloads on restore.
|
||||
@@ -1076,10 +1076,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
}, [fitAndResizeForSession, isOpen]);
|
||||
|
||||
// Use the session management hook
|
||||
const {
|
||||
tabs,
|
||||
activeTab,
|
||||
const {
|
||||
tabs,
|
||||
activeTab,
|
||||
isReady,
|
||||
autoCreateDisabled,
|
||||
bootstrapError,
|
||||
createTab,
|
||||
closeTab,
|
||||
@@ -1766,12 +1767,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
if (key === "v") {
|
||||
/*
|
||||
FNXC:Terminal 2026-07-04-10:24:
|
||||
GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so the browser/xterm native paste path cannot also emit duplicate input.
|
||||
GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so xterm's own key handling cannot also emit input.
|
||||
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
Returning false only skips xterm's key handling — it does NOT cancel the browser's default paste, which fires xterm's helper-textarea `paste` listener and delivered every Ctrl/Cmd+V payload to the PTY twice. Call event.preventDefault() so the custom clipboard read is the single delivery path.
|
||||
When the async clipboard API is unavailable (non-HTTPS remote access, older Firefox), return true instead of swallowing the shortcut: the browser's native paste into xterm's helper textarea is then the only working paste path.
|
||||
*/
|
||||
const readText = navigator.clipboard?.readText;
|
||||
if (!readText) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
readText.call(navigator.clipboard)
|
||||
.then((text) => {
|
||||
if (!text || xtermInitializedRef.current !== currentSessionId) {
|
||||
@@ -2443,6 +2449,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
// Once a tab exists we keep the xterm container visible while UI init runs,
|
||||
// avoiding a retry-loop spinner flash after bootstrap recovery.
|
||||
const isLoading = !isReady || (!activeTab && !bootstrapError);
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
GitHub #2121/#2307: when the sessions hook will never auto-create the first
|
||||
tab (Windows browser clients), the bootstrap spinner has nothing to wait for.
|
||||
Render an explicit "Start terminal" action instead of an indefinite
|
||||
"Starting terminal..." state whose only escape was discovering the tab-strip
|
||||
"+" button.
|
||||
*/
|
||||
const showManualStart = isReady && autoCreateDisabled && !activeTab && !bootstrapError;
|
||||
// FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it.
|
||||
const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
||||
const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`;
|
||||
@@ -2887,12 +2902,29 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
||||
|
||||
{/* Terminal container */}
|
||||
<div className="terminal-container" data-testid="terminal-container">
|
||||
{isLoading && !bootstrapError && (
|
||||
{isLoading && !bootstrapError && !showManualStart && (
|
||||
<div className="terminal-loading" data-testid="terminal-loading">
|
||||
<div className="terminal-spinner" />
|
||||
<span>{t("terminal.startingTerminal", "Starting terminal...")}</span>
|
||||
</div>
|
||||
)}
|
||||
{showManualStart && (
|
||||
<div className="terminal-loading" data-testid="terminal-manual-start">
|
||||
<div className="terminal-error-content">
|
||||
<span>{t("terminal.manualStartHint", "The terminal is ready — start a session to begin.")}</span>
|
||||
<div className="terminal-error-actions">
|
||||
<button
|
||||
className="terminal-retry-btn"
|
||||
onClick={() => void createTab()}
|
||||
data-testid="terminal-manual-start-btn"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("terminal.startTerminal", "Start terminal")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bootstrapError && !activeTab && (
|
||||
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
|
||||
<div className="terminal-error-content">
|
||||
|
||||
@@ -335,6 +335,7 @@ const defaultSessionState = {
|
||||
tabs: [defaultTab],
|
||||
activeTab: defaultTab,
|
||||
isReady: true,
|
||||
autoCreateDisabled: false,
|
||||
bootstrapError: null,
|
||||
createTab: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
@@ -1266,6 +1267,47 @@ describe("TerminalModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
GitHub #2121/#2307: Windows browser clients never auto-create the first tab,
|
||||
so an indefinite "Starting terminal..." spinner is a dead end. The modal must
|
||||
render an explicit start action instead.
|
||||
*/
|
||||
it("shows a Start terminal action instead of the endless spinner when auto-create is disabled", async () => {
|
||||
const createTab = vi.fn().mockResolvedValue(defaultTab);
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
tabs: [],
|
||||
activeTab: null,
|
||||
autoCreateDisabled: true,
|
||||
createTab,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-manual-start")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("terminal-loading")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByTestId("terminal-manual-start-btn"));
|
||||
expect(createTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the normal xterm surface when auto-create is disabled but a tab already exists", async () => {
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
...defaultSessionState,
|
||||
autoCreateDisabled: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("terminal-container")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("terminal-manual-start")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows error with retry and refresh buttons when bootstrap fails instead of stuck loading", async () => {
|
||||
const mockRetryBootstrap = vi.fn();
|
||||
mockUseTerminalSessions.mockReturnValue({
|
||||
@@ -7468,11 +7510,14 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
expect(terminalDataHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const handled = terminalKeyEventHandler?.(
|
||||
new KeyboardEvent("keydown", { key: "v", ...modifier }),
|
||||
);
|
||||
const pasteEvent = new KeyboardEvent("keydown", { key: "v", ...modifier, cancelable: true });
|
||||
const handled = terminalKeyEventHandler?.(pasteEvent);
|
||||
|
||||
expect(handled).toBe(false);
|
||||
// Returning false only skips xterm's key handling; without preventDefault
|
||||
// the browser's own paste fires xterm's helper-textarea paste listener
|
||||
// and the payload reaches the PTY twice.
|
||||
expect(pasteEvent.defaultPrevented).toBe(true);
|
||||
await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
|
||||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||||
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
||||
@@ -7480,7 +7525,39 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
["missing clipboard", undefined],
|
||||
["missing clipboard API", undefined],
|
||||
["clipboard without readText (Firefox / non-HTTPS)", { writeText: vi.fn() }],
|
||||
] as const)(
|
||||
"falls back to xterm's native paste path for %s instead of swallowing the shortcut",
|
||||
async (_label, clipboard) => {
|
||||
Object.defineProperty(navigator, "platform", {
|
||||
value: "Win32",
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: clipboard,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(terminalKeyEventHandler).not.toBeNull();
|
||||
});
|
||||
|
||||
const pasteEvent = new KeyboardEvent("keydown", { key: "v", ctrlKey: true, cancelable: true });
|
||||
const handled = terminalKeyEventHandler?.(pasteEvent);
|
||||
|
||||
// Without an async clipboard read the browser's native paste into
|
||||
// xterm's helper textarea is the ONLY working paste path — the handler
|
||||
// must let it run rather than returning false and killing paste dead.
|
||||
expect(handled).toBe(true);
|
||||
expect(pasteEvent.defaultPrevented).toBe(false);
|
||||
expect(mockSendInput).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }],
|
||||
["empty clipboard", { readText: vi.fn().mockResolvedValue("") }],
|
||||
] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => {
|
||||
|
||||
@@ -327,6 +327,86 @@ describe("useTerminalSessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
GitHub #2121/#2307: Windows browser clients intentionally skip first-tab
|
||||
auto-create (embedded shells could spawn Windows Terminal Help/version
|
||||
dialogs), but that skip must be observable via `autoCreateDisabled` so the
|
||||
modal renders an explicit start action instead of an endless spinner.
|
||||
*/
|
||||
describe("Windows client auto-create skip", () => {
|
||||
const setUserAgent = (value: string) => {
|
||||
Object.defineProperty(window.navigator, "userAgent", {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
};
|
||||
const originalUserAgent = window.navigator.userAgent;
|
||||
|
||||
afterEach(() => {
|
||||
setUserAgent(originalUserAgent);
|
||||
});
|
||||
|
||||
it("reports autoCreateDisabled and never auto-creates on a Windows browser", async () => {
|
||||
setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0");
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.autoCreateDisabled).toBe(true);
|
||||
// Give the (skipped) auto-create effect a chance to fire wrongly.
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
expect(result.current.tabs.length).toBe(0);
|
||||
});
|
||||
|
||||
it("reports autoCreateDisabled=false on non-Windows browsers", async () => {
|
||||
setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0");
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.autoCreateDisabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("persisted tab restore normalization", () => {
|
||||
it("activates the first tab when a persisted payload has no active tab", async () => {
|
||||
// An all-inactive persisted list previously left activeTab null forever:
|
||||
// auto-create is blocked by tabs.length > 0 and the modal spun on
|
||||
// "Starting terminal...". Normalize at the storage read boundary.
|
||||
const storedTabs = [
|
||||
{ id: "tab-a", sessionId: "session-a", title: "bash", isActive: false, createdAt: 1 },
|
||||
{ id: "tab-b", sessionId: "session-b", title: "zsh", isActive: false, createdAt: 2 },
|
||||
];
|
||||
localStorageMock.getItem.mockImplementation((key: string) =>
|
||||
key === TERMINAL_TABS_KEY ? JSON.stringify(storedTabs) : null,
|
||||
);
|
||||
mockListTerminalSessions.mockResolvedValue([
|
||||
{ id: "session-a" },
|
||||
{ id: "session-b" },
|
||||
] as never);
|
||||
|
||||
const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isReady).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.tabs.length).toBe(2);
|
||||
expect(result.current.activeTab?.id).toBe("tab-a");
|
||||
expect(mockCreateTerminalSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap sequencing (FN-7686)", () => {
|
||||
it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => {
|
||||
// FNXC:Terminal 2026-07-08-10:00:
|
||||
|
||||
@@ -41,6 +41,12 @@ interface UseTerminalSessionsReturn {
|
||||
activeTab: TerminalTab | null;
|
||||
/** Whether sessions have been validated and restored from server */
|
||||
isReady: boolean;
|
||||
/**
|
||||
* True when the first tab will NOT be auto-created (Windows browser clients;
|
||||
* see the auto-create effect). Callers must render an explicit start action
|
||||
* instead of an indefinite loading state.
|
||||
*/
|
||||
autoCreateDisabled: boolean;
|
||||
/** Error during bootstrap/session creation, or null if no error */
|
||||
bootstrapError: string | null;
|
||||
/** Creates a new tab with a fresh server session */
|
||||
@@ -72,6 +78,21 @@ function generateTabId(): string {
|
||||
return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
GitHub #2121/#2307: the Windows auto-create skip is keyed on the BROWSER
|
||||
user-agent, so any Windows client (even one pointed at a mac/linux-hosted
|
||||
dashboard) never auto-creates a first tab. That skip is intentional (the
|
||||
embedded shell may invoke Windows Terminal and spawn native Help/version
|
||||
dialogs — see the auto-create effect), but it must be observable: expose it as
|
||||
`autoCreateDisabled` so TerminalModal can render a "Start terminal" action
|
||||
instead of an infinite "Starting terminal..." spinner that only the tab-strip
|
||||
"+" button escapes.
|
||||
*/
|
||||
function isWindowsBrowserClient(): boolean {
|
||||
return typeof window !== "undefined" && window.navigator.userAgent.includes("Windows");
|
||||
}
|
||||
|
||||
function terminalTabsStorageKey(storageScope?: string): string {
|
||||
const trimmed = storageScope?.trim();
|
||||
return trimmed ? `${STORAGE_KEY}:${trimmed}` : STORAGE_KEY;
|
||||
@@ -83,7 +104,21 @@ function readTabsFromStorage(projectId?: string, storageScope?: string): Termina
|
||||
try {
|
||||
const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId);
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as TerminalTab[];
|
||||
const parsed = JSON.parse(stored) as TerminalTab[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
/*
|
||||
FNXC:Terminal 2026-07-23-14:30:
|
||||
A persisted payload where no tab is active must never survive the restore:
|
||||
TerminalModal derives its whole UI from `activeTab`, and an all-inactive
|
||||
tab list leaves the "Starting terminal..." spinner up forever while the
|
||||
auto-create effect is blocked by tabs.length > 0. The success path of
|
||||
server validation normalizes this, but the validation-failure path keeps
|
||||
tabs as-read, so normalize at the storage boundary instead.
|
||||
*/
|
||||
if (parsed.length > 0 && !parsed.some((tab) => tab.isActive)) {
|
||||
return parsed.map((tab, i) => ({ ...tab, isActive: i === 0 }));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
@@ -294,7 +329,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
// (wt.exe) and produce native "Help" version dialogs. Users can still create a terminal
|
||||
// explicitly from the UI.
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.navigator.userAgent.includes("Windows")) {
|
||||
if (isWindowsBrowserClient()) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
@@ -586,6 +621,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
||||
tabs,
|
||||
activeTab,
|
||||
isReady,
|
||||
autoCreateDisabled: isWindowsBrowserClient(),
|
||||
bootstrapError,
|
||||
createTab,
|
||||
closeTab,
|
||||
|
||||
@@ -8540,6 +8540,8 @@
|
||||
"resetPreferences": "Reset to defaults",
|
||||
"shortcuts": "Shortcuts",
|
||||
"startingTerminal": "Starting terminal...",
|
||||
"manualStartHint": "The terminal is ready — start a session to begin.",
|
||||
"startTerminal": "Start terminal",
|
||||
"statusConnected": "Connected",
|
||||
"statusConnecting": "Connecting...",
|
||||
"statusDisconnected": "Disconnected",
|
||||
|
||||
@@ -8530,6 +8530,8 @@
|
||||
"resetPreferences": "",
|
||||
"shortcuts": "Atajos",
|
||||
"startingTerminal": "Iniciando terminal...",
|
||||
"manualStartHint": "La terminal está lista: inicia una sesión para comenzar.",
|
||||
"startTerminal": "Iniciar terminal",
|
||||
"statusConnected": "Conectado",
|
||||
"statusConnecting": "Conectando...",
|
||||
"statusDisconnected": "Desconectado",
|
||||
|
||||
@@ -8530,6 +8530,8 @@
|
||||
"resetPreferences": "",
|
||||
"shortcuts": "Raccourcis",
|
||||
"startingTerminal": "Démarrage du terminal...",
|
||||
"manualStartHint": "Le terminal est prêt — démarrez une session pour commencer.",
|
||||
"startTerminal": "Démarrer le terminal",
|
||||
"statusConnected": "Connecté",
|
||||
"statusConnecting": "Connexion en cours...",
|
||||
"statusDisconnected": "Déconnecté",
|
||||
|
||||
@@ -8530,6 +8530,8 @@
|
||||
"resetPreferences": "",
|
||||
"shortcuts": "단축키",
|
||||
"startingTerminal": "터미널 시작 중...",
|
||||
"manualStartHint": "터미널이 준비되었습니다 — 세션을 시작하세요.",
|
||||
"startTerminal": "터미널 시작",
|
||||
"statusConnected": "연결됨",
|
||||
"statusConnecting": "연결 중...",
|
||||
"statusDisconnected": "연결 끊김",
|
||||
|
||||
@@ -8530,6 +8530,8 @@
|
||||
"resetPreferences": "",
|
||||
"shortcuts": "快捷键",
|
||||
"startingTerminal": "启动终端中...",
|
||||
"manualStartHint": "终端已就绪 — 启动会话以开始。",
|
||||
"startTerminal": "启动终端",
|
||||
"statusConnected": "已连接",
|
||||
"statusConnecting": "连接中...",
|
||||
"statusDisconnected": "已断开连接",
|
||||
|
||||
@@ -8530,6 +8530,8 @@
|
||||
"resetPreferences": "",
|
||||
"shortcuts": "快捷鍵",
|
||||
"startingTerminal": "正在啟動終端...",
|
||||
"manualStartHint": "終端已就緒 — 啟動工作階段以開始。",
|
||||
"startTerminal": "啟動終端",
|
||||
"statusConnected": "已連線",
|
||||
"statusConnecting": "正在連線...",
|
||||
"statusDisconnected": "已中斷連線",
|
||||
|
||||
Reference in New Issue
Block a user