fix: self-clear Quick Chat send-button tap latch on mobile
The send/stop buttons run their action on pointerdown/touchstart and set a shared handledMobileActionRef latch so the trailing synthetic onClick does not double-fire. The latch was only cleared inside onClick, but iOS suppresses that click after preventDefault() in touchstart — leaving it stuck true, so the next real click (e.g. after switching chats) was swallowed and the button looked dead. Make the latch self-clearing (auto-reset timer + consume-on-click) so it can never persist across taps; also stops a stuck stop-button latch from killing the next send tap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/fix-quick-chat-send-tap-latch.md
Normal file
5
.changeset/fix-quick-chat-send-tap-latch.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix the Quick Chat send button going dead after switching chats on mobile. The send and stop buttons run their action on `pointerdown`/`touchstart` (iOS needs that) and set a shared `handledMobileActionRef` latch so the trailing synthetic `onClick` doesn't double-fire — but the latch was only ever cleared inside `onClick`. On iOS, `preventDefault()` in `touchstart` routinely suppresses that click, leaving the latch stuck `true`, so the next real click (e.g. after opening a different chat) was swallowed and the button appeared unresponsive. The latch is now self-clearing: it auto-resets on a short timer after each gesture and is consumed-and-cancelled when a click does fire, so it can never persist across taps. Because the ref is shared by both buttons, this also stops a stuck stop-button latch from killing the next send tap.
|
||||||
@@ -1070,6 +1070,7 @@ export function QuickChatFAB({
|
|||||||
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
|
||||||
const shouldAutoFocusComposerRef = useRef(false);
|
const shouldAutoFocusComposerRef = useRef(false);
|
||||||
const handledMobileActionRef = useRef(false);
|
const handledMobileActionRef = useRef(false);
|
||||||
|
const handledMobileActionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const preserveComposerFocusRef = useRef(false);
|
const preserveComposerFocusRef = useRef(false);
|
||||||
// Always-mounted offscreen input used to claim the iOS soft keyboard
|
// Always-mounted offscreen input used to claim the iOS soft keyboard
|
||||||
// synchronously inside the FAB click gesture, before the real composer
|
// synchronously inside the FAB click gesture, before the real composer
|
||||||
@@ -1972,6 +1973,43 @@ export function QuickChatFAB({
|
|||||||
preserveComposerFocusRef.current = true;
|
preserveComposerFocusRef.current = true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Latch that a mobile pointer/touch handler already performed a button's
|
||||||
|
// action, so the synthetic onClick that trails the gesture is ignored
|
||||||
|
// (prevents a double send/stop). On iOS, preventDefault() in
|
||||||
|
// touchstart/pointerdown frequently suppresses that click entirely, so we
|
||||||
|
// also clear the latch on a timer: without it the ref stays stuck `true` and
|
||||||
|
// swallows the *next* real click (e.g. after switching chats), making the
|
||||||
|
// button look dead. The latch is shared by the send and stop buttons, so a
|
||||||
|
// stuck value cross-contaminates between them.
|
||||||
|
const markHandledMobileAction = useCallback(() => {
|
||||||
|
handledMobileActionRef.current = true;
|
||||||
|
if (handledMobileActionTimerRef.current != null) {
|
||||||
|
clearTimeout(handledMobileActionTimerRef.current);
|
||||||
|
}
|
||||||
|
handledMobileActionTimerRef.current = setTimeout(() => {
|
||||||
|
handledMobileActionRef.current = false;
|
||||||
|
handledMobileActionTimerRef.current = null;
|
||||||
|
}, 700);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If a mobile handler already ran this gesture's action, consume the latch
|
||||||
|
// (and cancel its timer) so the trailing onClick bails without double-firing.
|
||||||
|
const consumeHandledMobileAction = useCallback(() => {
|
||||||
|
if (!handledMobileActionRef.current) return false;
|
||||||
|
handledMobileActionRef.current = false;
|
||||||
|
if (handledMobileActionTimerRef.current != null) {
|
||||||
|
clearTimeout(handledMobileActionTimerRef.current);
|
||||||
|
handledMobileActionTimerRef.current = null;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (handledMobileActionTimerRef.current != null) {
|
||||||
|
clearTimeout(handledMobileActionTimerRef.current);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSendMessage = useCallback(async () => {
|
const handleSendMessage = useCallback(async () => {
|
||||||
const trimmed = messageInput.trim();
|
const trimmed = messageInput.trim();
|
||||||
const attachmentsToSend = pendingAttachmentsRef.current;
|
const attachmentsToSend = pendingAttachmentsRef.current;
|
||||||
@@ -3048,14 +3086,14 @@ export function QuickChatFAB({
|
|||||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.pointerType && event.pointerType !== "mouse") {
|
if (event.pointerType && event.pointerType !== "mouse") {
|
||||||
handledMobileActionRef.current = true;
|
markHandledMobileAction();
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handledMobileActionRef.current = true;
|
markHandledMobileAction();
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
}}
|
}}
|
||||||
onMouseDown={(event) => {
|
onMouseDown={(event) => {
|
||||||
@@ -3063,10 +3101,7 @@ export function QuickChatFAB({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (handledMobileActionRef.current) {
|
if (consumeHandledMobileAction()) return;
|
||||||
handledMobileActionRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopStreaming();
|
stopStreaming();
|
||||||
}}
|
}}
|
||||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||||
@@ -3082,7 +3117,7 @@ export function QuickChatFAB({
|
|||||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.pointerType && event.pointerType !== "mouse") {
|
if (event.pointerType && event.pointerType !== "mouse") {
|
||||||
handledMobileActionRef.current = true;
|
markHandledMobileAction();
|
||||||
markPreserveComposerFocus();
|
markPreserveComposerFocus();
|
||||||
focusComposerInput();
|
focusComposerInput();
|
||||||
void handleSendMessage();
|
void handleSendMessage();
|
||||||
@@ -3091,7 +3126,7 @@ export function QuickChatFAB({
|
|||||||
onTouchStart={(event) => {
|
onTouchStart={(event) => {
|
||||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handledMobileActionRef.current = true;
|
markHandledMobileAction();
|
||||||
markPreserveComposerFocus();
|
markPreserveComposerFocus();
|
||||||
focusComposerInput();
|
focusComposerInput();
|
||||||
void handleSendMessage();
|
void handleSendMessage();
|
||||||
@@ -3101,10 +3136,7 @@ export function QuickChatFAB({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (handledMobileActionRef.current) {
|
if (consumeHandledMobileAction()) return;
|
||||||
handledMobileActionRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void handleSendMessage();
|
void handleSendMessage();
|
||||||
}}
|
}}
|
||||||
disabled={sendDisabled}
|
disabled={sendDisabled}
|
||||||
|
|||||||
Reference in New Issue
Block a user