fix(dashboard): auto-start terminal sessions when the server host is not Windows
The wt.exe auto-create guard was keyed on the browser UA, forcing Windows browsers pointed at mac/linux-hosted dashboards through the manual "Start terminal" screen. Windows-UA clients now probe GET /api/system/info once and only keep the skip when the SERVER platform is win32 (or the probe fails, conservatively). Non-Windows browsers never probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/terminal-autostart-server-platform.md
Normal file
7
.changeset/terminal-autostart-server-platform.md
Normal file
@@ -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.
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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<string | null> | null = null;
|
||||
|
||||
function resolveServerPlatform(): Promise<string | null> {
|
||||
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<number | null>(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<string | null | undefined>(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,
|
||||
|
||||
Reference in New Issue
Block a user