feat(FN-1602): merge fusion/fn-1602

This commit is contained in:
gsxdsm
2026-04-12 19:16:15 -07:00
parent 7624d6e76a
commit 2797ea3777
3 changed files with 169 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
---
"@gsxdsm/fusion": patch
---
Fix terminal text entry not working in the dashboard.
This fix ensures xterm's hidden textarea receives proper focus after initialization by:
1. Focusing the helper textarea directly after `terminal.open()`
2. Dispatching a synthetic click event on the terminal container to trigger xterm's internal focus tracking
These changes address the root cause where programmatic `focus()` calls alone did not properly trigger xterm.js's internal focus management, which relies on canvas click events for full input handling setup.

View File

@@ -401,9 +401,40 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
// Clear watchdog — imports and open() succeeded within deadline // Clear watchdog — imports and open() succeeded within deadline
clearTimeout(watchdogTimer); clearTimeout(watchdogTimer);
// Ensure xterm's textarea receives focus for keyboard input.
// xterm.js creates a hidden textarea that captures keyboard events.
// We focus the textarea directly and dispatch a synthetic click on
// the container to trigger xterm's internal focus tracking.
const helperTextarea = terminalRef.current?.querySelector(
".xterm-helper-textarea",
) as HTMLTextAreaElement | undefined;
if (helperTextarea) {
helperTextarea.focus();
}
// Dispatch a click event on the xterm container to ensure xterm's
// internal focus tracking is properly initialized. This is necessary
// because xterm relies on canvas click events for full focus setup.
if (terminalRef.current) {
try {
terminalRef.current.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
}));
} catch {
// Ignore event dispatch errors in non-browser environments
}
}
// Initial fit // Initial fit
setTimeout(() => { setTimeout(() => {
fitAddon.fit(); fitAddon.fit();
// Re-focus after fit in case the DOM changed
const textarea = terminalRef.current?.querySelector(
".xterm-helper-textarea",
) as HTMLTextAreaElement | undefined;
if (textarea) {
textarea.focus();
}
}, 50); }, 50);
xtermRef.current = terminal; xtermRef.current = terminal;
@@ -614,7 +645,23 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
useEffect(() => { useEffect(() => {
if (connectionStatus === "connected" && xtermRef.current) { if (connectionStatus === "connected" && xtermRef.current) {
setTimeout(() => { setTimeout(() => {
xtermRef.current?.focus(); if (!xtermRef.current || !terminalRef.current) return;
// Focus the xterm textarea directly for keyboard input
const helperTextarea = terminalRef.current.querySelector(
".xterm-helper-textarea",
) as HTMLTextAreaElement | undefined;
if (helperTextarea) {
helperTextarea.focus();
}
// Also dispatch a click to trigger xterm's internal focus tracking
try {
terminalRef.current.dispatchEvent(new MouseEvent("click", {
bubbles: true,
cancelable: true,
}));
} catch {
// Ignore event dispatch errors in non-browser environments
}
}, 100); }, 100);
} }
}, [connectionStatus]); }, [connectionStatus]);

View File

@@ -3419,3 +3419,113 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
}); });
}); });
}); });
// --- xterm focus initialization regression tests ---
describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
const mockOnClose = vi.fn();
const mockSendInput = vi.fn();
const mockResize = vi.fn();
const mockReconnect = vi.fn();
const createMockTerminalState = (overrides = {}) => ({
connectionStatus: "disconnected" as const,
sendInput: mockSendInput,
resize: mockResize,
onData: vi.fn(() => vi.fn()),
onExit: vi.fn(() => vi.fn()),
onConnect: vi.fn(() => vi.fn()),
onScrollback: vi.fn(() => vi.fn()),
reconnect: mockReconnect,
onSessionInvalid: vi.fn(() => vi.fn()),
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockCreateTerminalSession.mockResolvedValue({
sessionId: "test-session-123",
shell: "/bin/bash",
cwd: "/project",
});
mockKillPtyTerminalSession.mockResolvedValue({ killed: true });
mockUseTerminal.mockReturnValue(createMockTerminalState());
mockUseTerminalSessions.mockReturnValue(defaultSessionState);
});
afterEach(() => {
vi.restoreAllMocks();
});
/**
* Regression: terminal text entry not working after xterm initialization.
*
* The original bug occurred because xterm's programmatic focus() call did not
* properly trigger xterm's internal focus tracking. xterm.js relies on
* canvas click events to set up focus handling, so we now:
* 1. Focus the helper textarea directly after terminal.open()
* 2. Dispatch a synthetic click on the container to trigger xterm's
* internal focus tracking
*/
it("renders terminal container after xterm is ready", async () => {
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "connected" })
);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Terminal container should be rendered
expect(screen.getByTestId("terminal-xterm")).toBeTruthy();
});
it("handles dispatchEvent errors gracefully in non-browser environments", async () => {
// Simulate dispatchEvent throwing an error (e.g., in jsdom without proper setup)
const originalDispatchEvent = Element.prototype.dispatchEvent;
Element.prototype.dispatchEvent = vi.fn(() => {
throw new Error("dispatchEvent not supported");
});
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "connected" })
);
// Should not throw despite dispatchEvent failing
expect(() => {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
}).not.toThrow();
// Restore original method
Element.prototype.dispatchEvent = originalDispatchEvent;
});
it("continues to work when connection status changes after initial render", async () => {
// Start with disconnected
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "disconnected" })
);
const { rerender } = render(
<TerminalModal isOpen={true} onClose={mockOnClose} />
);
// xterm should still initialize
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
// Now simulate connection becoming established
mockUseTerminal.mockReturnValue(
createMockTerminalState({ connectionStatus: "connected" })
);
rerender(<TerminalModal isOpen={true} onClose={mockOnClose} />);
// Modal should still render correctly
await waitFor(() => {
expect(screen.getByTestId("terminal-modal")).toBeTruthy();
});
});
});