- {isLoading && !bootstrapError && (
+ {isLoading && !bootstrapError && !showManualStart && (
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": "已中斷連線",