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:
gsxdsm
2026-06-12 11:38:53 -07:00
parent 0897b2a0bf
commit e5036b13c2
2 changed files with 49 additions and 12 deletions

View 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.

View File

@@ -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}