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

View File

@ -336,6 +336,41 @@ function longPress(badge: HTMLElement, pointerId = 7) {
}
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 () => {
const onSend = vi.fn();
const onQuotedContextChange = vi.fn();