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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { StrictMode } from "react"; import { StrictMode } from "react";
import { renderHook, waitFor, act } from "@testing-library/react"; import { renderHook, waitFor, act } from "@testing-library/react";
import { useTerminalSessions } from "../useTerminalSessions"; import { useTerminalSessions, __resetServerPlatformProbeForTests } from "../useTerminalSessions";
import { scopedKey } from "../../utils/projectStorage"; import { scopedKey } from "../../utils/projectStorage";
import * as apiModule from "../../api"; import * as apiModule from "../../api";
import * as systemPanelModule from "../../api/system-panel";
// Mock API // Mock API
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
@@ -12,9 +13,16 @@ vi.mock("../../api", () => ({
listTerminalSessions: vi.fn(), 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 mockCreateTerminalSession = vi.mocked(apiModule.createTerminalSession);
const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession); const mockKillPtyTerminalSession = vi.mocked(apiModule.killPtyTerminalSession);
const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions); const mockListTerminalSessions = vi.mocked(apiModule.listTerminalSessions);
const mockFetchSystemInfo = vi.mocked(systemPanelModule.fetchSystemInfo);
// Mock localStorage // Mock localStorage
const localStorageMock = { const localStorageMock = {
@@ -47,6 +55,9 @@ describe("useTerminalSessions", () => {
}); });
mockKillPtyTerminalSession.mockResolvedValue({ killed: true }); mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
mockListTerminalSessions.mockResolvedValue([]); mockListTerminalSessions.mockResolvedValue([]);
// Default: non-Windows host so the Windows-UA probe resolves permissive.
__resetServerPlatformProbeForTests();
mockFetchSystemInfo.mockResolvedValue({ platform: "darwin" } as systemPanelModule.SystemInfoResponse);
}); });
afterEach(() => { afterEach(() => {
@@ -329,12 +340,18 @@ describe("useTerminalSessions", () => {
/* /*
FNXC:Terminal 2026-07-23-14:30: 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 auto-create (embedded shells could spawn Windows Terminal Help/version
dialogs), but that skip must be observable via `autoCreateDisabled` so the dialogs), but that skip must be observable via `autoCreateDisabled` so the
modal renders an explicit start action instead of an endless spinner. 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) => { const setUserAgent = (value: string) => {
Object.defineProperty(window.navigator, "userAgent", { Object.defineProperty(window.navigator, "userAgent", {
value, value,
@@ -347,8 +364,9 @@ describe("useTerminalSessions", () => {
setUserAgent(originalUserAgent); 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"); 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)); const { result } = renderHook(() => useTerminalSessions(TEST_PROJECT_ID));
@@ -356,7 +374,9 @@ describe("useTerminalSessions", () => {
expect(result.current.isReady).toBe(true); 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. // Give the (skipped) auto-create effect a chance to fire wrongly.
await act(async () => { await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
@@ -365,6 +385,48 @@ describe("useTerminalSessions", () => {
expect(result.current.tabs.length).toBe(0); 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 () => { it("reports autoCreateDisabled=false on non-Windows browsers", async () => {
setUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0"); 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 // The Windows branch used to force isReady(true) on mount, letting xterm
// connect to a persisted-but-dead session before validation pruned it. // connect to a persisted-but-dead session before validation pruned it.
setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0"); setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/126.0");
mockFetchSystemInfo.mockResolvedValue({ platform: "win32" } as systemPanelModule.SystemInfoResponse);
const storedTabs = [ const storedTabs = [
{ id: "tab-dead", sessionId: "session-dead", title: "bash", isActive: true, createdAt: 1 }, { 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 { useState, useEffect, useCallback, useRef } from "react";
import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api"; import { createTerminalSession, killPtyTerminalSession, listTerminalSessions } from "../api";
import { fetchSystemInfo } from "../api/system-panel";
import { getScopedItem, setScopedItem } from "../utils/projectStorage"; import { getScopedItem, setScopedItem } from "../utils/projectStorage";
const STORAGE_KEY = "kb-terminal-tabs"; const STORAGE_KEY = "kb-terminal-tabs";
@@ -8,6 +9,8 @@ const STORAGE_KEY = "kb-terminal-tabs";
const BOOTSTRAP_LIST_TIMEOUT_MS = 15000; const BOOTSTRAP_LIST_TIMEOUT_MS = 15000;
/** Timeout for the auto-create createTerminalSession call during bootstrap. */ /** Timeout for the auto-create createTerminalSession call during bootstrap. */
const BOOTSTRAP_CREATE_TIMEOUT_MS = 15000; 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. * 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 */ /** 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; * True when the first tab will NOT be auto-created (win32-hosted servers,
* see the auto-create effect). Callers must render an explicit start action * probed by Windows browser clients; see the auto-create effect). Callers
* instead of an indefinite loading state. * must render an explicit start action instead of an indefinite loading
* state.
*/ */
autoCreateDisabled: boolean; autoCreateDisabled: boolean;
/** Error during bootstrap/session creation, or null if no error */ /** Error during bootstrap/session creation, or null if no error */
@@ -80,14 +84,16 @@ function generateTabId(): string {
/* /*
FNXC:Terminal 2026-07-23-14:30: FNXC:Terminal 2026-07-23-14:30:
GitHub #2121/#2307: the Windows auto-create skip is keyed on the BROWSER GitHub #2121/#2307: the Windows auto-create skip must be observable: expose it
user-agent, so any Windows client (even one pointed at a mac/linux-hosted as `autoCreateDisabled` so TerminalModal can render a "Start terminal" action
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 instead of an infinite "Starting terminal..." spinner that only the tab-strip
"+" button escapes. "+" 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 { function isWindowsBrowserClient(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -99,6 +105,37 @@ function isWindowsBrowserClient(): boolean {
return ua.includes("Windows NT") && !ua.includes("Windows Phone"); 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 { 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;
@@ -236,6 +273,31 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
const generationRef = useRef(0); const generationRef = useRef(0);
const bootstrapCreateInFlightGenerationRef = useRef<number | null>(null); 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(() => { useEffect(() => {
generationRef.current += 1; generationRef.current += 1;
// FNXC:Terminal 2026-07-15-10:40: // 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 and forcing it on mount let Windows clients connect xterm to persisted tabs
BEFORE server validation had pruned dead sessions. Skipping auto-create is BEFORE server validation had pruned dead sessions. Skipping auto-create is
the only Windows-specific behavior this effect owns. 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; return;
} }
if (tabs.length === 0 && isReady && serverAvailable && !bootstrapError) { if (tabs.length === 0 && isReady && serverAvailable && !bootstrapError) {
@@ -429,15 +500,17 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
} }
}, [ }, [
autoCreateDisabled,
bootstrapError, bootstrapError,
bootstrapWakeGeneration, bootstrapWakeGeneration,
defaultCwd, defaultCwd,
isReady, isReady,
projectId, projectId,
serverAvailable, serverAvailable,
serverPlatformPending,
tabs.length, tabs.length,
retryGeneration, 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). * Internal create tab function (used for auto-creation and user-initiated creation).
@@ -650,7 +723,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
tabs, tabs,
activeTab, activeTab,
isReady, isReady,
autoCreateDisabled: isWindowsBrowserClient(), autoCreateDisabled,
bootstrapError, bootstrapError,
createTab, createTab,
closeTab, closeTab,