From eeecfac538bfaff1e04bdd91176501043e7ace8c Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 3 Aug 2026 16:25:19 +0800 Subject: [PATCH] fix(webui): stabilize thread during IME input --- .../src/components/thread/ThreadComposer.tsx | 1 + .../src/components/thread/ThreadViewport.tsx | 15 ++++++++++ webui/src/tests/thread-composer.test.tsx | 27 +++++++++++++++++ webui/src/tests/thread-viewport.test.tsx | 30 +++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 8d1f1ebc2..0347d5e1d 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -1799,6 +1799,7 @@ export function ThreadComposer({ }; const onInput: React.FormEventHandler = (e) => { + if ((e.nativeEvent as InputEvent).isComposing) return; const el = e.currentTarget; el.style.height = "auto"; el.style.height = `${Math.min(el.scrollHeight, 260)}px`; diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 936ba2af0..df3c8214d 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -186,6 +186,7 @@ export const ThreadViewport = forwardRef(null); const restoreScrollAfterPrependRef = useRef<{ height: number; top: number } | null>(null); + const composerInputScrollTopRef = useRef(null); const composerDockHeightRef = useRef(0); const [atBottom, setAtBottom] = useState(true); const [composerDockHeight, setComposerDockHeight] = useState(0); @@ -688,9 +689,23 @@ export const ThreadViewport = forwardRef { if (event.target instanceof HTMLTextAreaElement) { + composerInputScrollTopRef.current = scrollRef.current?.scrollTop ?? null; threadMotionRef.current?.handleComposerInput(); } }} + onInput={(event) => { + if (!(event.target instanceof HTMLTextAreaElement)) return; + const previousScrollTop = composerInputScrollTopRef.current; + composerInputScrollTopRef.current = null; + const scrollEl = scrollRef.current; + if (scrollEl && previousScrollTop !== null) { + // Textarea autosizing briefly collapses to `height: auto` while + // measuring. Chrome can clamp the sibling thread scrollport in + // that intermediate layout; restore it before paint, then let + // ResizeObserver handle any real final composer height change. + scrollEl.scrollTop = previousScrollTop; + } + }} className={cn( "row-start-2 z-10 w-full", hasMessages ? "relative bg-background" : "relative self-center", diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 008ed6a52..02d0af8a9 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -441,6 +441,33 @@ describe("ThreadComposer", () => { expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]"); }); + it("defers textarea autosizing until IME composition commits", () => { + render( + , + ); + const input = screen.getByLabelText("Message input") as HTMLTextAreaElement; + Object.defineProperty(input, "scrollHeight", { + configurable: true, + value: 120, + }); + input.style.height = "50px"; + + fireEvent.input(input, { + target: { value: "zhongwen" }, + isComposing: true, + }); + expect(input.style.height).toBe("50px"); + + fireEvent.input(input, { + target: { value: "中文" }, + isComposing: false, + }); + expect(input.style.height).toBe("120px"); + }); + it("lets long model preset labels use their intrinsic width", () => { render( { } }); + it("restores thread scroll after the textarea autosize measurement collapses it", () => { + let scroller: HTMLElement | null = null; + const { container } = render( + { + if (scroller) scroller.scrollTop = 692; + }} + /> + )} + />, + ); + scroller = getScroller(container); + Object.defineProperty(scroller, "scrollTop", { + configurable: true, + writable: true, + value: 700, + }); + + fireEvent.input(screen.getByLabelText("Message input"), { + target: { value: "中文" }, + }); + + expect(scroller.scrollTop).toBe(700); + }); + it("keeps the thread scrollport above a mobile soft keyboard", async () => { const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 }); try {