fix(FN-000): skip iOS gesture focus handler so typed keys reach xterm

On iOS Safari/PWA, tapping the terminal opened the on-screen keyboard but
keystrokes were silently dropped. After the earlier CSS fix (c7266b7f) the
helper textarea now covers the terminal surface and iOS focuses it natively
on tap — but the bubble-phase onPointerDown/onTouchStart handler kept
re-focusing xterm + the textarea and calling setSelectionRange during the
touch gesture. That is the same class of re-focus-mid-gesture that the
prior commit identified as disrupting iOS input attribution; moving from
capture to bubble phase wasn't enough.

- Early-return from handleTerminalGestureFocus on
  (hover: none) and (pointer: coarse), so iOS handles focus with no JS
  interference.
- Desktop (fine pointer) keeps the existing behavior because the textarea
  stays 1x1 off-screen and still needs programmatic focus on canvas click.
- Add a regression test covering the no-op path with the media query mocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-23 12:38:13 -07:00
parent c6842c9bab
commit 7d31b21c77
3 changed files with 64 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix iOS terminal typing in the dashboard. On touch-primary devices, tapping the terminal opened the on-screen keyboard but keystrokes were silently dropped because the bubble-phase `handleTerminalGestureFocus` handler re-focused the helper textarea and reset its selection during touchstart/pointerdown, disrupting iOS's input-event attribution. The CSS fix in commit c7266b7f already positions the textarea to receive taps natively, so the JS handler is now a no-op on `(hover: none) and (pointer: coarse)` devices and desktop retains click-to-focus.

View File

@@ -683,10 +683,25 @@ export function TerminalModal({ isOpen, onClose, initialCommand, projectId }: Te
* On mobile browsers, opening the soft keyboard requires focus to happen
* within a real user gesture. Programmatic focus in async effects is often
* ignored even though xterm stays connected and receives output.
*
* On touch-primary devices, the CSS sizes `.xterm-helper-textarea` to cover
* the whole terminal surface (see styles.css @media (hover: none) and
* (pointer: coarse)), so iOS focuses it natively on tap. Re-focusing and
* calling setSelectionRange inside the touchstart/pointerdown handler
* disrupts iOS's input-event attribution (same class of bug the prior
* capture-phase handlers caused — see commit c7266b7f), and subsequent
* keystrokes are silently dropped. Early-return on touch-primary so iOS
* handles focus with no JS interference.
*/
const handleTerminalGestureFocus = useCallback(() => {
if (!terminalRef.current) return;
const isTouchPrimary =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(hover: none) and (pointer: coarse)")?.matches === true;
if (isTouchPrimary) return;
// Ensure xterm updates its own focus state first.
xtermRef.current?.focus();

View File

@@ -3646,6 +3646,50 @@ describe("TerminalModal — xterm focus initialization (FN-1602)", () => {
expect(helperTextarea.autocorrect).toBe("off");
expect(helperTextarea.spellcheck).toBe(false);
});
// On touch-primary devices (iOS, Android), the CSS sizes the helper textarea
// to cover the whole terminal so iOS focuses it natively on tap. Re-focusing
// in the bubble-phase gesture handler disrupts iOS input-event attribution
// and causes typed keys to be silently dropped. The handler must be a no-op
// in that environment — see commit c7266b7f for prior iOS input fix context.
it("no-ops gesture focus handler on touch-primary devices", async () => {
const matchMediaSpy = vi
.spyOn(window, "matchMedia")
.mockImplementation((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
try {
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockTerminalInstance.open).toHaveBeenCalled();
});
const terminalDiv = screen.getByTestId("terminal-xterm");
const helperTextarea = document.createElement("textarea");
helperTextarea.className = "xterm-helper-textarea";
const focusSpy = vi.spyOn(helperTextarea, "focus");
const setSelectionRangeSpy = vi.spyOn(helperTextarea, "setSelectionRange");
terminalDiv.appendChild(helperTextarea);
mockTerminalInstance.focus.mockClear();
fireEvent.touchStart(terminalDiv);
expect(mockTerminalInstance.focus).not.toHaveBeenCalled();
expect(focusSpy).not.toHaveBeenCalled();
expect(setSelectionRangeSpy).not.toHaveBeenCalled();
} finally {
matchMediaSpy.mockRestore();
}
});
});
// --- FN-1765: Project-context propagation ---