fix(webui): dismiss mobile keyboard after send

This commit is contained in:
chengyongru 2026-08-03 15:44:37 +08:00 committed by chengyongru
parent 52bc79d3a0
commit a9bb39b833
2 changed files with 46 additions and 7 deletions

View File

@ -75,6 +75,7 @@ import {
} from "@/hooks/useAttachedImages"; } from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream"; import type { SendAttachment, SendOptions } from "@/hooks/useNanobotStream";
import { usePageVisibility } from "@/hooks/usePageVisibility"; import { usePageVisibility } from "@/hooks/usePageVisibility";
import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder"; import { useVoiceRecorder, type VoiceRecorderErrorKey } from "@/hooks/useVoiceRecorder";
@ -862,6 +863,7 @@ export function ThreadComposer({
const [cursorPosition, setCursorPosition] = useState(0); const [cursorPosition, setCursorPosition] = useState(0);
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents()); const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]); const [queuedPrompts, setQueuedPrompts] = useState<QueuedPrompt[]>([]);
const hasTouchPrimaryPointer = useMediaQuery("(hover: none) and (pointer: coarse)");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null); const formRef = useRef<HTMLFormElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -964,12 +966,12 @@ export function ThreadComposer({
} = useClipboardAndDrop(addFiles); } = useClipboardAndDrop(addFiles);
useEffect(() => { useEffect(() => {
if (disabled) return; if (disabled || hasTouchPrimaryPointer) return;
const el = textareaRef.current; const el = textareaRef.current;
if (!el) return; if (!el) return;
const id = requestAnimationFrame(() => el.focus()); const id = requestAnimationFrame(() => el.focus());
return () => cancelAnimationFrame(id); return () => cancelAnimationFrame(id);
}, [disabled]); }, [disabled, hasTouchPrimaryPointer]);
useEffect(() => { useEffect(() => {
if (!focusRequest || disabled) return; if (!focusRequest || disabled) return;
@ -1300,13 +1302,13 @@ export function ThreadComposer({
}; };
}, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]); }, [filteredMentionCandidates.length, filteredSlashCommands.length, showAnyPalette]);
const resizeTextarea = useCallback(() => { const resizeTextarea = useCallback((restoreFocus = true) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const el = textareaRef.current; const el = textareaRef.current;
if (!el) return; if (!el) return;
el.style.height = "auto"; el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 260)}px`; el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
el.focus(); if (restoreFocus) el.focus();
}); });
}, []); }, []);
@ -1477,13 +1479,13 @@ export function ThreadComposer({
[cliAppMention, resizeTextarea, value], [cliAppMention, resizeTextarea, value],
); );
const clearComposerText = useCallback(() => { const clearComposerText = useCallback((restoreFocus = true) => {
setValue(""); setValue("");
setInlineError(null); setInlineError(null);
setSlashMenuDismissed(false); setSlashMenuDismissed(false);
setCliAppMenuDismissed(false); setCliAppMenuDismissed(false);
setCursorPosition(0); setCursorPosition(0);
resizeTextarea(); resizeTextarea(restoreFocus);
}, [resizeTextarea]); }, [resizeTextarea]);
const queueGuidancePrompt = useCallback(() => { const queueGuidancePrompt = useCallback(() => {
@ -1692,11 +1694,12 @@ export function ThreadComposer({
} }
: options, : options,
); );
if (hasTouchPrimaryPointer) textareaRef.current?.blur();
setQueuedPrompts([]); setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob // Bubble owns the data URL copy; safe to revoke every staged blob
// preview here without affecting the rendered message. // preview here without affecting the rendered message.
clear(); clear();
clearComposerText(); clearComposerText(!hasTouchPrimaryPointer);
onQuotedContextChange?.(null); onQuotedContextChange?.(null);
}, [ }, [
activeCliMentionApps, activeCliMentionApps,
@ -1704,6 +1707,7 @@ export function ThreadComposer({
canSend, canSend,
clear, clear,
clearComposerText, clearComposerText,
hasTouchPrimaryPointer,
handleStop, handleStop,
isStreaming, isStreaming,
maxTextBytes, maxTextBytes,

View File

@ -336,6 +336,41 @@ function longPress(badge: HTMLElement, pointerId = 7) {
} }
describe("ThreadComposer", () => { describe("ThreadComposer", () => {
it("dismisses the touch keyboard after a successful send", async () => {
vi.stubGlobal("matchMedia", vi.fn((query: string) => ({
matches: query === "(hover: none) and (pointer: coarse)",
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
const input = screen.getByLabelText("Message input");
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(input).not.toHaveFocus();
input.focus();
expect(input).toHaveFocus();
fireEvent.change(input, { target: { value: "hello from mobile" } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(onSend).toHaveBeenCalledWith("hello from mobile", undefined, undefined);
expect(input).toHaveValue("");
expect(input).not.toHaveFocus();
});
it("focuses and sends a removable quoted answer excerpt", async () => { it("focuses and sends a removable quoted answer excerpt", async () => {
const onSend = vi.fn(); const onSend = vi.fn();
const onQuotedContextChange = vi.fn(); const onQuotedContextChange = vi.fn();