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:
gsxdsm
2026-07-23 19:30:45 -07:00
parent 0e6108a1a5
commit 907e8d03e6
11 changed files with 256 additions and 12 deletions

View 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.

View File

@@ -1076,10 +1076,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
}, [fitAndResizeForSession, isOpen]); }, [fitAndResizeForSession, isOpen]);
// Use the session management hook // Use the session management hook
const { const {
tabs, tabs,
activeTab, activeTab,
isReady, isReady,
autoCreateDisabled,
bootstrapError, bootstrapError,
createTab, createTab,
closeTab, closeTab,
@@ -1766,12 +1767,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
if (key === "v") { if (key === "v") {
/* /*
FNXC:Terminal 2026-07-04-10:24: 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; const readText = navigator.clipboard?.readText;
if (!readText) { if (!readText) {
return false; return true;
} }
event.preventDefault();
readText.call(navigator.clipboard) readText.call(navigator.clipboard)
.then((text) => { .then((text) => {
if (!text || xtermInitializedRef.current !== currentSessionId) { 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, // Once a tab exists we keep the xterm container visible while UI init runs,
// avoiding a retry-loop spinner flash after bootstrap recovery. // avoiding a retry-loop spinner flash after bootstrap recovery.
const isLoading = !isReady || (!activeTab && !bootstrapError); 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. // 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 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" : ""}`; 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 */} {/* Terminal container */}
<div className="terminal-container" data-testid="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-loading" data-testid="terminal-loading">
<div className="terminal-spinner" /> <div className="terminal-spinner" />
<span>{t("terminal.startingTerminal", "Starting terminal...")}</span> <span>{t("terminal.startingTerminal", "Starting terminal...")}</span>
</div> </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 && ( {bootstrapError && !activeTab && (
<div className="terminal-loading" data-testid="terminal-bootstrap-error"> <div className="terminal-loading" data-testid="terminal-bootstrap-error">
<div className="terminal-error-content"> <div className="terminal-error-content">

View File

@@ -335,6 +335,7 @@ const defaultSessionState = {
tabs: [defaultTab], tabs: [defaultTab],
activeTab: defaultTab, activeTab: defaultTab,
isReady: true, isReady: true,
autoCreateDisabled: false,
bootstrapError: null, bootstrapError: null,
createTab: vi.fn(), createTab: vi.fn(),
closeTab: 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 () => { it("shows error with retry and refresh buttons when bootstrap fails instead of stuck loading", async () => {
const mockRetryBootstrap = vi.fn(); const mockRetryBootstrap = vi.fn();
mockUseTerminalSessions.mockReturnValue({ mockUseTerminalSessions.mockReturnValue({
@@ -7468,11 +7510,14 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
expect(terminalDataHandler).not.toBeNull(); expect(terminalDataHandler).not.toBeNull();
}); });
const handled = terminalKeyEventHandler?.( const pasteEvent = new KeyboardEvent("keydown", { key: "v", ...modifier, cancelable: true });
new KeyboardEvent("keydown", { key: "v", ...modifier }), const handled = terminalKeyEventHandler?.(pasteEvent);
);
expect(handled).toBe(false); 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)); await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
expect(mockSendInput).toHaveBeenCalledTimes(1); expect(mockSendInput).toHaveBeenCalledTimes(1);
expect(mockSendInput).toHaveBeenCalledWith("npm test\n"); expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
@@ -7480,7 +7525,39 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
); );
it.each([ 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")) }], ["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }],
["empty clipboard", { readText: vi.fn().mockResolvedValue("") }], ["empty clipboard", { readText: vi.fn().mockResolvedValue("") }],
] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => { ] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => {

View File

@@ -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)", () => { describe("bootstrap sequencing (FN-7686)", () => {
it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => { it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => {
// FNXC:Terminal 2026-07-08-10:00: // FNXC:Terminal 2026-07-08-10:00:

View File

@@ -41,6 +41,12 @@ interface UseTerminalSessionsReturn {
activeTab: TerminalTab | null; activeTab: TerminalTab | null;
/** Whether sessions have been validated and restored from server */ /** Whether sessions have been validated and restored from server */
isReady: boolean; 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 */ /** Error during bootstrap/session creation, or null if no error */
bootstrapError: string | null; bootstrapError: string | null;
/** Creates a new tab with a fresh server session */ /** 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)}`; 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 { function terminalTabsStorageKey(storageScope?: string): string {
const trimmed = storageScope?.trim(); const trimmed = storageScope?.trim();
return trimmed ? `${STORAGE_KEY}:${trimmed}` : STORAGE_KEY; return trimmed ? `${STORAGE_KEY}:${trimmed}` : STORAGE_KEY;
@@ -83,7 +104,21 @@ function readTabsFromStorage(projectId?: string, storageScope?: string): Termina
try { try {
const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId); const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId);
if (stored) { 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 { } catch {
// Ignore localStorage errors // 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 // (wt.exe) and produce native "Help" version dialogs. Users can still create a terminal
// explicitly from the UI. // explicitly from the UI.
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && window.navigator.userAgent.includes("Windows")) { if (isWindowsBrowserClient()) {
setIsReady(true); setIsReady(true);
return; return;
} }
@@ -586,6 +621,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
tabs, tabs,
activeTab, activeTab,
isReady, isReady,
autoCreateDisabled: isWindowsBrowserClient(),
bootstrapError, bootstrapError,
createTab, createTab,
closeTab, closeTab,

View File

@@ -8540,6 +8540,8 @@
"resetPreferences": "Reset to defaults", "resetPreferences": "Reset to defaults",
"shortcuts": "Shortcuts", "shortcuts": "Shortcuts",
"startingTerminal": "Starting terminal...", "startingTerminal": "Starting terminal...",
"manualStartHint": "The terminal is ready — start a session to begin.",
"startTerminal": "Start terminal",
"statusConnected": "Connected", "statusConnected": "Connected",
"statusConnecting": "Connecting...", "statusConnecting": "Connecting...",
"statusDisconnected": "Disconnected", "statusDisconnected": "Disconnected",

View File

@@ -8530,6 +8530,8 @@
"resetPreferences": "", "resetPreferences": "",
"shortcuts": "Atajos", "shortcuts": "Atajos",
"startingTerminal": "Iniciando terminal...", "startingTerminal": "Iniciando terminal...",
"manualStartHint": "La terminal está lista: inicia una sesión para comenzar.",
"startTerminal": "Iniciar terminal",
"statusConnected": "Conectado", "statusConnected": "Conectado",
"statusConnecting": "Conectando...", "statusConnecting": "Conectando...",
"statusDisconnected": "Desconectado", "statusDisconnected": "Desconectado",

View File

@@ -8530,6 +8530,8 @@
"resetPreferences": "", "resetPreferences": "",
"shortcuts": "Raccourcis", "shortcuts": "Raccourcis",
"startingTerminal": "Démarrage du terminal...", "startingTerminal": "Démarrage du terminal...",
"manualStartHint": "Le terminal est prêt — démarrez une session pour commencer.",
"startTerminal": "Démarrer le terminal",
"statusConnected": "Connecté", "statusConnected": "Connecté",
"statusConnecting": "Connexion en cours...", "statusConnecting": "Connexion en cours...",
"statusDisconnected": "Déconnecté", "statusDisconnected": "Déconnecté",

View File

@@ -8530,6 +8530,8 @@
"resetPreferences": "", "resetPreferences": "",
"shortcuts": "단축키", "shortcuts": "단축키",
"startingTerminal": "터미널 시작 중...", "startingTerminal": "터미널 시작 중...",
"manualStartHint": "터미널이 준비되었습니다 — 세션을 시작하세요.",
"startTerminal": "터미널 시작",
"statusConnected": "연결됨", "statusConnected": "연결됨",
"statusConnecting": "연결 중...", "statusConnecting": "연결 중...",
"statusDisconnected": "연결 끊김", "statusDisconnected": "연결 끊김",

View File

@@ -8530,6 +8530,8 @@
"resetPreferences": "", "resetPreferences": "",
"shortcuts": "快捷键", "shortcuts": "快捷键",
"startingTerminal": "启动终端中...", "startingTerminal": "启动终端中...",
"manualStartHint": "终端已就绪 — 启动会话以开始。",
"startTerminal": "启动终端",
"statusConnected": "已连接", "statusConnected": "已连接",
"statusConnecting": "连接中...", "statusConnecting": "连接中...",
"statusDisconnected": "已断开连接", "statusDisconnected": "已断开连接",

View File

@@ -8530,6 +8530,8 @@
"resetPreferences": "", "resetPreferences": "",
"shortcuts": "快捷鍵", "shortcuts": "快捷鍵",
"startingTerminal": "正在啟動終端...", "startingTerminal": "正在啟動終端...",
"manualStartHint": "終端已就緒 — 啟動工作階段以開始。",
"startTerminal": "啟動終端",
"statusConnected": "已連線", "statusConnected": "已連線",
"statusConnecting": "正在連線...", "statusConnecting": "正在連線...",
"statusDisconnected": "已中斷連線", "statusDisconnected": "已中斷連線",