diff --git a/web/src/hooks/useViewportHeight.test.ts b/web/src/hooks/useViewportHeight.test.ts index 8262af94..5a2d6664 100644 --- a/web/src/hooks/useViewportHeight.test.ts +++ b/web/src/hooks/useViewportHeight.test.ts @@ -60,4 +60,47 @@ describe('useViewportHeight update logic', () => { expect(root.style.getPropertyValue('--app-viewport-height')).toBe('') }) + + it('resets page scroll when keyboard is open', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + // Simulate: keyboard open AND page has been scrolled by iOS + Object.defineProperty(window, 'scrollY', { value: 120, configurable: true }) + + const viewportHeight = 400 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } + } + + expect(scrollToSpy).toHaveBeenCalledWith(0, 0) + + // Cleanup + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }) + scrollToSpy.mockRestore() + }) + + it('does not reset scroll when page is not scrolled', () => { + const scrollToSpy = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) + + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true }) + + const viewportHeight = 400 + const windowHeight = 800 + const diff = windowHeight - viewportHeight + if (diff > 1) { + root.style.setProperty('--app-viewport-height', `${viewportHeight}px`) + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } + } + + expect(scrollToSpy).not.toHaveBeenCalled() + + scrollToSpy.mockRestore() + }) }) diff --git a/web/src/hooks/useViewportHeight.ts b/web/src/hooks/useViewportHeight.ts index b8c500c8..fbf206f3 100644 --- a/web/src/hooks/useViewportHeight.ts +++ b/web/src/hooks/useViewportHeight.ts @@ -32,15 +32,26 @@ export function useViewportHeight(): void { const diff = window.innerHeight - viewport.height if (diff > 1) { root.style.setProperty('--app-viewport-height', `${viewport.height}px`) + // On iOS PWA (black-translucent status bar + viewport-fit=cover), + // the browser scrolls the page upward when the keyboard opens to + // keep the focused input visible. This pushes the header behind + // the iOS status bar. Reset the page scroll so the app stays + // pinned to the top — the inner flex layout already handles + // keeping the composer visible. + if (window.scrollY > 0) { + window.scrollTo(0, 0) + } } else { root.style.removeProperty('--app-viewport-height') } } viewport.addEventListener('resize', update) + viewport.addEventListener('scroll', update) return () => { viewport.removeEventListener('resize', update) + viewport.removeEventListener('scroll', update) root.style.removeProperty('--app-viewport-height') } }, [])