From 2f2c127f13481fdbf7532fbdfa361a515f425ebe Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 1 Jan 2026 16:02:41 +0200 Subject: [PATCH] feat(mobile): add keyboard avoidance for input area --- .../src/components/auth/SessionAuthGate.tsx | 2 +- packages/ui/src/components/chat/ChatInput.tsx | 7 ++- .../ui/src/components/layout/MainLayout.tsx | 54 ++++++++++++++++++- .../components/multirun/MultiRunLauncher.tsx | 2 +- .../components/sections/agents/AgentsPage.tsx | 2 +- .../sections/commands/CommandsPage.tsx | 4 +- .../sections/openchamber/OpenChamberPage.tsx | 2 + .../sections/providers/ProvidersPage.tsx | 2 +- .../components/sections/skills/SkillsPage.tsx | 4 +- .../skills/catalog/AddCatalogDialog.tsx | 2 +- .../skills/catalog/InstallSkillDialog.tsx | 2 +- .../skills/catalog/SkillsCatalogPage.tsx | 2 +- .../src/components/session/SessionSidebar.tsx | 1 + .../src/components/ui/MobileOverlayPanel.tsx | 17 +++--- .../src/components/ui/ScrollableOverlay.tsx | 7 ++- packages/ui/src/components/ui/dialog.tsx | 3 ++ packages/ui/src/components/views/GitView.tsx | 2 +- .../ui/src/components/views/SettingsView.tsx | 2 +- .../ui/src/components/views/TerminalView.tsx | 1 + .../components/views/git/CommitSection.tsx | 1 + packages/ui/src/index.css | 5 ++ 21 files changed, 100 insertions(+), 24 deletions(-) diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 4ab44208..0412de5b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -209,7 +209,7 @@ export const SessionAuthGate: React.FC = ({ children }) =>

-
+
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b351c55f..6f687760 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1166,7 +1166,12 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo return ( - 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}> + 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined} + > { let stickyKeyboardInset = 0; let ignoreOpenUntilZero = false; let previousHeight = 0; + let keyboardAvoidTarget: HTMLElement | null = null; const setKeyboardOpen = useUIStore.getState().setKeyboardOpen; + const clearKeyboardAvoidTarget = () => { + if (!keyboardAvoidTarget) { + return; + } + keyboardAvoidTarget.style.setProperty('--oc-keyboard-avoid-offset', '0px'); + keyboardAvoidTarget.removeAttribute('data-keyboard-avoid-active'); + keyboardAvoidTarget = null; + }; + + const resolveKeyboardAvoidTarget = (active: HTMLElement | null) => { + if (!active) { + return null; + } + const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null; + if (markedTarget) { + return markedTarget; + } + if (active.classList.contains('overlay-scrollbar-container')) { + const parent = active.parentElement; + if (parent instanceof HTMLElement) { + return parent; + } + } + return active; + }; + const forceKeyboardClosed = () => { stickyKeyboardInset = 0; ignoreOpenUntilZero = true; @@ -155,7 +182,7 @@ export const MainLayout: React.FC = () => { // (prevents false positives during Android keyboard animation) const closingByHeight = !isTextTarget && height > previousHeight + 6; -if (measuredInset === 0) { + if (measuredInset === 0) { stickyKeyboardInset = 0; setKeyboardOpen(false); } else if (closingByHeight) { @@ -174,6 +201,30 @@ if (measuredInset === 0) { root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`); previousHeight = height; + const avoidTarget = isTextTarget ? resolveKeyboardAvoidTarget(active) : null; + + if (!isMobile || !avoidTarget || !active) { + clearKeyboardAvoidTarget(); + } else { + if (avoidTarget !== keyboardAvoidTarget) { + clearKeyboardAvoidTarget(); + keyboardAvoidTarget = avoidTarget; + } + const viewportBottom = offsetTop + height; + const rect = active.getBoundingClientRect(); + const overlap = rect.bottom - viewportBottom; + const clearance = 8; + const keyboardInset = Math.max(stickyKeyboardInset, measuredInset); + const avoidOffset = overlap > clearance && keyboardInset > 0 + ? Math.min(overlap, keyboardInset) + : 0; + const target = keyboardAvoidTarget; + if (target) { + target.style.setProperty('--oc-keyboard-avoid-offset', `${avoidOffset}px`); + target.setAttribute('data-keyboard-avoid-active', 'true'); + } + } + // Only force-scroll lock while an input is focused. if (isMobile && isTextTarget) { const scroller = document.scrollingElement; @@ -239,6 +290,7 @@ if (measuredInset === 0) { window.removeEventListener('orientationchange', updateVisualViewport); document.removeEventListener('focusin', handleFocusIn, true); document.removeEventListener('focusout', handleFocusOut, true); + clearKeyboardAvoidTarget(); }; }, [isMobile]); diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 47be30ee..4e7a9c03 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -422,7 +422,7 @@ export const MultiRunLauncher: React.FC = ({ {/* Content with chat-column max-width */}
- + {/* Group name (required) */}