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 shouldAutoFocusComposerRef = useRef(false);
|
||||
const handledMobileActionRef = useRef(false);
|
||||
const handledMobileActionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const preserveComposerFocusRef = useRef(false);
|
||||
// Always-mounted offscreen input used to claim the iOS soft keyboard
|
||||
// synchronously inside the FAB click gesture, before the real composer
|
||||
@@ -1972,6 +1973,43 @@ export function QuickChatFAB({
|
||||
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 trimmed = messageInput.trim();
|
||||
const attachmentsToSend = pendingAttachmentsRef.current;
|
||||
@@ -3048,14 +3086,14 @@ export function QuickChatFAB({
|
||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||
event.preventDefault();
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
handledMobileActionRef.current = true;
|
||||
markHandledMobileAction();
|
||||
stopStreaming();
|
||||
}
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||
event.preventDefault();
|
||||
handledMobileActionRef.current = true;
|
||||
markHandledMobileAction();
|
||||
stopStreaming();
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
@@ -3063,10 +3101,7 @@ export function QuickChatFAB({
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
if (handledMobileActionRef.current) {
|
||||
handledMobileActionRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (consumeHandledMobileAction()) return;
|
||||
stopStreaming();
|
||||
}}
|
||||
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;
|
||||
event.preventDefault();
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
handledMobileActionRef.current = true;
|
||||
markHandledMobileAction();
|
||||
markPreserveComposerFocus();
|
||||
focusComposerInput();
|
||||
void handleSendMessage();
|
||||
@@ -3091,7 +3126,7 @@ export function QuickChatFAB({
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
|
||||
event.preventDefault();
|
||||
handledMobileActionRef.current = true;
|
||||
markHandledMobileAction();
|
||||
markPreserveComposerFocus();
|
||||
focusComposerInput();
|
||||
void handleSendMessage();
|
||||
@@ -3101,10 +3136,7 @@ export function QuickChatFAB({
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
if (handledMobileActionRef.current) {
|
||||
handledMobileActionRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (consumeHandledMobileAction()) return;
|
||||
void handleSendMessage();
|
||||
}}
|
||||
disabled={sendDisabled}
|
||||
|
||||
Reference in New Issue
Block a user