diff --git a/.changeset/terminal-autostart-server-platform.md b/.changeset/terminal-autostart-server-platform.md new file mode 100644 index 0000000000..8ff4e54161 --- /dev/null +++ b/.changeset/terminal-autostart-server-platform.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Terminal now auto-starts a session from Windows browsers when the dashboard host is not Windows. +category: fix +dev: Windows-UA clients probe `GET /api/system/info` (memoized, 5s timeout) and only keep the manual "Start terminal" gate when the server platform is `win32` or the probe fails. diff --git a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts index cfd2d38ba8..3ab05900f0 100644 --- a/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useTerminalSessions.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { StrictMode } from "react"; import { renderHook, waitFor, act } from "@testing-library/react"; -import { useTerminalSessions } from "../useTerminalSessions"; +import { useTerminalSessions, __resetServerPlatformProbeForTests } from "../useTerminalSessions"; import { scopedKey } from "../../utils/projectStorage"; import * as apiModule from "../../api"; +import * as systemPanelModule from "../../api/system-panel"; // Mock API vi.mock("../../api", () => ({ @@ -12,9 +13,16 @@ vi.mock("../../api", () => ({ listTerminalSessions: vi.fn(), })); +// FNXC:Terminal 2026-07-23-22:40: Windows-UA clients probe the server platform +// (GET /api/system/info) before deciding whether auto-create is skipped. +vi.mock("../../api/system-panel", () => ({ + fetchSystemInfo: vi.fn(), +})); + const mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession); const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession); const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions); +const mockFetchSystemInfo = vi.mocked(systemPanelModule.fetchSystemInfo); // Mock localStorage const localStorageMock = { @@ -47,6 +55,9 @@ describe("useTerminalSessions", () => { }); mockKillPtyTerminalSession.mockResolvedValue({ killed: true }); mockListTerminalSessions.mockResolvedValue([]); + // Default: non-Windows host so the Windows-UA probe resolves permissive. + __resetServerPlatformProbeForTests(); + mockFetchSystemInfo.mockResolvedValue({ platform: "darwin" } as systemPanelModule.SystemInfoResponse); }); afterEach(() => { @@ -329,12 +340,18 @@ describe("useTerminalSessions", () => { /* FNXC:Terminal 2026-07-23-14:30: - GitHub #2121/#2307: Windows browser clients intentionally skip first-tab + GitHub #2121/#2307: win32-hosted servers 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. + + FNXC:Terminal 2026-07-23-22:40: + The skip is keyed on the SERVER platform (probed via /api/system/info by + Windows-UA clients), not the browser UA: a Windows browser pointed at a + mac/linux host must auto-start a session instead of showing the manual + "Start terminal" screen. */ - describe("Windows client auto-create skip", () => { + describe("Windows host auto-create skip", () => { const setUserAgent = (value: string) => { Object.defineProperty(window.navigator, "userAgent", { value, @@ -347,8 +364,9 @@ describe("useTerminalSessions", () => { setUserAgent(originalUserAgent); }); - it("reports autoCreateDisabled and never auto-creates on a Windows browser", async () => { + it("reports autoCreateDisabled and never auto-creates when the server is win32", async () => { setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"); + mockFetchSystemInfo.mockResolvedValue({ platform: "win32" } as systemPanelModule.SystemInfoResponse); const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); @@ -356,7 +374,9 @@ describe("useTerminalSessions", () => { expect(result.current.isReady).toBe(true); }); - expect(result.current.autoCreateDisabled).toBe(true); + await waitFor(() => { + 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)); @@ -365,6 +385,48 @@ describe("useTerminalSessions", () => { expect(result.current.tabs.length).toBe(0); }); + it("auto-creates from a Windows browser when the server host is not Windows", async () => { + // Regression: the manual "Start terminal" screen appeared for Windows + // browsers even against mac/linux-hosted dashboards, where there is no + // wt.exe hazard — opening the terminal must start a session directly. + setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"); + mockFetchSystemInfo.mockResolvedValue({ platform: "darwin" } as systemPanelModule.SystemInfoResponse); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.tabs.length).toBe(1); + }); + expect(result.current.autoCreateDisabled).toBe(false); + expect(mockCreateTerminalSession).toHaveBeenCalledTimes(1); + }); + + it("keeps the skip (conservatively) when the platform probe fails", async () => { + setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"); + mockFetchSystemInfo.mockRejectedValue(new Error("network down")); + + const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID)); + + await waitFor(() => { + expect(result.current.autoCreateDisabled).toBe(true); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + expect(mockCreateTerminalSession).not.toHaveBeenCalled(); + }); + + it("never probes the server platform from 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.tabs.length).toBe(1); + }); + expect(mockFetchSystemInfo).not.toHaveBeenCalled(); + }); + it("reports autoCreateDisabled=false on non-Windows browsers", async () => { setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0"); @@ -397,6 +459,7 @@ describe("useTerminalSessions", () => { // The Windows branch used to force isReady(true) on mount, letting xterm // connect to a persisted-but-dead session before validation pruned it. setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"); + mockFetchSystemInfo.mockResolvedValue({ platform: "win32" } as systemPanelModule.SystemInfoResponse); const storedTabs = [ { id: "tab-dead", sessionId: "session-dead", title: "bash", isActive: true, createdAt: 1 }, ]; diff --git a/packages/dashboard/app/hooks/useTerminalSessions.ts b/packages/dashboard/app/hooks/useTerminalSessions.ts index c3199733ec..2a0181ed5c 100644 --- a/packages/dashboard/app/hooks/useTerminalSessions.ts +++ b/packages/dashboard/app/hooks/useTerminalSessions.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api"; +import { fetchSystemInfo } from "../api/system-panel"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; const STORAGE_KEY = "kb-terminal-tabs"; @@ -8,6 +9,8 @@ const STORAGE_KEY = "kb-terminal-tabs"; const BOOTSTRAP_LIST_TIMEOUT_MS = 15000; /** Timeout for the auto-create createTerminalSession call during bootstrap. */ const BOOTSTRAP_CREATE_TIMEOUT_MS = 15000; +/** Timeout for the server-platform probe consulted by Windows browser clients. */ +const SERVER_PLATFORM_TIMEOUT_MS = 5000; /** * Represents a terminal tab with its metadata and session information. @@ -42,9 +45,10 @@ interface UseTerminalSessionsReturn { /** 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. + * True when the first tab will NOT be auto-created (win32-hosted servers, + * probed by 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 */ @@ -80,14 +84,16 @@ function generateTabId(): string { /* 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 +GitHub #2121/#2307: the Windows auto-create skip 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. + +FNXC:Terminal 2026-07-23-22:40: +This UA sniff is now only the trigger for the server-platform probe, not the +skip itself: Windows-UA clients ask the server (resolveServerPlatform) whether +the PTY host is actually win32 before the skip applies. See the probe comment +below for the full contract. */ function isWindowsBrowserClient(): boolean { if (typeof window === "undefined") return false; @@ -99,6 +105,37 @@ function isWindowsBrowserClient(): boolean { return ua.includes("Windows NT") && !ua.includes("Windows Phone"); } +/* +FNXC:Terminal 2026-07-23-22:40: +The wt.exe Help/version-dialog hazard the auto-create skip guards against lives +on the HOST that spawns the PTY, not in the browser: a Windows browser pointed +at a mac/linux-hosted dashboard was still forced through the manual "Start +terminal" screen for no reason. Windows-UA clients now probe the server's +platform (GET /api/system/info) once per page load and only keep the skip when +the SERVER is win32; a failed/timed-out probe conservatively keeps the skip so +a real Windows host can never auto-create through a probe outage. Non-Windows +browsers never probe — their instant auto-create path is unchanged. +*/ +let serverPlatformProbe: Promise | null = null; + +function resolveServerPlatform(): Promise { + if (!serverPlatformProbe) { + serverPlatformProbe = withTimeout(fetchSystemInfo(), SERVER_PLATFORM_TIMEOUT_MS, "fetchSystemInfo") + .then((info) => (typeof info.platform === "string" ? info.platform : null)) + .catch(() => { + // Do not cache failures: a later terminal mount may retry the probe. + serverPlatformProbe = null; + return null; + }); + } + return serverPlatformProbe; +} + +/** Test-only: clears the memoized server-platform probe between test cases. */ +export function __resetServerPlatformProbeForTests(): void { + serverPlatformProbe = null; +} + function terminalTabsStorageKey(storageScope?: string): string { const trimmed = storageScope?.trim(); return trimmed ? `${STORAGE_KEY}:${trimmed}` : STORAGE_KEY; @@ -236,6 +273,31 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess const generationRef = useRef(0); const bootstrapCreateInFlightGenerationRef = useRef(null); + /* + FNXC:Terminal 2026-07-23-22:40: + Server platform learned from the memoized /api/system/info probe. Only + Windows-UA clients consult it (see resolveServerPlatform): `undefined` means + the probe is still in flight (auto-create waits, spinner stays up), `null` + means the probe failed (conservatively treated as a Windows host), and a + string is the server's process.platform. Non-Windows browsers never enter + the pending state, so their auto-create is not serialized behind the probe. + */ + const uaWindows = isWindowsBrowserClient(); + const [serverPlatform, setServerPlatform] = useState(undefined); + const serverPlatformPending = uaWindows && serverPlatform === undefined; + const autoCreateDisabled = uaWindows && (serverPlatform === "win32" || serverPlatform === null); + + useEffect(() => { + if (!uaWindows) return; + let cancelled = false; + resolveServerPlatform().then((platform) => { + if (!cancelled) setServerPlatform(platform); + }); + return () => { + cancelled = true; + }; + }, [uaWindows]); + useEffect(() => { generationRef.current += 1; // FNXC:Terminal 2026-07-15-10:40: @@ -358,8 +420,17 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess and forcing it on mount let Windows clients connect xterm to persisted tabs BEFORE server validation had pruned dead sessions. Skipping auto-create is the only Windows-specific behavior this effect owns. + + FNXC:Terminal 2026-07-23-22:40: + The skip is now keyed on the SERVER platform, not the browser UA: opening + the terminal must auto-start a session whenever the host that spawns the + PTY is not Windows, even from a Windows browser. While the platform probe + is in flight for a Windows-UA client, hold auto-create (pending) instead of + racing it; when the probe resolves non-win32 this effect re-runs and + creates the first tab, so the manual "Start terminal" screen is reserved + for genuine win32 hosts (and probe failures, conservatively). */ - if (isWindowsBrowserClient()) { + if (serverPlatformPending || autoCreateDisabled) { return; } if (tabs.length === 0 && isReady && serverAvailable && !bootstrapError) { @@ -429,15 +500,17 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess return () => clearTimeout(timeout); } }, [ + autoCreateDisabled, bootstrapError, bootstrapWakeGeneration, defaultCwd, isReady, projectId, serverAvailable, + serverPlatformPending, tabs.length, retryGeneration, - ]); // Run when ready, when tabs become empty, or after a stale attempt settles + ]); // Run when ready, when tabs become empty, after a stale attempt settles, or when the platform probe resolves /** * Internal create tab function (used for auto-creation and user-initiated creation). @@ -650,7 +723,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess tabs, activeTab, isReady, - autoCreateDisabled: isWindowsBrowserClient(), + autoCreateDisabled, bootstrapError, createTab, closeTab,