From 052e7f1559944bb7f07d1caa458011eaac4ac8e7 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 14 Jun 2026 20:41:57 +0800 Subject: [PATCH] fix: keep mobile composer above soft keyboard --- .../src/components/thread/ThreadComposer.tsx | 30 +++++- .../src/components/thread/ThreadViewport.tsx | 70 +++++++++++++- webui/src/tests/thread-composer.test.tsx | 49 ++++++++++ webui/src/tests/thread-viewport.test.tsx | 93 +++++++++++++++++++ 4 files changed, 235 insertions(+), 7 deletions(-) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index fdf66345..3e8f738d 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -419,9 +419,20 @@ function suppressNativeDragPreview(dataTransfer: DataTransfer): void { window.setTimeout(() => ghost.remove(), 0); } +function visualViewportBounds(): { top: number; bottom: number; height: number } { + const viewport = window.visualViewport; + if (!viewport) { + return { top: 0, bottom: window.innerHeight, height: window.innerHeight }; + } + const top = Math.max(0, viewport.offsetTop); + const height = Math.max(0, viewport.height); + return { top, bottom: top + height, height }; +} + function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } { - let top = 0; - let bottom = window.innerHeight; + const viewport = visualViewportBounds(); + let top = viewport.top; + let bottom = viewport.bottom; let parent = el.parentElement; while (parent) { @@ -455,11 +466,12 @@ const GOAL_PANEL_MIN_HEIGHT_PX = 112; const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62; function measureGoalPanelMaxCssHeight(stripTopY: number): number { + const viewport = visualViewportBounds(); const spaceAboveStrip = - stripTopY - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX; + stripTopY - viewport.top - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX; return Math.min( Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX), - Math.floor(window.innerHeight * GOAL_PANEL_MAX_VIEWPORT_RATIO), + Math.floor(viewport.height * GOAL_PANEL_MAX_VIEWPORT_RATIO), ); } @@ -593,10 +605,15 @@ function RunElapsedStrip({ if (stripWrapperRef.current && ro) { ro.observe(stripWrapperRef.current); } + const viewport = window.visualViewport; + viewport?.addEventListener("resize", relayout); + viewport?.addEventListener("scroll", relayout); window.addEventListener("resize", relayout); window.addEventListener("scroll", relayout, true); return () => { ro?.disconnect(); + viewport?.removeEventListener("resize", relayout); + viewport?.removeEventListener("scroll", relayout); window.removeEventListener("resize", relayout); window.removeEventListener("scroll", relayout, true); }; @@ -1111,9 +1128,14 @@ export function ThreadComposer({ }; updateLayout(); + const viewport = window.visualViewport; + viewport?.addEventListener("resize", updateLayout); + viewport?.addEventListener("scroll", updateLayout); window.addEventListener("resize", updateLayout); document.addEventListener("scroll", updateLayout, true); return () => { + viewport?.removeEventListener("resize", updateLayout); + viewport?.removeEventListener("scroll", updateLayout); window.removeEventListener("resize", updateLayout); document.removeEventListener("scroll", updateLayout, true); }; diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index e779e1bc..598ec246 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -50,6 +50,7 @@ const NEAR_BOTTOM_PX = 48; const NEAR_TOP_PX = 96; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; +const SOFT_KEYBOARD_MIN_INSET_PX = 80; export const INITIAL_HISTORY_WINDOW = 160; export const HISTORY_WINDOW_INCREMENT = 120; @@ -66,6 +67,35 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM return messages.slice(start); } +function isKeyboardEditableElement(element: Element | null): element is HTMLElement { + if (!(element instanceof HTMLElement)) return false; + if (element.isContentEditable) return true; + if (element instanceof HTMLTextAreaElement) return true; + if (!(element instanceof HTMLInputElement)) return false; + return ![ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", + ].includes(element.type); +} + +function readSoftKeyboardInsetBottom(container: HTMLElement | null): number { + const viewport = window.visualViewport; + if (!viewport) return 0; + const active = document.activeElement; + if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0; + const layoutHeight = window.innerHeight || document.documentElement.clientHeight; + const inset = layoutHeight - viewport.height - viewport.offsetTop; + return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0; +} + export const ThreadViewport = forwardRef(function ThreadViewport({ messages, isStreaming, @@ -99,6 +129,7 @@ export const ThreadViewport = forwardRef 0; @@ -116,9 +147,13 @@ export const ThreadViewport = forwardRef hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount : null; - const scrollButtonBottom = composerDockHeight > 0 - ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX - : DEFAULT_SCROLL_BUTTON_BOTTOM_PX; + const scrollButtonBottom = + keyboardInsetBottom + + (composerDockHeight > 0 + ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX + : DEFAULT_SCROLL_BUTTON_BOTTOM_PX); + const scrollViewportStyle = + keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined; const cancelScheduledBottomScroll = useCallback(() => { for (const id of scrollFrameIdsRef.current) { @@ -216,6 +251,29 @@ export const ThreadViewport = forwardRef { + const updateKeyboardInset = () => { + const next = readSoftKeyboardInsetBottom(scrollRef.current); + setKeyboardInsetBottom((current) => + Math.abs(current - next) < 1 ? current : next, + ); + }; + updateKeyboardInset(); + const viewport = window.visualViewport; + viewport?.addEventListener("resize", updateKeyboardInset); + viewport?.addEventListener("scroll", updateKeyboardInset); + window.addEventListener("resize", updateKeyboardInset); + document.addEventListener("focusin", updateKeyboardInset); + document.addEventListener("focusout", updateKeyboardInset); + return () => { + viewport?.removeEventListener("resize", updateKeyboardInset); + viewport?.removeEventListener("scroll", updateKeyboardInset); + window.removeEventListener("resize", updateKeyboardInset); + document.removeEventListener("focusin", updateKeyboardInset); + document.removeEventListener("focusout", updateKeyboardInset); + }; + }, []); + useEffect(() => { if (!atBottom) return; // Instant jump: CSS scroll-smooth + behavior "auto" still animates in some @@ -223,6 +281,11 @@ export const ThreadViewport = forwardRef { + if (userReadingHistoryRef.current) return; + scrollToBottom(false, 4); + }, [keyboardInsetBottom, scrollToBottom]); + useEffect(() => { if (scrollToBottomSignal <= 0) return; userReadingHistoryRef.current = false; @@ -332,6 +395,7 @@ export const ThreadViewport = forwardRef {hasMessages ? (
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 12c0ac9d..8dd59423 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -134,6 +134,28 @@ function mockBlobUrls() { }); } +function stubVisualViewport({ + height, + offsetTop = 0, +}: { + height: number; + offsetTop?: number; +}) { + const target = new EventTarget(); + vi.stubGlobal("visualViewport", { + width: 390, + height, + offsetTop, + offsetLeft: 0, + pageTop: offsetTop, + pageLeft: 0, + scale: 1, + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), + } as unknown as VisualViewport); +} + afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); @@ -1124,6 +1146,33 @@ describe("ThreadComposer", () => { }); }); + it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => { + vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue( + rect({ top: 120, bottom: 220, width: 390, height: 100 }), + ); + Object.defineProperty(window, "innerHeight", { + value: 800, + configurable: true, + }); + stubVisualViewport({ height: 300 }); + render( + , + ); + const input = screen.getByLabelText("Message input"); + + fireEvent.change(input, { target: { value: "/" } }); + + await waitFor(() => { + const palette = screen.getByRole("listbox", { name: "Slash commands" }); + expect(palette.className).toContain("bottom-full"); + expect(palette).toHaveStyle({ maxHeight: "112px" }); + }); + }); + it("dismisses the slash command palette on outside click", () => { render(
diff --git a/webui/src/tests/thread-viewport.test.tsx b/webui/src/tests/thread-viewport.test.tsx index 6a442db4..aa4db228 100644 --- a/webui/src/tests/thread-viewport.test.tsx +++ b/webui/src/tests/thread-viewport.test.tsx @@ -29,6 +29,59 @@ interface ResizeObserverInstance { disconnect: ReturnType; } +function stubVisualViewport({ + height, + innerHeight, + offsetTop = 0, +}: { + height: number; + innerHeight: number; + offsetTop?: number; +}) { + const originalInnerHeight = window.innerHeight; + const originalVisualViewport = window.visualViewport; + const target = new EventTarget(); + const viewport = { + width: 390, + height, + offsetTop, + offsetLeft: 0, + pageTop: offsetTop, + pageLeft: 0, + scale: 1, + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), + } as unknown as VisualViewport; + + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: innerHeight, + }); + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: viewport, + }); + + return { + viewport, + restore: () => { + Object.defineProperty(window, "innerHeight", { + configurable: true, + value: originalInnerHeight, + }); + if (originalVisualViewport) { + Object.defineProperty(window, "visualViewport", { + configurable: true, + value: originalVisualViewport, + }); + } else { + Reflect.deleteProperty(window, "visualViewport"); + } + }, + }; +} + function makeLongMessages(count: number): UIMessage[] { return Array.from({ length: count }, (_, index) => ({ id: `m${index}`, @@ -129,6 +182,46 @@ describe("ThreadViewport", () => { } }); + it("keeps the thread scrollport above a mobile soft keyboard", async () => { + const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 }); + try { + const { container } = render( + } + />, + ); + const scroller = container.firstElementChild?.firstElementChild as HTMLElement; + Object.defineProperties(scroller, { + scrollHeight: { configurable: true, value: 2400 }, + clientHeight: { configurable: true, value: 600 }, + scrollTop: { configurable: true, value: 0 }, + }); + + act(() => { + scroller.dispatchEvent(new Event("scroll")); + }); + + const input = screen.getByLabelText("Message input"); + act(() => { + input.focus(); + fireEvent.focusIn(input); + }); + + await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" })); + const button = screen.getByRole("button", { name: "Scroll to bottom" }); + expect(button.parentElement).toHaveStyle({ bottom: "512px" }); + + act(() => { + visualViewport.viewport.dispatchEvent(new Event("resize")); + }); + expect(scroller).toHaveStyle({ bottom: "320px" }); + } finally { + visualViewport.restore(); + } + }); + it("hides the scroll-to-bottom button when disabled for the welcome view", () => { const { container } = render(