fix(FN-xxx): keep mobile chat keyboard open on send

This commit is contained in:
gsxdsm
2026-05-01 07:31:36 -07:00
parent 7f55dde353
commit 23134cf14d
3 changed files with 119 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Keep mobile chat composer focused when tapping Send so the keyboard stays open and messages send on the first tap.

View File

@@ -754,6 +754,8 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const hideSkillMenuTimeoutRef = useRef<number | null>(null); const hideSkillMenuTimeoutRef = useRef<number | null>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const preserveComposerFocusRef = useRef(false);
const handledMobileSendRef = useRef(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]); const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const mentionCursorPosRef = useRef(0); const mentionCursorPosRef = useRef(0);
@@ -831,6 +833,24 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
messagesContainer.scrollTop = messagesContainer.scrollHeight; messagesContainer.scrollTop = messagesContainer.scrollHeight;
}, [keyboardOverlap]); }, [keyboardOverlap]);
// Lock body scroll on mobile while the keyboard is up so iOS can't shift
// the visual viewport (offsetTop > 0) and push the input off the top.
useEffect(() => {
if (!isMobile || !keyboardOpen) return;
const scrollY = window.scrollY;
const html = document.documentElement;
const body = document.body;
const prev = { htmlOverflow: html.style.overflow, bodyOverflow: body.style.overflow };
window.scrollTo(0, 0);
html.style.overflow = "hidden";
body.style.overflow = "hidden";
return () => {
html.style.overflow = prev.htmlOverflow;
body.style.overflow = prev.bodyOverflow;
window.scrollTo(0, scrollY);
};
}, [isMobile, keyboardOpen]);
// Close context menu on outside click // Close context menu on outside click
useEffect(() => { useEffect(() => {
const handleClick = () => setContextMenu(null); const handleClick = () => setContextMenu(null);
@@ -978,6 +998,20 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
}); });
}, [messageInput, pendingAttachments, activeSession, sendMessage]); }, [messageInput, pendingAttachments, activeSession, sendMessage]);
const focusComposerInput = useCallback(() => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
const input = inputRef.current;
if (!input || input.disabled) return;
input.focus({ preventScroll: true });
}, []);
const markPreserveComposerFocus = useCallback(() => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
preserveComposerFocusRef.current = true;
}, []);
const handleSkillSelect = useCallback( const handleSkillSelect = useCallback(
(skill: DiscoveredSkill) => { (skill: DiscoveredSkill) => {
setMessageInput((currentInput) => { setMessageInput((currentInput) => {
@@ -1223,6 +1257,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
); );
const handleInputBlur = useCallback(() => { const handleInputBlur = useCallback(() => {
if (preserveComposerFocusRef.current) {
window.requestAnimationFrame(() => {
focusComposerInput();
});
return;
}
if (hideSkillMenuTimeoutRef.current !== null) { if (hideSkillMenuTimeoutRef.current !== null) {
window.clearTimeout(hideSkillMenuTimeoutRef.current); window.clearTimeout(hideSkillMenuTimeoutRef.current);
} }
@@ -1236,7 +1277,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
fileMention.dismissMention(); fileMention.dismissMention();
hideSkillMenuTimeoutRef.current = null; hideSkillMenuTimeoutRef.current = null;
}, 120); }, 120);
}, [fileMention]); }, [fileMention, focusComposerInput]);
const handleInputFocus = useCallback(() => { const handleInputFocus = useCallback(() => {
if (hideSkillMenuTimeoutRef.current !== null) { if (hideSkillMenuTimeoutRef.current !== null) {
@@ -1672,6 +1713,13 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
onBlur={handleInputBlur} onBlur={handleInputBlur}
onFocus={handleInputFocus} onFocus={handleInputFocus}
onPaste={handlePaste} onPaste={handlePaste}
onTouchStart={(event) => {
if (typeof window === "undefined") return;
if (window.innerWidth > 768) return;
if (document.activeElement === event.currentTarget) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
}}
rows={1} rows={1}
data-testid="chat-input" data-testid="chat-input"
/> />
@@ -1723,8 +1771,43 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
</button> </button>
) : ( ) : (
<button <button
type="button"
className="chat-input-send" className="chat-input-send"
onClick={() => void handleSend()} onPointerDown={(event) => {
if (typeof window === "undefined" || window.innerWidth > 768) return;
event.preventDefault();
if (event.pointerType && event.pointerType !== "mouse") {
handledMobileSendRef.current = true;
markPreserveComposerFocus();
focusComposerInput();
void handleSend();
window.setTimeout(() => {
preserveComposerFocusRef.current = false;
}, 300);
}
}}
onTouchStart={(event) => {
if (typeof window === "undefined" || window.innerWidth > 768) return;
event.preventDefault();
handledMobileSendRef.current = true;
markPreserveComposerFocus();
focusComposerInput();
void handleSend();
window.setTimeout(() => {
preserveComposerFocusRef.current = false;
}, 300);
}}
onMouseDown={(event) => {
if (typeof window === "undefined" || window.innerWidth > 768) return;
event.preventDefault();
}}
onClick={() => {
if (handledMobileSendRef.current) {
handledMobileSendRef.current = false;
return;
}
void handleSend();
}}
disabled={!messageInput.trim() && pendingAttachments.length === 0} disabled={!messageInput.trim() && pendingAttachments.length === 0}
data-testid="chat-send-btn" data-testid="chat-send-btn"
> >

View File

@@ -2353,6 +2353,35 @@ describe("ChatView mobile behavior", () => {
} }
}); });
it("mobile mode: send button sends on first touch and keeps composer focused", async () => {
const restoreMatchMedia = mockMobileViewport();
const sendMessage = vi.fn();
try {
setupMockChat({
activeSession: activeSessionFixture,
messages: [],
sendMessage,
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const input = screen.getByTestId("chat-input") as HTMLTextAreaElement;
fireEvent.change(input, { target: { value: "Hello mobile" } });
input.focus();
const sendButton = screen.getByTestId("chat-send-btn");
fireEvent.touchStart(sendButton);
fireEvent.click(sendButton);
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith("Hello mobile", []);
expect(document.activeElement).toBe(input);
} finally {
restoreMatchMedia.mockRestore();
}
});
it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => { it("mobile mode: sets and clears keyboard overlap CSS vars on chat thread", async () => {
const restoreMatchMedia = mockMobileViewport(); const restoreMatchMedia = mockMobileViewport();
const { listeners, mockVV } = mockMobileVisualViewport({ const { listeners, mockVV } = mockMobileVisualViewport({