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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user