fix(dashboard): terminal manual-start on Windows clients and single-delivery Ctrl/Cmd+V paste
GitHub #2121/#2307: Windows browser clients intentionally skip first-tab auto-create, but TerminalModal showed an endless 'Starting terminal...' spinner whose only escape was the tab-strip '+'. useTerminalSessions now exposes autoCreateDisabled and the modal renders an explicit 'Start terminal' action instead. All-inactive persisted tab payloads are also normalized on restore so the spinner can't wedge on activeTab=null. Paste: attachCustomKeyEventHandler returning false does not cancel the browser's default paste, so Ctrl/Cmd+V delivered the payload twice (custom clipboard read + xterm helper-textarea paste event). preventDefault() makes the custom read the single path; when the async clipboard API is missing (non-HTTPS remote access, older Firefox) the handler returns true so the native paste path works instead of paste being dead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/terminal-manual-start-and-single-paste.md
Normal file
7
.changeset/terminal-manual-start-and-single-paste.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Terminal no longer sticks on "Starting terminal..." on Windows and Ctrl/Cmd+V paste is delivered exactly once.
|
||||||
|
category: fix
|
||||||
|
dev: TerminalModal Cmd/Ctrl+V now calls preventDefault so the browser's native paste cannot double-deliver, and returns true (native xterm paste) when the async clipboard API is unavailable (non-HTTPS remote, older Firefox). useTerminalSessions exposes `autoCreateDisabled` (Windows browser clients) so the modal renders a "Start terminal" action instead of an endless spinner, and normalizes all-inactive persisted tab payloads on restore.
|
||||||
@@ -1076,10 +1076,11 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
}, [fitAndResizeForSession, isOpen]);
|
}, [fitAndResizeForSession, isOpen]);
|
||||||
|
|
||||||
// Use the session management hook
|
// Use the session management hook
|
||||||
const {
|
const {
|
||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
isReady,
|
isReady,
|
||||||
|
autoCreateDisabled,
|
||||||
bootstrapError,
|
bootstrapError,
|
||||||
createTab,
|
createTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
@@ -1766,12 +1767,17 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
if (key === "v") {
|
if (key === "v") {
|
||||||
/*
|
/*
|
||||||
FNXC:Terminal 2026-07-04-10:24:
|
FNXC:Terminal 2026-07-04-10:24:
|
||||||
GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so the browser/xterm native paste path cannot also emit duplicate input.
|
GitHub #1902 showed that relying only on xterm's helper-textarea paste can swallow physical Ctrl/Cmd+V before clipboard text reaches the PTY. Own platform paste here, then return false so xterm's own key handling cannot also emit input.
|
||||||
|
|
||||||
|
FNXC:Terminal 2026-07-23-14:30:
|
||||||
|
Returning false only skips xterm's key handling — it does NOT cancel the browser's default paste, which fires xterm's helper-textarea `paste` listener and delivered every Ctrl/Cmd+V payload to the PTY twice. Call event.preventDefault() so the custom clipboard read is the single delivery path.
|
||||||
|
When the async clipboard API is unavailable (non-HTTPS remote access, older Firefox), return true instead of swallowing the shortcut: the browser's native paste into xterm's helper textarea is then the only working paste path.
|
||||||
*/
|
*/
|
||||||
const readText = navigator.clipboard?.readText;
|
const readText = navigator.clipboard?.readText;
|
||||||
if (!readText) {
|
if (!readText) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
|
event.preventDefault();
|
||||||
readText.call(navigator.clipboard)
|
readText.call(navigator.clipboard)
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
if (!text || xtermInitializedRef.current !== currentSessionId) {
|
if (!text || xtermInitializedRef.current !== currentSessionId) {
|
||||||
@@ -2443,6 +2449,15 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
// Once a tab exists we keep the xterm container visible while UI init runs,
|
// Once a tab exists we keep the xterm container visible while UI init runs,
|
||||||
// avoiding a retry-loop spinner flash after bootstrap recovery.
|
// avoiding a retry-loop spinner flash after bootstrap recovery.
|
||||||
const isLoading = !isReady || (!activeTab && !bootstrapError);
|
const isLoading = !isReady || (!activeTab && !bootstrapError);
|
||||||
|
/*
|
||||||
|
FNXC:Terminal 2026-07-23-14:30:
|
||||||
|
GitHub #2121/#2307: when the sessions hook will never auto-create the first
|
||||||
|
tab (Windows browser clients), the bootstrap spinner has nothing to wait for.
|
||||||
|
Render an explicit "Start terminal" action instead of an indefinite
|
||||||
|
"Starting terminal..." state whose only escape was discovering the tab-strip
|
||||||
|
"+" button.
|
||||||
|
*/
|
||||||
|
const showManualStart = isReady && autoCreateDisabled && !activeTab && !bootstrapError;
|
||||||
// FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it.
|
// FNXC:Terminal 2026-06-23-04:30: Always carry the base `terminal-modal-overlay` class so the no-dim/no-blur rule applies in EVERY mode (docked, floating, AND the mobile/default sheet that is neither) — the terminal must never dim the page behind it.
|
||||||
const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
const overlayClassName = `modal-overlay open terminal-modal-overlay${isDockedMode ? " terminal-modal-overlay--docked" : ""}${isFloatingMode ? " terminal-modal-overlay--floating" : ""}`;
|
||||||
const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`;
|
const modalClassName = `modal terminal-modal${isMobileTerminal && !embedded ? " terminal-modal--mobile" : ""}${isDockedMode ? " terminal-modal--docked" : ""}${isFloatingMode ? " terminal-modal--floating" : ""}${isBelowMode ? " terminal-modal--below" : ""}${embedded ? " terminal-modal--embedded" : ""}`;
|
||||||
@@ -2887,12 +2902,29 @@ export function TerminalModal({ isOpen, onClose, initialCommand, initialCommandG
|
|||||||
|
|
||||||
{/* Terminal container */}
|
{/* Terminal container */}
|
||||||
<div className="terminal-container" data-testid="terminal-container">
|
<div className="terminal-container" data-testid="terminal-container">
|
||||||
{isLoading && !bootstrapError && (
|
{isLoading && !bootstrapError && !showManualStart && (
|
||||||
<div className="terminal-loading" data-testid="terminal-loading">
|
<div className="terminal-loading" data-testid="terminal-loading">
|
||||||
<div className="terminal-spinner" />
|
<div className="terminal-spinner" />
|
||||||
<span>{t("terminal.startingTerminal", "Starting terminal...")}</span>
|
<span>{t("terminal.startingTerminal", "Starting terminal...")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showManualStart && (
|
||||||
|
<div className="terminal-loading" data-testid="terminal-manual-start">
|
||||||
|
<div className="terminal-error-content">
|
||||||
|
<span>{t("terminal.manualStartHint", "The terminal is ready — start a session to begin.")}</span>
|
||||||
|
<div className="terminal-error-actions">
|
||||||
|
<button
|
||||||
|
className="terminal-retry-btn"
|
||||||
|
onClick={() => void createTab()}
|
||||||
|
data-testid="terminal-manual-start-btn"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
{t("terminal.startTerminal", "Start terminal")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{bootstrapError && !activeTab && (
|
{bootstrapError && !activeTab && (
|
||||||
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
|
<div className="terminal-loading" data-testid="terminal-bootstrap-error">
|
||||||
<div className="terminal-error-content">
|
<div className="terminal-error-content">
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ const defaultSessionState = {
|
|||||||
tabs: [defaultTab],
|
tabs: [defaultTab],
|
||||||
activeTab: defaultTab,
|
activeTab: defaultTab,
|
||||||
isReady: true,
|
isReady: true,
|
||||||
|
autoCreateDisabled: false,
|
||||||
bootstrapError: null,
|
bootstrapError: null,
|
||||||
createTab: vi.fn(),
|
createTab: vi.fn(),
|
||||||
closeTab: 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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
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 () => {
|
it("shows error with retry and refresh buttons when bootstrap fails instead of stuck loading", async () => {
|
||||||
const mockRetryBootstrap = vi.fn();
|
const mockRetryBootstrap = vi.fn();
|
||||||
mockUseTerminalSessions.mockReturnValue({
|
mockUseTerminalSessions.mockReturnValue({
|
||||||
@@ -7468,11 +7510,14 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
|||||||
expect(terminalDataHandler).not.toBeNull();
|
expect(terminalDataHandler).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handled = terminalKeyEventHandler?.(
|
const pasteEvent = new KeyboardEvent("keydown", { key: "v", ...modifier, cancelable: true });
|
||||||
new KeyboardEvent("keydown", { key: "v", ...modifier }),
|
const handled = terminalKeyEventHandler?.(pasteEvent);
|
||||||
);
|
|
||||||
|
|
||||||
expect(handled).toBe(false);
|
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));
|
await waitFor(() => expect(readText).toHaveBeenCalledTimes(1));
|
||||||
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
expect(mockSendInput).toHaveBeenCalledTimes(1);
|
||||||
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
expect(mockSendInput).toHaveBeenCalledWith("npm test\n");
|
||||||
@@ -7480,7 +7525,39 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
it.each([
|
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(<TerminalModal isOpen={true} onClose={mockOnClose} />);
|
||||||
|
|
||||||
|
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")) }],
|
["rejected clipboard", { readText: vi.fn().mockRejectedValue(new DOMException("denied")) }],
|
||||||
["empty clipboard", { readText: vi.fn().mockResolvedValue("") }],
|
["empty clipboard", { readText: vi.fn().mockResolvedValue("") }],
|
||||||
] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => {
|
] as const)("fails safely for %s physical paste while preserving xterm input", async (_label, clipboard) => {
|
||||||
|
|||||||
@@ -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)", () => {
|
describe("bootstrap sequencing (FN-7686)", () => {
|
||||||
it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => {
|
it("does not serialize auto-create behind a never-resolving session list on a fresh open", async () => {
|
||||||
// FNXC:Terminal 2026-07-08-10:00:
|
// FNXC:Terminal 2026-07-08-10:00:
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ interface UseTerminalSessionsReturn {
|
|||||||
activeTab: TerminalTab | null;
|
activeTab: TerminalTab | null;
|
||||||
/** 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;
|
||||||
|
* 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 */
|
/** Error during bootstrap/session creation, or null if no error */
|
||||||
bootstrapError: string | null;
|
bootstrapError: string | null;
|
||||||
/** Creates a new tab with a fresh server session */
|
/** 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)}`;
|
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 {
|
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;
|
||||||
@@ -83,7 +104,21 @@ function readTabsFromStorage(projectId?: string, storageScope?: string): Termina
|
|||||||
try {
|
try {
|
||||||
const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId);
|
const stored = getScopedItem(terminalTabsStorageKey(storageScope), projectId);
|
||||||
if (stored) {
|
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 {
|
} catch {
|
||||||
// Ignore localStorage errors
|
// 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
|
// (wt.exe) and produce native "Help" version dialogs. Users can still create a terminal
|
||||||
// explicitly from the UI.
|
// explicitly from the UI.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && window.navigator.userAgent.includes("Windows")) {
|
if (isWindowsBrowserClient()) {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -586,6 +621,7 @@ export function useTerminalSessions(projectId?: string, options: UseTerminalSess
|
|||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
isReady,
|
isReady,
|
||||||
|
autoCreateDisabled: isWindowsBrowserClient(),
|
||||||
bootstrapError,
|
bootstrapError,
|
||||||
createTab,
|
createTab,
|
||||||
closeTab,
|
closeTab,
|
||||||
|
|||||||
@@ -8540,6 +8540,8 @@
|
|||||||
"resetPreferences": "Reset to defaults",
|
"resetPreferences": "Reset to defaults",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts",
|
||||||
"startingTerminal": "Starting terminal...",
|
"startingTerminal": "Starting terminal...",
|
||||||
|
"manualStartHint": "The terminal is ready — start a session to begin.",
|
||||||
|
"startTerminal": "Start terminal",
|
||||||
"statusConnected": "Connected",
|
"statusConnected": "Connected",
|
||||||
"statusConnecting": "Connecting...",
|
"statusConnecting": "Connecting...",
|
||||||
"statusDisconnected": "Disconnected",
|
"statusDisconnected": "Disconnected",
|
||||||
|
|||||||
@@ -8530,6 +8530,8 @@
|
|||||||
"resetPreferences": "",
|
"resetPreferences": "",
|
||||||
"shortcuts": "Atajos",
|
"shortcuts": "Atajos",
|
||||||
"startingTerminal": "Iniciando terminal...",
|
"startingTerminal": "Iniciando terminal...",
|
||||||
|
"manualStartHint": "La terminal está lista: inicia una sesión para comenzar.",
|
||||||
|
"startTerminal": "Iniciar terminal",
|
||||||
"statusConnected": "Conectado",
|
"statusConnected": "Conectado",
|
||||||
"statusConnecting": "Conectando...",
|
"statusConnecting": "Conectando...",
|
||||||
"statusDisconnected": "Desconectado",
|
"statusDisconnected": "Desconectado",
|
||||||
|
|||||||
@@ -8530,6 +8530,8 @@
|
|||||||
"resetPreferences": "",
|
"resetPreferences": "",
|
||||||
"shortcuts": "Raccourcis",
|
"shortcuts": "Raccourcis",
|
||||||
"startingTerminal": "Démarrage du terminal...",
|
"startingTerminal": "Démarrage du terminal...",
|
||||||
|
"manualStartHint": "Le terminal est prêt — démarrez une session pour commencer.",
|
||||||
|
"startTerminal": "Démarrer le terminal",
|
||||||
"statusConnected": "Connecté",
|
"statusConnected": "Connecté",
|
||||||
"statusConnecting": "Connexion en cours...",
|
"statusConnecting": "Connexion en cours...",
|
||||||
"statusDisconnected": "Déconnecté",
|
"statusDisconnected": "Déconnecté",
|
||||||
|
|||||||
@@ -8530,6 +8530,8 @@
|
|||||||
"resetPreferences": "",
|
"resetPreferences": "",
|
||||||
"shortcuts": "단축키",
|
"shortcuts": "단축키",
|
||||||
"startingTerminal": "터미널 시작 중...",
|
"startingTerminal": "터미널 시작 중...",
|
||||||
|
"manualStartHint": "터미널이 준비되었습니다 — 세션을 시작하세요.",
|
||||||
|
"startTerminal": "터미널 시작",
|
||||||
"statusConnected": "연결됨",
|
"statusConnected": "연결됨",
|
||||||
"statusConnecting": "연결 중...",
|
"statusConnecting": "연결 중...",
|
||||||
"statusDisconnected": "연결 끊김",
|
"statusDisconnected": "연결 끊김",
|
||||||
|
|||||||
@@ -8530,6 +8530,8 @@
|
|||||||
"resetPreferences": "",
|
"resetPreferences": "",
|
||||||
"shortcuts": "快捷键",
|
"shortcuts": "快捷键",
|
||||||
"startingTerminal": "启动终端中...",
|
"startingTerminal": "启动终端中...",
|
||||||
|
"manualStartHint": "终端已就绪 — 启动会话以开始。",
|
||||||
|
"startTerminal": "启动终端",
|
||||||
"statusConnected": "已连接",
|
"statusConnected": "已连接",
|
||||||
"statusConnecting": "连接中...",
|
"statusConnecting": "连接中...",
|
||||||
"statusDisconnected": "已断开连接",
|
"statusDisconnected": "已断开连接",
|
||||||
|
|||||||
@@ -8530,6 +8530,8 @@
|
|||||||
"resetPreferences": "",
|
"resetPreferences": "",
|
||||||
"shortcuts": "快捷鍵",
|
"shortcuts": "快捷鍵",
|
||||||
"startingTerminal": "正在啟動終端...",
|
"startingTerminal": "正在啟動終端...",
|
||||||
|
"manualStartHint": "終端已就緒 — 啟動工作階段以開始。",
|
||||||
|
"startTerminal": "啟動終端",
|
||||||
"statusConnected": "已連線",
|
"statusConnected": "已連線",
|
||||||
"statusConnecting": "正在連線...",
|
"statusConnecting": "正在連線...",
|
||||||
"statusDisconnected": "已中斷連線",
|
"statusDisconnected": "已中斷連線",
|
||||||
|
|||||||
Reference in New Issue
Block a user