fix(FN-xxx): keep mobile chat keyboard open on send
This commit is contained in:
5
.changeset/fix-mobile-chat-send-focus.md
Normal file
5
.changeset/fix-mobile-chat-send-focus.md
Normal 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.
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user