feat(mobile): add keyboard avoidance for input area

This commit is contained in:
Bohdan Triapitsyn
2026-01-01 16:02:41 +02:00
parent 10e64b4f90
commit 2f2c127f13
21 changed files with 100 additions and 24 deletions
@@ -209,7 +209,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="w-full space-y-2"> <form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" /> <RiLockLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/60" />
@@ -1166,7 +1166,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return ( return (
<form onSubmit={handleSubmit} className="pt-0 pb-2 md:pb-4 bottom-safe-area" style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}> <form
onSubmit={handleSubmit}
className="pt-0 pb-2 md:pb-4 bottom-safe-area"
data-keyboard-avoid="true"
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
<StatusRow <StatusRow
isWorking={working.isWorking} isWorking={working.isWorking}
statusText={workingStatusText} statusText={workingStatusText}
@@ -102,9 +102,36 @@ export const MainLayout: React.FC = () => {
let stickyKeyboardInset = 0; let stickyKeyboardInset = 0;
let ignoreOpenUntilZero = false; let ignoreOpenUntilZero = false;
let previousHeight = 0; let previousHeight = 0;
let keyboardAvoidTarget: HTMLElement | null = null;
const setKeyboardOpen = useUIStore.getState().setKeyboardOpen; 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 = () => { const forceKeyboardClosed = () => {
stickyKeyboardInset = 0; stickyKeyboardInset = 0;
ignoreOpenUntilZero = true; ignoreOpenUntilZero = true;
@@ -155,7 +182,7 @@ export const MainLayout: React.FC = () => {
// (prevents false positives during Android keyboard animation) // (prevents false positives during Android keyboard animation)
const closingByHeight = !isTextTarget && height > previousHeight + 6; const closingByHeight = !isTextTarget && height > previousHeight + 6;
if (measuredInset === 0) { if (measuredInset === 0) {
stickyKeyboardInset = 0; stickyKeyboardInset = 0;
setKeyboardOpen(false); setKeyboardOpen(false);
} else if (closingByHeight) { } else if (closingByHeight) {
@@ -174,6 +201,30 @@ if (measuredInset === 0) {
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`); root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height; 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. // Only force-scroll lock while an input is focused.
if (isMobile && isTextTarget) { if (isMobile && isTextTarget) {
const scroller = document.scrollingElement; const scroller = document.scrollingElement;
@@ -239,6 +290,7 @@ if (measuredInset === 0) {
window.removeEventListener('orientationchange', updateVisualViewport); window.removeEventListener('orientationchange', updateVisualViewport);
document.removeEventListener('focusin', handleFocusIn, true); document.removeEventListener('focusin', handleFocusIn, true);
document.removeEventListener('focusout', handleFocusOut, true); document.removeEventListener('focusout', handleFocusOut, true);
clearKeyboardAvoidTarget();
}; };
}, [isMobile]); }, [isMobile]);
@@ -422,7 +422,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
{/* Content with chat-column max-width */} {/* Content with chat-column max-width */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<div className="chat-column py-6"> <div className="chat-column py-6">
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6" data-keyboard-avoid="true">
{/* Group name (required) */} {/* Group name (required) */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="group-name" className="typography-ui-label font-medium text-foreground"> <label htmlFor="group-name" className="typography-ui-label font-medium text-foreground">
@@ -266,7 +266,7 @@ export const AgentsPage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */} {/* Header */}
<div className="space-y-1"> <div className="space-y-1">
@@ -122,8 +122,8 @@ export const CommandsPage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{/* Header */} {/* Header */}
<div className="space-y-1"> <div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg"> <h1 className="typography-ui-header font-semibold text-lg">
@@ -21,6 +21,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
if (!section) { if (!section) {
return ( return (
<ScrollableOverlay <ScrollableOverlay
keyboardAvoid
outerClassName="h-full" outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6" className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
> >
@@ -56,6 +57,7 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return ( return (
<ScrollableOverlay <ScrollableOverlay
keyboardAvoid
outerClassName="h-full" outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6" className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6"
> >
@@ -732,7 +732,7 @@ export const ProvidersPage: React.FC = () => {
}); });
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -363,7 +363,7 @@ export const SkillsPage: React.FC = () => {
} }
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
{isNewSkill ? modeTabs : null} {isNewSkill ? modeTabs : null}
@@ -558,7 +558,7 @@ export const SkillsPage: React.FC = () => {
setIsFileDialogOpen(open); setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null); if (!open) setEditingFilePath(null);
}}> }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col"> <DialogContent className="max-w-2xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogHeader className="flex-shrink-0"> <DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle> <DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -224,7 +224,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl"> <DialogContent className="max-w-xl" keyboardAvoid>
<DialogHeader> <DialogHeader>
<DialogTitle>Add skills catalog</DialogTitle> <DialogTitle>Add skills catalog</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -93,7 +93,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg" keyboardAvoid>
<DialogHeader> <DialogHeader>
<DialogTitle>Install skill</DialogTitle> <DialogTitle>Install skill</DialogTitle>
<DialogDescription> <DialogDescription>
@@ -121,7 +121,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
}; };
return ( return (
<ScrollableOverlay outerClassName="h-full" className="w-full"> <ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<div className="mx-auto max-w-3xl space-y-6 p-6"> <div className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-3"> <div className="space-y-3">
<AnimatedTabs <AnimatedTabs
@@ -730,6 +730,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<div className="flex min-w-0 flex-1 flex-col gap-0"> <div className="flex min-w-0 flex-1 flex-col gap-0">
<form <form
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
data-keyboard-avoid="true"
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
handleSaveEdit(); handleSaveEdit();
@@ -75,14 +75,15 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
aria-modal="true" aria-modal="true"
onClick={onClose} onClick={onClose}
> >
<div <div
className={cn( className={cn(
'mt-auto w-full rounded-t-xl border border-border/50 bg-background shadow-none pwa-overlay-panel', 'mt-auto w-full rounded-t-xl border border-border/50 bg-background shadow-none pwa-overlay-panel',
'mx-auto max-w-lg', 'mx-auto max-w-lg',
className className
)} )}
onClick={(event) => event.stopPropagation()} data-keyboard-avoid="true"
> onClick={(event) => event.stopPropagation()}
>
{(() => { {(() => {
const closeButton = ( const closeButton = (
<button <button
@@ -10,6 +10,7 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
scrollbarClassName?: string; scrollbarClassName?: string;
disableHorizontal?: boolean; disableHorizontal?: boolean;
fillContainer?: boolean; fillContainer?: boolean;
keyboardAvoid?: boolean;
}; };
export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlayProps>( export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlayProps>(
@@ -24,6 +25,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
scrollbarClassName, scrollbarClassName,
disableHorizontal = false, disableHorizontal = false,
fillContainer = true, fillContainer = true,
keyboardAvoid = false,
...rest ...rest
}, ref) => { }, ref) => {
const containerRef = React.useRef<HTMLElement | null>(null); const containerRef = React.useRef<HTMLElement | null>(null);
@@ -31,7 +33,10 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []); React.useImperativeHandle(ref, () => containerRef.current as HTMLElement, []);
return ( return (
<div className={cn("relative flex flex-col min-h-0 w-full overflow-hidden", outerClassName)}> <div
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden", outerClassName)}
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
>
<Component <Component
ref={containerRef as React.Ref<HTMLElement>} ref={containerRef as React.Ref<HTMLElement>}
className={cn( className={cn(
+3
View File
@@ -63,15 +63,18 @@ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
keyboardAvoid = false,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean
keyboardAvoid?: boolean
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
<DialogOverlay className="rounded-none" /> <DialogOverlay className="rounded-none" />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
className={cn( className={cn(
"bg-background text-foreground fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-hidden pwa-dialog-content", "bg-background text-foreground fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-xl border p-6 shadow-none overflow-hidden pwa-dialog-content",
className className
+1 -1
View File
@@ -640,7 +640,7 @@ export const GitView: React.FC = () => {
} }
return ( return (
<div className="flex h-full flex-col overflow-hidden bg-background"> <div className="flex h-full flex-col overflow-hidden bg-background" data-keyboard-avoid="true">
<GitHeader <GitHeader
status={status} status={status}
localBranches={localBranches} localBranches={localBranches}
@@ -370,7 +370,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
{/* Mobile: show sidebar OR page content based on drill-down state */} {/* Mobile: show sidebar OR page content based on drill-down state */}
{isMobile ? ( {isMobile ? (
showMobilePageContent ? ( showMobilePageContent ? (
<div className="flex-1 overflow-hidden bg-background"> <div className="flex-1 overflow-hidden bg-background" data-keyboard-avoid="true">
<ErrorBoundary>{renderPageContent()}</ErrorBoundary> <ErrorBoundary>{renderPageContent()}</ErrorBoundary>
</div> </div>
) : ( ) : (
@@ -804,6 +804,7 @@ export const TerminalView: React.FC = () => {
<div <div
className="relative flex-1 overflow-hidden" className="relative flex-1 overflow-hidden"
style={{ backgroundColor: xtermTheme.background }} style={{ backgroundColor: xtermTheme.background }}
data-keyboard-avoid="true"
> >
<div className="h-full w-full box-border px-3 pt-3 pb-4"> <div className="h-full w-full box-border px-3 pt-3 pb-4">
{isTerminalActive ? ( {isTerminalActive ? (
@@ -53,6 +53,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
<Collapsible <Collapsible
open={hasSelectedFiles} open={hasSelectedFiles}
className="rounded-xl border border-border/60 bg-background/70 overflow-hidden" className="rounded-xl border border-border/60 bg-background/70 overflow-hidden"
data-keyboard-avoid="true"
> >
<div className="flex w-full items-center justify-between px-3 py-2"> <div className="flex w-full items-center justify-between px-3 py-2">
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3> <h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
+5
View File
@@ -12,6 +12,7 @@
--oc-header-height: 56px; --oc-header-height: 56px;
--oc-visual-viewport-offset-top: 0px; --oc-visual-viewport-offset-top: 0px;
--oc-keyboard-inset: 0px; --oc-keyboard-inset: 0px;
--oc-keyboard-avoid-offset: 0px;
--ui-regular-font-weight: 400; --ui-regular-font-weight: 400;
--oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4); --oc-scrollbar-thumb: oklch(0.32 0.03 50 / 0.4);
--oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6); --oc-scrollbar-thumb-hover: oklch(0.32 0.03 50 / 0.6);
@@ -56,6 +57,10 @@ textarea[data-chat-input="true"]:hover {
-webkit-appearance: none; -webkit-appearance: none;
} }
[data-keyboard-avoid-active="true"] {
transform: translateY(calc(-1 * var(--oc-keyboard-avoid-offset, 0px)));
}
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */ /* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] { [data-scroll-shadow="true"] {
--scroll-shadow-size: var(--scroll-shadow-size, 48px); --scroll-shadow-size: var(--scroll-shadow-size, 48px);