fix(chat): keep mobile send from losing focus

This commit is contained in:
2026-07-01 20:00:54 +08:00
parent 1cb031f793
commit 8586e87ab6
4 changed files with 52 additions and 5 deletions
+33 -5
View File
@@ -10,6 +10,7 @@ import { ChatSendButton } from "./chat-send-button";
import styles from "./chat-input-bar.module.css"; import styles from "./chat-input-bar.module.css";
const log = new Logger("AppChatComponentsChatInputBar"); const log = new Logger("AppChatComponentsChatInputBar");
const POINTER_SEND_CLICK_DEDUPE_MS = 500;
export interface ChatInputBarProps { export interface ChatInputBarProps {
disabled?: boolean; disabled?: boolean;
@@ -19,12 +20,15 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
const dispatch = useChatDispatch(); const dispatch = useChatDispatch();
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const lastPointerSendAtRef = useRef(0);
const hasContent = input.trim().length > 0; const hasContent = input.trim().length > 0;
useKeyboardHeight({ useKeyboardHeight({
targetRef: textareaRef, targetRef: textareaRef,
containerRef: barRef,
active: isFocused, active: isFocused,
onKeyboardDismiss: () => { onKeyboardDismiss: () => {
log.important("info", "[chat-input-bar] keyboard dismissed", { log.important("info", "[chat-input-bar] keyboard dismissed", {
@@ -51,9 +55,23 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
setIsFocused(focused); setIsFocused(focused);
}; };
const handleSend = () => { const handleSend = (source: "click" | "keyboard" | "pointerdown") => {
if (!hasContent) return; if (source === "click") {
const elapsed = Date.now() - lastPointerSendAtRef.current;
if (elapsed >= 0 && elapsed < POINTER_SEND_CLICK_DEDUPE_MS) {
log.important("info", "[chat-input-bar] suppress duplicate click", {
elapsed,
contentLength: input.length,
hasContent,
disabled,
isFocused,
});
return;
}
}
if (disabled || !hasContent) return;
log.debug("[chat-input-bar] handleSend", { log.debug("[chat-input-bar] handleSend", {
source,
contentLength: input.length, contentLength: input.length,
contentPreview: input.slice(0, 50), contentPreview: input.slice(0, 50),
hasContent, hasContent,
@@ -61,6 +79,7 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
isFocused, isFocused,
}); });
log.important("info", "[chat-input-bar] send", { log.important("info", "[chat-input-bar] send", {
source,
contentLength: input.length, contentLength: input.length,
hasContent, hasContent,
disabled, disabled,
@@ -71,21 +90,30 @@ export function ChatInputBar({ disabled = false }: ChatInputBarProps) {
textareaRef.current?.focus(); textareaRef.current?.focus();
}; };
const handlePointerDownSend = () => {
lastPointerSendAtRef.current = Date.now();
handleSend("pointerdown");
};
return ( return (
<div className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}> <div
ref={barRef}
className={`${styles.bar} ${isFocused ? styles.barFocused : ""}`}
>
<div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}> <div className={`${styles.row} ${isFocused ? styles.rowFocused : ""}`}>
<ChatInputTextField <ChatInputTextField
ref={textareaRef} ref={textareaRef}
value={input} value={input}
onChange={handleInputChange} onChange={handleInputChange}
onSubmit={handleSend} onSubmit={() => handleSend("keyboard")}
onFocusChange={handleFocusChange} onFocusChange={handleFocusChange}
disabled={disabled} disabled={disabled}
/> />
<ChatSendButton <ChatSendButton
disabled={disabled} disabled={disabled}
hasContent={hasContent} hasContent={hasContent}
onClick={handleSend} onClick={() => handleSend("click")}
onPointerDownSend={handlePointerDownSend}
/> />
</div> </div>
</div> </div>
@@ -18,12 +18,14 @@ export interface ChatSendButtonProps {
disabled: boolean; disabled: boolean;
hasContent: boolean; hasContent: boolean;
onClick: () => void; onClick: () => void;
onPointerDownSend: () => void;
} }
export function ChatSendButton({ export function ChatSendButton({
disabled, disabled,
hasContent, hasContent,
onClick, onClick,
onPointerDownSend,
}: ChatSendButtonProps) { }: ChatSendButtonProps) {
return ( return (
<button <button
@@ -32,6 +34,13 @@ export function ChatSendButton({
hasContent && !disabled ? styles.buttonActive : styles.buttonEmpty hasContent && !disabled ? styles.buttonActive : styles.buttonEmpty
}`} }`}
disabled={disabled} disabled={disabled}
onPointerDown={(event) => {
if (event.pointerType === "mouse") return;
if (disabled) return;
event.preventDefault();
if (!hasContent) return;
onPointerDownSend();
}}
onClick={onClick} onClick={onClick}
aria-label="Send message" aria-label="Send message"
> >
+9
View File
@@ -51,6 +51,7 @@ interface KeyboardSnapshot {
export function useKeyboardHeight({ export function useKeyboardHeight({
targetRef, targetRef,
containerRef,
active = false, active = false,
onKeyboardDismiss, onKeyboardDismiss,
}: UseKeyboardHeightOptions = {}): void { }: UseKeyboardHeightOptions = {}): void {
@@ -172,6 +173,13 @@ export function useKeyboardHeight({
const handlePointerDown = (event: PointerEvent | TouchEvent) => { const handlePointerDown = (event: PointerEvent | TouchEvent) => {
const target = targetRef?.current; const target = targetRef?.current;
const container = containerRef?.current;
if (event.target instanceof Node && container?.contains(event.target)) {
log.important("info", "[keyboard] keep active for input container tap", {
snapshot: formatSnapshot(readKeyboardSnapshot()),
});
return;
}
if (!target || event.target === target) return; if (!target || event.target === target) return;
if (event.target instanceof Node && target.contains(event.target)) return; if (event.target instanceof Node && target.contains(event.target)) return;
release("outside-interaction"); release("outside-interaction");
@@ -215,6 +223,7 @@ export function useKeyboardHeight({
}; };
}, [ }, [
active, active,
containerRef,
targetRef, targetRef,
]); ]);
} }
+1
View File
@@ -2,6 +2,7 @@ import type { RefObject } from "react";
export interface UseKeyboardHeightOptions { export interface UseKeyboardHeightOptions {
targetRef?: RefObject<HTMLElement | null>; targetRef?: RefObject<HTMLElement | null>;
containerRef?: RefObject<HTMLElement | null>;
active?: boolean; active?: boolean;
onKeyboardDismiss?: () => void; onKeyboardDismiss?: () => void;
} }