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:
gsxdsm
2026-07-23 21:33:55 -07:00
parent 07541f78e2
commit 86f56b5cdc
3 changed files with 160 additions and 17 deletions

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

View File

@@ -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 },
];

View File

@@ -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,