fix(dashboard): stop mobile send-button double-fire that aborted chats

The ChatView and QuickChat send/stop buttons each had both an
onPointerDown and an onTouchStart handler invoking the action. On a
quick mobile tap both fire, so handleSend / handleSendMessage /
stopStreaming ran twice in rapid succession. The second invocation
closed the first's SSE stream (streamRef.current.close()), the server
treated that as a cancel via beginGeneration, and the chat ended with
no output — exactly matching the reported "tap silently fails, long
press works" symptom (long press happened to suppress one of the two
events).

Both handlers now early-return when the existing handledMobile*Ref
flag is already set, so only the first event for a given tap fires the
action. The send button additionally gets touch-action: manipulation
(removes the click delay that lets the textarea blur win the race) and
an expanded invisible hit area via ::before so slightly-off taps don't
land on the surrounding textarea and dismiss the keyboard without
sending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 19:55:37 -07:00
parent a296b4d3bd
commit 35ea499e08
4 changed files with 57 additions and 0 deletions

View File

@@ -856,6 +856,25 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
/* Disable double-tap zoom delay on mobile so the click handler fires
immediately on tap, and ignore other gestures the browser would
otherwise speculate on. Without this, quick taps can land in the
ambiguity window where the browser dismisses the keyboard (textarea
blur) before deciding whether to register a click on the button. */
touch-action: manipulation;
}
/* Expand the effective tap target without changing the visual size, so
slightly-off taps still land on the button instead of falling through
to the surrounding textarea (which would dismiss the mobile keyboard
without sending). */
.chat-input-send::before {
content: "";
position: absolute;
inset: -8px;
}
.chat-input-send {
position: relative;
}
.chat-input-send:disabled {

View File

@@ -1998,6 +1998,15 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
if (typeof window === "undefined" || window.innerWidth > 768) return;
event.preventDefault();
if (event.pointerType && event.pointerType !== "mouse") {
// On mobile, both onPointerDown and onTouchStart fire
// for a quick tap. Without this guard handleSend runs
// twice — the second call closes the first's stream
// (useChat.ts: streamRef.current.close()) and the
// server-side beginGeneration aborts the in-flight
// generation, leaving the chat with no output. A
// long-press doesn't fire both events the same way,
// which is why holding the button "fixes" sending.
if (handledMobileSendRef.current) return;
handledMobileSendRef.current = true;
markPreserveComposerFocus();
focusComposerInput();
@@ -2010,6 +2019,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
onTouchStart={(event) => {
if (typeof window === "undefined" || window.innerWidth > 768) return;
event.preventDefault();
if (handledMobileSendRef.current) return;
handledMobileSendRef.current = true;
markPreserveComposerFocus();
focusComposerInput();

View File

@@ -2471,6 +2471,7 @@ export function QuickChatFAB({
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
event.preventDefault();
if (event.pointerType && event.pointerType !== "mouse") {
if (handledMobileActionRef.current) return;
handledMobileActionRef.current = true;
stopStreaming();
}
@@ -2478,6 +2479,7 @@ export function QuickChatFAB({
onTouchStart={(event) => {
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
event.preventDefault();
if (handledMobileActionRef.current) return;
handledMobileActionRef.current = true;
stopStreaming();
}}
@@ -2505,6 +2507,13 @@ export function QuickChatFAB({
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
event.preventDefault();
if (event.pointerType && event.pointerType !== "mouse") {
// Guard against the same touch firing both
// onPointerDown and onTouchStart, which would call
// handleSendMessage twice — the second call closes
// the first's stream and the server-side
// beginGeneration aborts the in-flight generation,
// producing no output. (See ChatView send button.)
if (handledMobileActionRef.current) return;
handledMobileActionRef.current = true;
markPreserveComposerFocus();
focusComposerInput();
@@ -2514,6 +2523,7 @@ export function QuickChatFAB({
onTouchStart={(event) => {
if (typeof window === "undefined" || window.innerWidth > QUICK_CHAT_DESKTOP_BREAKPOINT) return;
event.preventDefault();
if (handledMobileActionRef.current) return;
handledMobileActionRef.current = true;
markPreserveComposerFocus();
focusComposerInput();