diff --git a/.changeset/terminal-manual-start-and-single-paste.md b/.changeset/terminal-manual-start-and-single-paste.md new file mode 100644 index 0000000000..3a6d7475d5 --- /dev/null +++ b/.changeset/terminal-manual-start-and-single-paste.md @@ -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. diff --git a/packages/dashboard/app/components/TerminalModal.tsx b/packages/dashboard/app/components/TerminalModal.tsx index 371cbc0399..96fd34007c 100644 --- a/packages/dashboard/app/components/TerminalModal.tsx +++ b/packages/dashboard/app/components/TerminalModal.tsx @@ -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 */}
- {isLoading && !bootstrapError && ( + {isLoading && !bootstrapError && !showManualStart && (
{t("terminal.startingTerminal", "Starting terminal...")}
)} + {showManualStart && ( +
+
+ {t("terminal.manualStartHint", "The terminal is ready — start a session to begin.")} +
+ +
+
+
+ )} {bootstrapError && !activeTab && (
diff --git a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx index e409ed3208..92366b18ec 100644 --- a/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/TerminalModal.test.tsx @@ -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(); + + 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(); + + 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(); + + 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) => { diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index 57d3e83f42..0fc3af5aa0 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -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: diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index e8aac51aaf..05edb10fb8 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -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, diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 244034a870..1c655d5887 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -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", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 980225e583..a0615161f9 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -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", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index 19ab258f10..4bba6851c8 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -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é", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 48d9877d26..7b5b26971c 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -8530,6 +8530,8 @@ "resetPreferences": "", "shortcuts": "단축키", "startingTerminal": "터미널 시작 중...", + "manualStartHint": "터미널이 준비되었습니다 — 세션을 시작하세요.", + "startTerminal": "터미널 시작", "statusConnected": "연결됨", "statusConnecting": "연결 중...", "statusDisconnected": "연결 끊김", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 209a0ec734..5a0f2591d1 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -8530,6 +8530,8 @@ "resetPreferences": "", "shortcuts": "快捷键", "startingTerminal": "启动终端中...", + "manualStartHint": "终端已就绪 — 启动会话以开始。", + "startTerminal": "启动终端", "statusConnected": "已连接", "statusConnecting": "连接中...", "statusDisconnected": "已断开连接", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 2c4795dc41..c75d88d89c 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -8530,6 +8530,8 @@ "resetPreferences": "", "shortcuts": "快捷鍵", "startingTerminal": "正在啟動終端...", + "manualStartHint": "終端已就緒 — 啟動工作階段以開始。", + "startTerminal": "啟動終端", "statusConnected": "已連線", "statusConnecting": "正在連線...", "statusDisconnected": "已中斷連線",