From 5b59fc5c963b528c416e2458556e388b0356535c Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 20 Jan 2026 13:13:21 -0500 Subject: [PATCH 1/4] Add DebugOverlay and enhance visual viewport handling Introduces a DebugOverlay component for UI debugging, activated via the 'debug-ui' query param. Improves visual viewport and keyboard inset handling by tracking additional metrics (offsets, viewport size), updating related CSS variables, and refining polling and event logic in useVisualViewport. Updates tests and styles to support new debug and viewport features, and improves keyboard visibility detection in Terminal. --- src/client/App.tsx | 7 + src/client/__tests__/visualViewport.test.ts | 61 +++++-- .../__tests__/visualViewportHook.test.tsx | 21 ++- src/client/components/DebugOverlay.tsx | 166 ++++++++++++++++++ src/client/components/Terminal.tsx | 19 +- src/client/hooks/useVisualViewport.ts | 147 +++++++++++++++- src/client/main.tsx | 6 + src/client/styles/index.css | 56 +++++- 8 files changed, 451 insertions(+), 32 deletions(-) create mode 100644 src/client/components/DebugOverlay.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index cd96df3..f846c3e 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -5,6 +5,7 @@ import SessionList from './components/SessionList' import Terminal from './components/Terminal' import NewSessionModal from './components/NewSessionModal' import SettingsModal from './components/SettingsModal' +import DebugOverlay from './components/DebugOverlay' import { useSessionStore } from './stores/sessionStore' import { useSettingsStore, @@ -24,6 +25,10 @@ interface ServerInfo { } export default function App() { + const debugUI = useMemo(() => { + if (typeof window === 'undefined' || !window.location) return false + return new URLSearchParams(window.location.search).has('debug-ui') + }, []) const [isModalOpen, setIsModalOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [serverError, setServerError] = useState(null) @@ -413,6 +418,8 @@ export default function App() { isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} /> + + {debugUI && } ) } diff --git a/src/client/__tests__/visualViewport.test.ts b/src/client/__tests__/visualViewport.test.ts index 3653ebf..2884f7e 100644 --- a/src/client/__tests__/visualViewport.test.ts +++ b/src/client/__tests__/visualViewport.test.ts @@ -14,12 +14,12 @@ function createMockClassList() { describe('visual viewport helpers', () => { test('updates keyboard inset and clears it', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, removeProperty: (_key: string) => { - style.value = '' + style.values.delete(_key) }, } const classList = createMockClassList() @@ -27,15 +27,23 @@ describe('visual viewport helpers', () => { documentElement: { style, classList }, } as unknown as Document const win = { innerHeight: 900 } as Window - const viewport = { height: 700 } as VisualViewport + const viewport = { height: 700, width: 800 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) expect(updated).toBe(true) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('0px') + expect(style.values.get('--viewport-offset-left')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('700px') + expect(style.values.get('--visual-viewport-width')).toBe('800px') expect(classList.has('keyboard-visible')).toBe(true) clearKeyboardInset(doc) - expect(style.value).toBe('') + expect(style.values.has('--keyboard-inset')).toBe(false) + expect(style.values.has('--viewport-offset-top')).toBe(false) + expect(style.values.has('--viewport-offset-left')).toBe(false) + expect(style.values.has('--visual-viewport-height')).toBe(false) + expect(style.values.has('--visual-viewport-width')).toBe(false) expect(classList.has('keyboard-visible')).toBe(false) }) @@ -50,9 +58,9 @@ describe('visual viewport helpers', () => { test('clamps negative keyboard inset to zero', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, } const classList = createMockClassList() @@ -60,20 +68,22 @@ describe('visual viewport helpers', () => { documentElement: { style, classList }, } as unknown as Document const win = { innerHeight: 600 } as Window - const viewport = { height: 800 } as VisualViewport + const viewport = { height: 800, width: 900 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) expect(updated).toBe(true) - expect(style.value).toBe('0px') + expect(style.values.get('--keyboard-inset')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('800px') + expect(style.values.get('--visual-viewport-width')).toBe('900px') // Keyboard not visible when height is 0 expect(classList.has('keyboard-visible')).toBe(false) }) test('toggles keyboard-visible class based on threshold', () => { const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, } const classList = createMockClassList() @@ -83,11 +93,34 @@ describe('visual viewport helpers', () => { const win = { innerHeight: 900 } as Window // Below threshold (100px) - not visible - updateKeyboardInset({ viewport: { height: 850 } as VisualViewport, win, doc }) + updateKeyboardInset({ viewport: { height: 850, width: 800 } as VisualViewport, win, doc }) expect(classList.has('keyboard-visible')).toBe(false) // Above threshold - visible - updateKeyboardInset({ viewport: { height: 700 } as VisualViewport, win, doc }) + updateKeyboardInset({ viewport: { height: 700, width: 800 } as VisualViewport, win, doc }) expect(classList.has('keyboard-visible')).toBe(true) }) + + test('accounts for visual viewport offset in inset calculation', () => { + const style = { + values: new Map(), + setProperty: (_key: string, val: string) => { + style.values.set(_key, val) + }, + } + const classList = createMockClassList() + const doc = { + documentElement: { style, classList }, + } as unknown as Document + const win = { innerHeight: 900 } as Window + const viewport = { height: 600, width: 700, offsetTop: 100, offsetLeft: 20 } as VisualViewport + + const updated = updateKeyboardInset({ viewport, win, doc }) + expect(updated).toBe(true) + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('100px') + expect(style.values.get('--visual-viewport-height')).toBe('600px') + expect(style.values.get('--viewport-offset-left')).toBe('20px') + expect(style.values.get('--visual-viewport-width')).toBe('700px') + }) }) diff --git a/src/client/__tests__/visualViewportHook.test.tsx b/src/client/__tests__/visualViewportHook.test.tsx index 2115ea7..db519a8 100644 --- a/src/client/__tests__/visualViewportHook.test.tsx +++ b/src/client/__tests__/visualViewportHook.test.tsx @@ -34,18 +34,19 @@ describe('useVisualViewport', () => { const events = new Map() const removed: string[] = [] const style = { - value: '', + values: new Map(), setProperty: (_key: string, val: string) => { - style.value = val + style.values.set(_key, val) }, removeProperty: (_key: string) => { - style.value = '' + style.values.delete(_key) }, } const classList = createMockClassList() const viewport = { height: 700, + width: 800, addEventListener: (event: string, handler: EventListener) => { events.set(event, handler) }, @@ -69,18 +70,26 @@ describe('useVisualViewport', () => { renderer = TestRenderer.create() }) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') + expect(style.values.get('--viewport-offset-top')).toBe('0px') + expect(style.values.get('--viewport-offset-left')).toBe('0px') + expect(style.values.get('--visual-viewport-height')).toBe('700px') + expect(style.values.get('--visual-viewport-width')).toBe('800px') expect(events.has('resize')).toBe(true) expect(events.has('scroll')).toBe(true) events.get('resize')?.({} as Event) - expect(style.value).toBe('200px') + expect(style.values.get('--keyboard-inset')).toBe('200px') act(() => { renderer.unmount() }) expect(removed).toEqual(['resize', 'scroll']) - expect(style.value).toBe('') + expect(style.values.has('--keyboard-inset')).toBe(false) + expect(style.values.has('--viewport-offset-top')).toBe(false) + expect(style.values.has('--viewport-offset-left')).toBe(false) + expect(style.values.has('--visual-viewport-height')).toBe(false) + expect(style.values.has('--visual-viewport-width')).toBe(false) }) }) diff --git a/src/client/components/DebugOverlay.tsx b/src/client/components/DebugOverlay.tsx new file mode 100644 index 0000000..0c9b274 --- /dev/null +++ b/src/client/components/DebugOverlay.tsx @@ -0,0 +1,166 @@ +import { useEffect, useMemo, useState } from 'react' + +type ViewportMetrics = { + innerWidth: number + innerHeight: number + visualWidth: number | null + visualHeight: number | null + offsetTop: number | null + offsetLeft: number | null + scale: number | null + keyboardVisibleClass: boolean + activeElement: string + cssVars: Record +} + +type HitInfo = { + x: number + y: number + target: string + rect: DOMRect | null +} | null + +function readCssVars(): Record { + const root = document.documentElement + const computed = window.getComputedStyle(root) + const names = [ + '--keyboard-inset', + '--viewport-offset-top', + '--viewport-offset-left', + '--visual-viewport-height', + '--visual-viewport-width', + ] + const vars: Record = {} + for (const name of names) { + vars[name] = computed.getPropertyValue(name).trim() + } + return vars +} + +function getActiveElementLabel(): string { + const active = document.activeElement + if (!active) return 'none' + const el = active as HTMLElement + const name = el.tagName.toLowerCase() + const id = el.id ? `#${el.id}` : '' + const classes = el.className ? `.${String(el.className).trim().replace(/\s+/g, '.')}` : '' + return `${name}${id}${classes}` +} + +function getViewportMetrics(): ViewportMetrics { + const vv = window.visualViewport + return { + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + visualWidth: vv ? vv.width : null, + visualHeight: vv ? vv.height : null, + offsetTop: vv ? vv.offsetTop : null, + offsetLeft: vv ? vv.offsetLeft : null, + scale: vv ? vv.scale : null, + keyboardVisibleClass: document.documentElement.classList.contains('keyboard-visible'), + activeElement: getActiveElementLabel(), + cssVars: readCssVars(), + } +} + +function formatTarget(el: Element | null): string { + if (!el) return 'none' + const htmlEl = el as HTMLElement + const name = htmlEl.tagName.toLowerCase() + const id = htmlEl.id ? `#${htmlEl.id}` : '' + const classes = htmlEl.className ? `.${String(htmlEl.className).trim().replace(/\s+/g, '.')}` : '' + return `${name}${id}${classes}` +} + +export default function DebugOverlay() { + const [metrics, setMetrics] = useState(() => getViewportMetrics()) + const [hitInfo, setHitInfo] = useState(null) + + useEffect(() => { + const update = () => { + setMetrics(getViewportMetrics()) + } + + const handleTouch = (event: TouchEvent) => { + const touch = event.touches[0] ?? event.changedTouches[0] + if (!touch) return + const x = touch.clientX + const y = touch.clientY + const target = document.elementFromPoint(x, y) + const rect = target ? target.getBoundingClientRect() : null + setHitInfo({ x, y, target: formatTarget(target), rect }) + } + + const handlePointer = (event: PointerEvent) => { + const x = event.clientX + const y = event.clientY + const target = document.elementFromPoint(x, y) + const rect = target ? target.getBoundingClientRect() : null + setHitInfo({ x, y, target: formatTarget(target), rect }) + } + + update() + const vv = window.visualViewport + vv?.addEventListener('resize', update) + vv?.addEventListener('scroll', update) + window.addEventListener('resize', update) + window.addEventListener('orientationchange', update) + window.addEventListener('touchstart', handleTouch, { passive: true }) + window.addEventListener('pointerdown', handlePointer, { passive: true }) + + return () => { + vv?.removeEventListener('resize', update) + vv?.removeEventListener('scroll', update) + window.removeEventListener('resize', update) + window.removeEventListener('orientationchange', update) + window.removeEventListener('touchstart', handleTouch) + window.removeEventListener('pointerdown', handlePointer) + } + }, []) + + const hitStyle = useMemo(() => { + if (!hitInfo) return { display: 'none' } + return { + position: 'fixed' as const, + left: `${hitInfo.x}px`, + top: `${hitInfo.y}px`, + width: '12px', + height: '12px', + marginLeft: '-6px', + marginTop: '-6px', + borderRadius: '999px', + background: 'rgba(255, 0, 0, 0.7)', + zIndex: 9999, + pointerEvents: 'none' as const, + } + }, [hitInfo]) + + const rectStyle = useMemo(() => { + if (!hitInfo?.rect) return { display: 'none' } + const { rect } = hitInfo + return { + position: 'fixed' as const, + left: `${rect.left}px`, + top: `${rect.top}px`, + width: `${rect.width}px`, + height: `${rect.height}px`, + border: '1px solid rgba(0, 255, 255, 0.7)', + zIndex: 9998, + pointerEvents: 'none' as const, + } + }, [hitInfo]) + + return ( +
+
+
inner: {metrics.innerWidth}x{metrics.innerHeight}
+
visual: {metrics.visualWidth ?? 'n/a'}x{metrics.visualHeight ?? 'n/a'} offset {metrics.offsetLeft ?? 'n/a'},{metrics.offsetTop ?? 'n/a'} scale {metrics.scale ?? 'n/a'}
+
keyboard-visible: {String(metrics.keyboardVisibleClass)} active: {metrics.activeElement}
+
vars: {Object.entries(metrics.cssVars).map(([k, v]) => `${k}=${v || 'unset'}`).join(' ')}
+
hit: {hitInfo ? `${hitInfo.x},${hitInfo.y} -> ${hitInfo.target}` : 'none'}
+
+
+
+
+ ) +} diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 466623e..f2b77d8 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -837,10 +837,27 @@ export default function Terminal({ }, [session, sendMessage, containerRef, inTmuxCopyModeRef, setTmuxCopyMode]) const isKeyboardVisible = useCallback(() => { + if (typeof document === 'undefined') return false const container = containerRef.current if (!container) return false const textarea = container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null - return textarea ? document.activeElement === textarea : false + if (textarea && document.activeElement === textarea) { + return true + } + + const activeElement = document.activeElement + const inputActive = + typeof HTMLInputElement !== 'undefined' && activeElement instanceof HTMLInputElement + const textareaActive = + typeof HTMLTextAreaElement !== 'undefined' && activeElement instanceof HTMLTextAreaElement + if (inputActive || textareaActive) { + return false + } + if (activeElement && (activeElement as HTMLElement).isContentEditable) { + return false + } + + return !!document.documentElement?.classList?.contains('keyboard-visible') }, [containerRef]) useEffect(() => { diff --git a/src/client/hooks/useVisualViewport.ts b/src/client/hooks/useVisualViewport.ts index 971fb7a..fce5080 100644 --- a/src/client/hooks/useVisualViewport.ts +++ b/src/client/hooks/useVisualViewport.ts @@ -22,14 +22,35 @@ export function updateKeyboardInset({ return false } - const keyboardHeight = win.innerHeight - viewport.height + const offsetTop = Math.max(0, viewport.offsetTop || 0) + const offsetLeft = Math.max(0, viewport.offsetLeft || 0) + const keyboardHeight = win.innerHeight - (viewport.height + offsetTop) + const clampedKeyboardHeight = Math.max(0, keyboardHeight) + const viewportHeight = Math.max(0, viewport.height || 0) + const viewportWidth = Math.max(0, viewport.width || 0) doc.documentElement.style.setProperty( '--keyboard-inset', - `${Math.max(0, keyboardHeight)}px` + `${clampedKeyboardHeight}px` + ) + doc.documentElement.style.setProperty( + '--viewport-offset-top', + `${offsetTop}px` + ) + doc.documentElement.style.setProperty( + '--viewport-offset-left', + `${offsetLeft}px` + ) + doc.documentElement.style.setProperty( + '--visual-viewport-height', + `${viewportHeight}px` + ) + doc.documentElement.style.setProperty( + '--visual-viewport-width', + `${viewportWidth}px` ) // Toggle class for CSS-based safe area handling - if (keyboardHeight > KEYBOARD_THRESHOLD) { + if (clampedKeyboardHeight > KEYBOARD_THRESHOLD) { doc.documentElement.classList.add('keyboard-visible') } else { doc.documentElement.classList.remove('keyboard-visible') @@ -40,6 +61,10 @@ export function updateKeyboardInset({ export function clearKeyboardInset(doc: Document) { doc.documentElement.style.removeProperty('--keyboard-inset') + doc.documentElement.style.removeProperty('--viewport-offset-top') + doc.documentElement.style.removeProperty('--viewport-offset-left') + doc.documentElement.style.removeProperty('--visual-viewport-height') + doc.documentElement.style.removeProperty('--visual-viewport-width') doc.documentElement.classList.remove('keyboard-visible') } @@ -48,20 +73,136 @@ export function useVisualViewport() { const viewport = window.visualViewport if (!viewport) return + let rafId: number | null = null + let pollTimer: number | null = null + let basePollTimer: number | null = null + + const isTextInputActive = () => { + const active = document.activeElement + if (!active) return false + const tagName = (active as HTMLElement).tagName?.toLowerCase() + if (tagName === 'input' || tagName === 'textarea') { + return true + } + return Boolean((active as HTMLElement).isContentEditable) + } + const updateViewport = () => { updateKeyboardInset({ viewport, win: window, doc: document }) } + const shouldPollAlways = + typeof document.documentElement?.classList?.contains === 'function' && + document.documentElement.classList.contains('ios') + + const startRafBurst = () => { + if (typeof window.requestAnimationFrame !== 'function') { + updateViewport() + return + } + if (rafId !== null) { + window.cancelAnimationFrame(rafId) + } + const start = performance.now() + const tick = () => { + updateViewport() + if (performance.now() - start < 800) { + rafId = window.requestAnimationFrame(tick) + } else { + rafId = null + } + } + rafId = window.requestAnimationFrame(tick) + } + + const pollTick = () => { + if (!isTextInputActive()) { + stopPolling() + return + } + updateViewport() + } + + const startPolling = () => { + if (pollTimer !== null || typeof window.setInterval !== 'function') return + pollTimer = window.setInterval(pollTick, 250) + pollTick() + } + + const stopPolling = () => { + if (pollTimer === null || typeof window.clearInterval !== 'function') return + window.clearInterval(pollTimer) + pollTimer = null + } + + const startBasePolling = () => { + if (!shouldPollAlways || basePollTimer !== null || typeof window.setInterval !== 'function') { + return + } + basePollTimer = window.setInterval(updateViewport, 250) + } + + const stopBasePolling = () => { + if (basePollTimer === null || typeof window.clearInterval !== 'function') return + window.clearInterval(basePollTimer) + basePollTimer = null + } + + const syncActiveState = () => { + if (isTextInputActive()) { + startRafBurst() + startPolling() + updateViewport() + } else { + stopPolling() + } + } + + const handleFocusIn = () => { + syncActiveState() + } + + const handleFocusOut = () => { + syncActiveState() + } + // Initial update updateViewport() + syncActiveState() + startBasePolling() // Listen for viewport changes (keyboard show/hide, zoom, scroll) viewport.addEventListener('resize', updateViewport) viewport.addEventListener('scroll', updateViewport) + if (typeof window.addEventListener === 'function') { + window.addEventListener('resize', updateViewport) + window.addEventListener('orientationchange', startRafBurst) + } + if (typeof document.addEventListener === 'function') { + document.addEventListener('focusin', handleFocusIn) + document.addEventListener('focusout', handleFocusOut) + document.addEventListener('focus', handleFocusIn, true) + document.addEventListener('blur', handleFocusOut, true) + } return () => { viewport.removeEventListener('resize', updateViewport) viewport.removeEventListener('scroll', updateViewport) + if (typeof window.removeEventListener === 'function') { + window.removeEventListener('resize', updateViewport) + window.removeEventListener('orientationchange', startRafBurst) + } + if (typeof document.removeEventListener === 'function') { + document.removeEventListener('focusin', handleFocusIn) + document.removeEventListener('focusout', handleFocusOut) + document.removeEventListener('focus', handleFocusIn, true) + document.removeEventListener('blur', handleFocusOut, true) + } + if (rafId !== null && typeof window.cancelAnimationFrame === 'function') { + window.cancelAnimationFrame(rafId) + } + stopPolling() + stopBasePolling() clearKeyboardInset(document) } }, []) diff --git a/src/client/main.tsx b/src/client/main.tsx index 3061e99..5978a1e 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -12,6 +12,12 @@ if (isIOSDevice()) { if (isIOSPWA()) { document.documentElement.classList.add('ios-pwa') } +if (typeof window !== 'undefined' && window.location) { + const params = new URLSearchParams(window.location.search) + if (params.has('debug-ui')) { + document.documentElement.classList.add('debug-ui') + } +} const container = document.getElementById('root') if (!container) { diff --git a/src/client/styles/index.css b/src/client/styles/index.css index 6f0adf3..b4a5f5e 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -147,9 +147,46 @@ body { overflow: hidden; } +/* Align app root to the visual viewport on iOS PWA to keep hit-testing accurate */ +@media (max-width: 767px) { + .ios #root { + position: fixed; + top: 0; + left: 0; + width: var(--visual-viewport-width, 100vw); + height: var(--visual-viewport-height, 100dvh); + transform: translate3d(var(--viewport-offset-left, 0px), var(--viewport-offset-top, 0px), 0); + transform-origin: top left; + } +} + +.debug-ui #root { + outline: 2px solid rgba(255, 0, 0, 0.7); +} + +.debug-ui .terminal-mobile-overlay { + outline: 2px solid rgba(0, 255, 0, 0.7); +} + +.debug-ui .terminal-controls { + outline: 2px solid rgba(255, 255, 0, 0.7); +} + +.debug-ui .terminal-controls button { + outline: 1px dashed rgba(255, 255, 0, 0.6); +} + +.debug-ui .xterm { + outline: 2px solid rgba(0, 153, 255, 0.7); +} + /* Keyboard inset variable (set by useVisualViewport hook) */ :root { --keyboard-inset: 0px; + --viewport-offset-top: 0px; + --viewport-offset-left: 0px; + --visual-viewport-height: 100dvh; + --visual-viewport-width: 100vw; } ::selection { @@ -327,6 +364,8 @@ body { caret-color: transparent; width: 1px; height: 1px; + font-size: 16px; + line-height: 16px; pointer-events: none; -webkit-user-select: text; user-select: text; @@ -406,16 +445,13 @@ body { /* Mobile terminal overlay */ @media (max-width: 767px) { .terminal-mobile-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: var(--keyboard-inset, 0px); + position: relative; + height: 100%; z-index: 30; display: flex; flex-direction: column; background: var(--bg-base); - transition: bottom 0.1s ease-out; + transition: height 0.1s ease-out; } } @@ -489,11 +525,15 @@ body { } .ios .terminal-mobile-overlay { - bottom: var(--keyboard-inset, 0px); + height: var(--visual-viewport-height, 100dvh); +} + +.ios-pwa .terminal-mobile-overlay { + height: 100%; } .ios:not(.keyboard-visible) .terminal-mobile-overlay { - bottom: calc(var(--keyboard-inset, 0px) + env(safe-area-inset-bottom, 0px)); + padding-bottom: env(safe-area-inset-bottom, 0px); } /* Hide scrollbar but allow scrolling */ From 72ab23aa4a957431915523c7932c32c85ecedb83 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 20 Jan 2026 13:45:07 -0500 Subject: [PATCH 2/4] Improve keyboard detection and viewport handling Refines keyboard visibility logic by using isKeyboardVisible in Terminal and updates keyboard height calculation to consider document height. Adds orientation change handling in useVisualViewport and updates related tests. Also sets position and z-index for terminal controls in CSS. --- src/client/__tests__/visualViewport.test.ts | 10 +++++----- src/client/__tests__/visualViewportHook.test.tsx | 1 + src/client/components/Terminal.tsx | 5 ++--- src/client/hooks/useVisualViewport.ts | 15 ++++++++++++--- src/client/styles/index.css | 2 ++ 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/client/__tests__/visualViewport.test.ts b/src/client/__tests__/visualViewport.test.ts index 2884f7e..b9222a1 100644 --- a/src/client/__tests__/visualViewport.test.ts +++ b/src/client/__tests__/visualViewport.test.ts @@ -26,7 +26,7 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window const viewport = { height: 700, width: 800 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) @@ -51,7 +51,7 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style: { setProperty: () => {} } }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window expect(updateKeyboardInset({ viewport: null, win, doc })).toBe(false) }) @@ -67,7 +67,7 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 600 } as Window + const win = { innerHeight: 600, screen: { height: 600 } } as Window const viewport = { height: 800, width: 900 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) @@ -90,7 +90,7 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window // Below threshold (100px) - not visible updateKeyboardInset({ viewport: { height: 850, width: 800 } as VisualViewport, win, doc }) @@ -112,7 +112,7 @@ describe('visual viewport helpers', () => { const doc = { documentElement: { style, classList }, } as unknown as Document - const win = { innerHeight: 900 } as Window + const win = { innerHeight: 900, screen: { height: 900 } } as Window const viewport = { height: 600, width: 700, offsetTop: 100, offsetLeft: 20 } as VisualViewport const updated = updateKeyboardInset({ viewport, win, doc }) diff --git a/src/client/__tests__/visualViewportHook.test.tsx b/src/client/__tests__/visualViewportHook.test.tsx index db519a8..7bc7ee3 100644 --- a/src/client/__tests__/visualViewportHook.test.tsx +++ b/src/client/__tests__/visualViewportHook.test.tsx @@ -57,6 +57,7 @@ describe('useVisualViewport', () => { globalAny.window = { innerHeight: 900, + screen: { height: 900 }, visualViewport: viewport, } as unknown as Window & typeof globalThis diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index f2b77d8..51b739f 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -870,8 +870,7 @@ export default function Terminal({ if (!a11yRoot || !a11yTree) return const updatePointerEvents = () => { - const textarea = container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null - const keyboardVisible = textarea ? document.activeElement === textarea : false + const keyboardVisible = isKeyboardVisible() if (keyboardVisible && !isSelectingTextRef.current) { a11yRoot.style.pointerEvents = 'none' a11yTree.style.pointerEvents = 'none' @@ -889,7 +888,7 @@ export default function Terminal({ document.removeEventListener('focusin', updatePointerEvents) document.removeEventListener('focusout', updatePointerEvents) } - }, [containerRef, isiOS, isSelectingText]) + }, [containerRef, isiOS, isSelectingText, isKeyboardVisible]) return (
{ + startRafBurst() + } + // Initial update updateViewport() syncActiveState() @@ -176,7 +185,7 @@ export function useVisualViewport() { viewport.addEventListener('scroll', updateViewport) if (typeof window.addEventListener === 'function') { window.addEventListener('resize', updateViewport) - window.addEventListener('orientationchange', startRafBurst) + window.addEventListener('orientationchange', handleOrientationChange) } if (typeof document.addEventListener === 'function') { document.addEventListener('focusin', handleFocusIn) @@ -190,7 +199,7 @@ export function useVisualViewport() { viewport.removeEventListener('scroll', updateViewport) if (typeof window.removeEventListener === 'function') { window.removeEventListener('resize', updateViewport) - window.removeEventListener('orientationchange', startRafBurst) + window.removeEventListener('orientationchange', handleOrientationChange) } if (typeof document.removeEventListener === 'function') { document.removeEventListener('focusin', handleFocusIn) diff --git a/src/client/styles/index.css b/src/client/styles/index.css index b4a5f5e..45d6db7 100644 --- a/src/client/styles/index.css +++ b/src/client/styles/index.css @@ -507,6 +507,8 @@ body { /* Terminal control strip styling */ .terminal-controls { flex-shrink: 0; + position: relative; + z-index: 40; } .terminal-key { From 510283b7d73e58cf45f7b8480540ccafb02194b0 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Tue, 20 Jan 2026 14:23:09 -0500 Subject: [PATCH 3/4] fix: stabilize iOS keyboard controls and remove debug UI --- src/client/App.tsx | 6 - src/client/components/DebugOverlay.tsx | 166 ------------------------- src/client/components/Terminal.tsx | 5 +- src/client/hooks/useVisualViewport.ts | 7 +- src/client/main.tsx | 6 - src/client/styles/index.css | 19 --- 6 files changed, 4 insertions(+), 205 deletions(-) delete mode 100644 src/client/components/DebugOverlay.tsx diff --git a/src/client/App.tsx b/src/client/App.tsx index f846c3e..9d46854 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -5,7 +5,6 @@ import SessionList from './components/SessionList' import Terminal from './components/Terminal' import NewSessionModal from './components/NewSessionModal' import SettingsModal from './components/SettingsModal' -import DebugOverlay from './components/DebugOverlay' import { useSessionStore } from './stores/sessionStore' import { useSettingsStore, @@ -25,10 +24,6 @@ interface ServerInfo { } export default function App() { - const debugUI = useMemo(() => { - if (typeof window === 'undefined' || !window.location) return false - return new URLSearchParams(window.location.search).has('debug-ui') - }, []) const [isModalOpen, setIsModalOpen] = useState(false) const [isSettingsOpen, setIsSettingsOpen] = useState(false) const [serverError, setServerError] = useState(null) @@ -419,7 +414,6 @@ export default function App() { onClose={() => setIsSettingsOpen(false)} /> - {debugUI && }
) } diff --git a/src/client/components/DebugOverlay.tsx b/src/client/components/DebugOverlay.tsx deleted file mode 100644 index 0c9b274..0000000 --- a/src/client/components/DebugOverlay.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { useEffect, useMemo, useState } from 'react' - -type ViewportMetrics = { - innerWidth: number - innerHeight: number - visualWidth: number | null - visualHeight: number | null - offsetTop: number | null - offsetLeft: number | null - scale: number | null - keyboardVisibleClass: boolean - activeElement: string - cssVars: Record -} - -type HitInfo = { - x: number - y: number - target: string - rect: DOMRect | null -} | null - -function readCssVars(): Record { - const root = document.documentElement - const computed = window.getComputedStyle(root) - const names = [ - '--keyboard-inset', - '--viewport-offset-top', - '--viewport-offset-left', - '--visual-viewport-height', - '--visual-viewport-width', - ] - const vars: Record = {} - for (const name of names) { - vars[name] = computed.getPropertyValue(name).trim() - } - return vars -} - -function getActiveElementLabel(): string { - const active = document.activeElement - if (!active) return 'none' - const el = active as HTMLElement - const name = el.tagName.toLowerCase() - const id = el.id ? `#${el.id}` : '' - const classes = el.className ? `.${String(el.className).trim().replace(/\s+/g, '.')}` : '' - return `${name}${id}${classes}` -} - -function getViewportMetrics(): ViewportMetrics { - const vv = window.visualViewport - return { - innerWidth: window.innerWidth, - innerHeight: window.innerHeight, - visualWidth: vv ? vv.width : null, - visualHeight: vv ? vv.height : null, - offsetTop: vv ? vv.offsetTop : null, - offsetLeft: vv ? vv.offsetLeft : null, - scale: vv ? vv.scale : null, - keyboardVisibleClass: document.documentElement.classList.contains('keyboard-visible'), - activeElement: getActiveElementLabel(), - cssVars: readCssVars(), - } -} - -function formatTarget(el: Element | null): string { - if (!el) return 'none' - const htmlEl = el as HTMLElement - const name = htmlEl.tagName.toLowerCase() - const id = htmlEl.id ? `#${htmlEl.id}` : '' - const classes = htmlEl.className ? `.${String(htmlEl.className).trim().replace(/\s+/g, '.')}` : '' - return `${name}${id}${classes}` -} - -export default function DebugOverlay() { - const [metrics, setMetrics] = useState(() => getViewportMetrics()) - const [hitInfo, setHitInfo] = useState(null) - - useEffect(() => { - const update = () => { - setMetrics(getViewportMetrics()) - } - - const handleTouch = (event: TouchEvent) => { - const touch = event.touches[0] ?? event.changedTouches[0] - if (!touch) return - const x = touch.clientX - const y = touch.clientY - const target = document.elementFromPoint(x, y) - const rect = target ? target.getBoundingClientRect() : null - setHitInfo({ x, y, target: formatTarget(target), rect }) - } - - const handlePointer = (event: PointerEvent) => { - const x = event.clientX - const y = event.clientY - const target = document.elementFromPoint(x, y) - const rect = target ? target.getBoundingClientRect() : null - setHitInfo({ x, y, target: formatTarget(target), rect }) - } - - update() - const vv = window.visualViewport - vv?.addEventListener('resize', update) - vv?.addEventListener('scroll', update) - window.addEventListener('resize', update) - window.addEventListener('orientationchange', update) - window.addEventListener('touchstart', handleTouch, { passive: true }) - window.addEventListener('pointerdown', handlePointer, { passive: true }) - - return () => { - vv?.removeEventListener('resize', update) - vv?.removeEventListener('scroll', update) - window.removeEventListener('resize', update) - window.removeEventListener('orientationchange', update) - window.removeEventListener('touchstart', handleTouch) - window.removeEventListener('pointerdown', handlePointer) - } - }, []) - - const hitStyle = useMemo(() => { - if (!hitInfo) return { display: 'none' } - return { - position: 'fixed' as const, - left: `${hitInfo.x}px`, - top: `${hitInfo.y}px`, - width: '12px', - height: '12px', - marginLeft: '-6px', - marginTop: '-6px', - borderRadius: '999px', - background: 'rgba(255, 0, 0, 0.7)', - zIndex: 9999, - pointerEvents: 'none' as const, - } - }, [hitInfo]) - - const rectStyle = useMemo(() => { - if (!hitInfo?.rect) return { display: 'none' } - const { rect } = hitInfo - return { - position: 'fixed' as const, - left: `${rect.left}px`, - top: `${rect.top}px`, - width: `${rect.width}px`, - height: `${rect.height}px`, - border: '1px solid rgba(0, 255, 255, 0.7)', - zIndex: 9998, - pointerEvents: 'none' as const, - } - }, [hitInfo]) - - return ( -
-
-
inner: {metrics.innerWidth}x{metrics.innerHeight}
-
visual: {metrics.visualWidth ?? 'n/a'}x{metrics.visualHeight ?? 'n/a'} offset {metrics.offsetLeft ?? 'n/a'},{metrics.offsetTop ?? 'n/a'} scale {metrics.scale ?? 'n/a'}
-
keyboard-visible: {String(metrics.keyboardVisibleClass)} active: {metrics.activeElement}
-
vars: {Object.entries(metrics.cssVars).map(([k, v]) => `${k}=${v || 'unset'}`).join(' ')}
-
hit: {hitInfo ? `${hitInfo.x},${hitInfo.y} -> ${hitInfo.target}` : 'none'}
-
-
-
-
- ) -} diff --git a/src/client/components/Terminal.tsx b/src/client/components/Terminal.tsx index 51b739f..f2b77d8 100644 --- a/src/client/components/Terminal.tsx +++ b/src/client/components/Terminal.tsx @@ -870,7 +870,8 @@ export default function Terminal({ if (!a11yRoot || !a11yTree) return const updatePointerEvents = () => { - const keyboardVisible = isKeyboardVisible() + const textarea = container.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement | null + const keyboardVisible = textarea ? document.activeElement === textarea : false if (keyboardVisible && !isSelectingTextRef.current) { a11yRoot.style.pointerEvents = 'none' a11yTree.style.pointerEvents = 'none' @@ -888,7 +889,7 @@ export default function Terminal({ document.removeEventListener('focusin', updatePointerEvents) document.removeEventListener('focusout', updatePointerEvents) } - }, [containerRef, isiOS, isSelectingText, isKeyboardVisible]) + }, [containerRef, isiOS, isSelectingText]) return (
Date: Tue, 20 Jan 2026 14:41:32 -0500 Subject: [PATCH 4/4] fix: remove always-on iOS polling, extend RAF burst to 1500ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the constant 250ms basePollTimer on iOS with a longer RAF burst duration (800ms → 1500ms) during focus/orientation changes. This should still catch keyboard animations while reducing unnecessary polling when the keyboard is hidden. Remaining update mechanisms: - RAF burst (1500ms) on focus/orientation change - pollTimer (250ms) while text input is focused - Event listeners for resize, scroll, focus changes Co-Authored-By: Claude Opus 4.5 --- src/client/hooks/useVisualViewport.ts | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/src/client/hooks/useVisualViewport.ts b/src/client/hooks/useVisualViewport.ts index dd48afe..ded90c1 100644 --- a/src/client/hooks/useVisualViewport.ts +++ b/src/client/hooks/useVisualViewport.ts @@ -75,7 +75,6 @@ export function useVisualViewport() { let rafId: number | null = null let pollTimer: number | null = null - let basePollTimer: number | null = null const isTextInputActive = () => { const active = document.activeElement @@ -91,10 +90,6 @@ export function useVisualViewport() { updateKeyboardInset({ viewport, win: window, doc: document }) } - const shouldPollAlways = - typeof document.documentElement?.classList?.contains === 'function' && - document.documentElement.classList.contains('ios') - const startRafBurst = () => { if (typeof window.requestAnimationFrame !== 'function') { updateViewport() @@ -106,7 +101,7 @@ export function useVisualViewport() { const start = performance.now() const tick = () => { updateViewport() - if (performance.now() - start < 800) { + if (performance.now() - start < 1500) { rafId = window.requestAnimationFrame(tick) } else { rafId = null @@ -135,19 +130,6 @@ export function useVisualViewport() { pollTimer = null } - const startBasePolling = () => { - if (!shouldPollAlways || basePollTimer !== null || typeof window.setInterval !== 'function') { - return - } - basePollTimer = window.setInterval(updateViewport, 250) - } - - const stopBasePolling = () => { - if (basePollTimer === null || typeof window.clearInterval !== 'function') return - window.clearInterval(basePollTimer) - basePollTimer = null - } - const syncActiveState = () => { if (isTextInputActive()) { startRafBurst() @@ -173,7 +155,6 @@ export function useVisualViewport() { // Initial update updateViewport() syncActiveState() - startBasePolling() // Listen for viewport changes (keyboard show/hide, zoom, scroll) viewport.addEventListener('resize', updateViewport) @@ -206,7 +187,6 @@ export function useVisualViewport() { window.cancelAnimationFrame(rafId) } stopPolling() - stopBasePolling() clearKeyboardInset(document) } }, [])