refactor: drop mobile keyboard/viewport hacks, rely on browser

Remove body position:fixed lock, visualViewport listener in MainLayout
(keyboard-inset heuristics, scroll-to-zero lock, focusin/focusout RAF),
[data-keyboard-avoid-active] translateY rule, and related CSS vars
(--oc-keyboard-inset, --oc-keyboard-avoid-offset, --oc-keyboard-home-indicator,
--oc-visual-viewport-offset-top). Strip data-keyboard-avoid* attrs and
keyboardAvoid props from Dialog/ScrollableOverlay and consumers. Drop
isKeyboardOpen from useUIStore.

With body unlocked and the layout plain flex-col (h-dvh), the browser
shrinks the viewport on keyboard open naturally — composer sits above
the keyboard, header stops lagging, input no longer jitters.

FilesView.nudgeEditorSelectionAboveKeyboard now derives the occluded
bottom locally from visualViewport + documentElement.clientHeight.
This commit is contained in:
Bohdan Triapitsyn
2026-04-24 12:30:11 +03:00
parent 9d9af7e263
commit 6a70d51ea7
34 changed files with 134 additions and 496 deletions
@@ -484,7 +484,7 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
</div>
{!isTunnelLocked && (
<form onSubmit={handleSubmit} className="w-full space-y-2" data-keyboard-avoid="true">
<form onSubmit={handleSubmit} className="w-full space-y-2">
{canUsePasskey && (
<Button
type="button"
@@ -640,39 +640,33 @@ export const ChatContainer: React.FC = () => {
void load();
}, [currentSessionId, hasLoadedSessionMessages, isPinned, loadMessages, resumeToLatestInstant, sessionStatusForCurrent.type]);
if (!currentSessionId && !draftOpen) {
return (
<div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<ChatEmptyState />
</div>
);
}
if (!currentSessionId && !draftOpen) {
return (
<div className="flex flex-col h-full bg-background">
<ChatEmptyState />
</div>
);
}
if (!currentSessionId && draftOpen) {
return (
<div
className="relative flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
if (!currentSessionId && draftOpen) {
return (
<div className="relative flex flex-col h-full bg-background transform-gpu">
{!isDesktopExpandedInput ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
}
@@ -680,16 +674,13 @@ export const ChatContainer: React.FC = () => {
return null;
}
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
if (isSessionHydrating && sessionMessages.length === 0 && !streamingMessageId) {
return (
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
@@ -728,26 +719,23 @@ export const ChatContainer: React.FC = () => {
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
}
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div
className="relative flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div
className={cn(
if (sessionMessages.length === 0 && !streamingMessageId) {
return (
<div className="relative flex flex-col h-full bg-background transform-gpu">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
@@ -764,25 +752,22 @@ export const ChatContainer: React.FC = () => {
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
<ChatInput scrollToBottom={resumeToLatestInstant} />
</div>
</div>
);
}
return (
<div
className="relative flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
return (
<div className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
@@ -809,13 +794,13 @@ export const ChatContainer: React.FC = () => {
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
+18 -59
View File
@@ -787,7 +787,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]);
const isMobile = useUIStore((state) => state.isMobile);
const inputBarOffset = useUIStore((state) => state.inputBarOffset);
const isKeyboardOpen = useUIStore((state) => state.isKeyboardOpen);
const persistChatDraft = useUIStore((state) => state.persistChatDraft);
const inputSpellcheckEnabled = useUIStore((state) => state.inputSpellcheckEnabled);
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
@@ -1170,50 +1169,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
const handleTextareaPointerDownCapture = React.useCallback((event: React.PointerEvent<HTMLTextAreaElement>) => {
if (!isMobile) {
return;
}
if (event.pointerType !== 'touch') {
return;
}
const textarea = textareaRef.current;
if (!textarea) {
return;
}
if (document.activeElement === textarea) {
return;
}
// Prevent iOS from scrolling the page to reveal the input.
event.preventDefault();
event.stopPropagation();
const scroller = document.scrollingElement;
if (scroller && scroller.scrollTop !== 0) {
scroller.scrollTop = 0;
}
if (window.scrollY !== 0) {
window.scrollTo(0, 0);
}
try {
textarea.focus({ preventScroll: true });
} catch {
textarea.focus();
}
const len = textarea.value.length;
try {
textarea.setSelectionRange(len, len);
} catch {
// ignored
}
}, [isMobile]);
const handleOpenMobileControls = React.useCallback(() => {
if (!isMobile) {
return;
@@ -1226,16 +1181,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
setMobileControlsPanel(null);
if (isKeyboardOpen) {
textareaRef.current?.blur();
requestAnimationFrame(() => {
setMobileControlsOpen(true);
});
return;
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
setMobileControlsOpen(true);
}, [isMobile, isKeyboardOpen, mobileControlsOpen]);
}, [isMobile, mobileControlsOpen]);
const handleCloseMobileControls = React.useCallback(() => {
setMobileControlsOpen(false);
@@ -2136,7 +2087,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const isWordBoundary = !charBefore || /\s/.test(charBefore);
if (isWordBoundary && !textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
setMentionQuery(textAfterAt);
setAutocompleteTab('agents');
setAutocompleteTab((current) => current === 'files' ? 'files' : 'agents');
setShowFileMention(true);
} else {
setShowFileMention(false);
@@ -2179,22 +2130,31 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// ignored
}
}
const cursorPosition = textarea?.selectionStart ?? message.length;
const textBeforeCursor = message.substring(0, cursorPosition);
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
const nextMentionQuery = lastAtSymbol !== -1
? textBeforeCursor.substring(lastAtSymbol + 1).replace(/[\s\n].*$/, '')
: '';
setAutocompleteTab(tab);
setCommandQuery('');
setMentionQuery('');
if (tab === 'commands') {
setMentionQuery('');
applyAutocompletePrefix('/');
}
if (tab === 'agents') {
setMentionQuery(nextMentionQuery);
applyAutocompletePrefix('@');
}
if (tab === 'files') {
setMentionQuery(nextMentionQuery);
applyAutocompletePrefix('@');
}
setShowSkillAutocomplete(false);
setShowCommandAutocomplete(tab === 'commands');
setShowFileMention(tab === 'agents' || tab === 'files');
}, [applyAutocompletePrefix, isMobile, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
}, [applyAutocompletePrefix, isMobile, message, setAutocompleteTab, setCommandQuery, setMentionQuery, setShowCommandAutocomplete, setShowFileMention, setShowSkillAutocomplete]);
const handleOpenCommandMenu = React.useCallback(() => {
if (!isMobile) {
@@ -3331,10 +3291,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
className={cn(
"relative pt-0 pb-4",
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
isMobile && (isKeyboardOpen ? 'ios-keyboard-safe-area' : 'bottom-safe-area')
isMobile && 'bottom-safe-area'
)}
data-keyboard-avoid="true"
style={isMobile && inputBarOffset > 0 && !isKeyboardOpen ? { marginBottom: `${inputBarOffset}px` } : undefined}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
<div className={cn('chat-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
<AttachedFilesList />
@@ -3689,7 +3649,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onDropCapture={handleDropCapture}
onDrop={handleDrop}
onDragEnd={handleDragEnd}
onPointerDownCapture={handleTextareaPointerDownCapture}
onKeyUp={updateAutocompleteOverlayPosition}
onClick={updateAutocompleteOverlayPosition}
onScroll={(event) => {
@@ -350,259 +350,6 @@ export const MainLayout: React.FC = () => {
};
}, []);
React.useEffect(() => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
const root = document.documentElement;
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 explicitTargetId = active.getAttribute('data-keyboard-avoid-target-id');
if (explicitTargetId) {
const explicitTarget = document.getElementById(explicitTargetId);
if (explicitTarget instanceof HTMLElement) {
return explicitTarget;
}
}
const markedTarget = active.closest('[data-keyboard-avoid]') as HTMLElement | null;
if (markedTarget) {
// data-keyboard-avoid="none" opts out of translateY avoidance entirely.
// Used by components with their own scroll (e.g. CodeMirror).
if (markedTarget.getAttribute('data-keyboard-avoid') === 'none') {
return null;
}
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;
root.style.setProperty('--oc-keyboard-inset', '0px');
setKeyboardOpen(false);
};
// Batch visualViewport updates to once per animation frame to avoid
// layout thrashing during keyboard open/close animations.
let rafId = 0;
const updateVisualViewport = () => {
const viewport = window.visualViewport;
const height = viewport ? Math.round(viewport.height) : window.innerHeight;
const offsetTop = viewport ? Math.max(0, Math.round(viewport.offsetTop)) : 0;
root.style.setProperty('--oc-visual-viewport-offset-top', `${offsetTop}px`);
const active = document.activeElement as HTMLElement | null;
const tagName = active?.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
const isTextTarget = isInput || Boolean(active?.isContentEditable);
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
// Keyboard heuristic:
// - when an input is focused, smaller deltas can still be keyboard
// - when not focused, treat only big deltas as keyboard (ignore toolbars)
const openThreshold = isTextTarget ? 120 : 180;
const measuredInset = rawInset >= openThreshold ? rawInset : 0;
// Make the UI stable: treat keyboard inset as a step function.
// - When opening: take the first big inset and hold it.
// - When closing starts: immediately drop to 0 (even if keyboard animation continues).
// Closing start signals:
// - focus lost (handled via focusout)
// - visual viewport height starts increasing while inset is non-zero
if (ignoreOpenUntilZero) {
if (measuredInset === 0) {
ignoreOpenUntilZero = false;
}
stickyKeyboardInset = 0;
} else if (stickyKeyboardInset === 0) {
if (measuredInset > 0 && isTextTarget) {
stickyKeyboardInset = measuredInset;
}
} else {
// Only detect closing-by-height when focus is NOT on text input
// (prevents false positives during Android keyboard animation)
const closingByHeight = !isTextTarget && height > previousHeight + 6;
if (measuredInset === 0) {
stickyKeyboardInset = 0;
setKeyboardOpen(false);
} else if (closingByHeight) {
forceKeyboardClosed();
} else if (measuredInset > 0 && isTextTarget) {
// When focus is on text input, track actual inset (allows settling
// to correct value after Android animation fluctuations)
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
} else if (measuredInset > stickyKeyboardInset) {
stickyKeyboardInset = measuredInset;
setKeyboardOpen(true);
}
}
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height;
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const keyboardHomeIndicator = isIOS && stickyKeyboardInset > 0 ? 34 : 0;
root.style.setProperty('--oc-keyboard-home-indicator', `${keyboardHomeIndicator}px`);
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;
if (scroller && scroller.scrollTop !== 0) {
scroller.scrollTop = 0;
}
if (window.scrollY !== 0) {
window.scrollTo(0, 0);
}
}
};
const scheduleVisualViewportUpdate = () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
updateVisualViewport();
});
};
updateVisualViewport();
const viewport = window.visualViewport;
viewport?.addEventListener('resize', scheduleVisualViewportUpdate);
viewport?.addEventListener('scroll', scheduleVisualViewportUpdate);
window.addEventListener('resize', scheduleVisualViewportUpdate);
window.addEventListener('orientationchange', scheduleVisualViewportUpdate);
const isTextInputTarget = (element: HTMLElement | null) => {
if (!element) {
return false;
}
const tagName = element.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
return isInput || element.isContentEditable;
};
// Reset ignoreOpenUntilZero when focus moves to a text input.
// This allows keyboard detection to work when user taps input quickly
// while keyboard is still closing (common on Android).
const handleFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (isTextInputTarget(target)) {
ignoreOpenUntilZero = false;
}
scheduleVisualViewportUpdate();
};
document.addEventListener('focusin', handleFocusIn, true);
const handleFocusOut = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!isTextInputTarget(target)) {
return;
}
// Check if focus is moving to another input - if so, don't close keyboard
const related = event.relatedTarget as HTMLElement | null;
if (isTextInputTarget(related)) {
return;
}
// On mobile contenteditable editors (CodeMirror), focus can momentarily
// leave and return during drag-selection handles. Defer closing until the
// next frame and only close when no text target is focused and the
// visual viewport inset is actually zero.
window.requestAnimationFrame(() => {
if (isTextInputTarget(document.activeElement as HTMLElement | null)) {
return;
}
const currentViewport = window.visualViewport;
const height = currentViewport ? Math.round(currentViewport.height) : window.innerHeight;
const offsetTop = currentViewport ? Math.max(0, Math.round(currentViewport.offsetTop)) : 0;
const layoutHeight = Math.round(root.clientHeight || window.innerHeight);
const viewportSum = height + offsetTop;
const rawInset = Math.max(0, layoutHeight - viewportSum);
if (rawInset > 0) {
updateVisualViewport();
return;
}
forceKeyboardClosed();
updateVisualViewport();
});
};
document.addEventListener('focusout', handleFocusOut, true);
return () => {
if (rafId) cancelAnimationFrame(rafId);
viewport?.removeEventListener('resize', scheduleVisualViewportUpdate);
viewport?.removeEventListener('scroll', scheduleVisualViewportUpdate);
window.removeEventListener('resize', scheduleVisualViewportUpdate);
window.removeEventListener('orientationchange', scheduleVisualViewportUpdate);
document.removeEventListener('focusin', handleFocusIn, true);
document.removeEventListener('focusout', handleFocusOut, true);
clearKeyboardAvoidTarget();
};
}, [isMobile]);
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
case 'plan':
@@ -585,7 +585,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
const configuredSetupCount = setupCommands.filter(cmd => cmd.trim()).length;
return (
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background" data-keyboard-avoid="true">
<form onSubmit={handleSubmit} className="flex flex-col h-full bg-background">
{!isWindowed ? (
<header
onMouseDown={handleDragStart}
@@ -593,7 +593,7 @@ export const AgentsPage: React.FC = () => {
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header & Actions */}
@@ -169,7 +169,7 @@ export const CommandsPage: React.FC = () => {
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -112,7 +112,7 @@ export const GitPage: React.FC = () => {
return (
<>
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-3xl space-y-6 p-3 sm:p-6 sm:pt-8">
<GitHubSettings />
@@ -1253,7 +1253,7 @@ export const McpPage: React.FC = () => {
);
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -1755,7 +1755,7 @@ export const McpPage: React.FC = () => {
}
}}
>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Import JSON Snippet</DialogTitle>
<DialogDescription>
@@ -32,7 +32,6 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
if (!section) {
return (
<ScrollableOverlay
keyboardAvoid
outerClassName="h-full"
className="w-full"
>
@@ -95,7 +94,6 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
return (
<ScrollableOverlay
keyboardAvoid
outerClassName="h-full"
className="w-full"
>
@@ -236,7 +236,7 @@ export const ProjectsPage: React.FC = () => {
if (!selectedProject) {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
<p className="typography-meta text-muted-foreground">No projects available.</p>
</div>
@@ -244,7 +244,7 @@ export const ProjectsPage: React.FC = () => {
);
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<ScrollableOverlay outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
{/* Top Header & Actions */}
@@ -519,7 +519,7 @@ export const ProvidersPage: React.FC = () => {
if (isAddMode) {
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
<div className="mb-4">
<h1 className="typography-ui-header font-semibold text-foreground">Connect Provider</h1>
@@ -791,7 +791,7 @@ export const ProvidersPage: React.FC = () => {
});
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -340,7 +340,7 @@ const SkillsInstalledPage: React.FC = () => {
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -558,7 +558,7 @@ const SkillsInstalledPage: React.FC = () => {
setIsFileDialogOpen(open);
if (!open) setEditingFilePath(null);
}}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col" keyboardAvoid>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>{editingFilePath ? 'Edit Supporting File' : 'Add Supporting File'}</DialogTitle>
<DialogDescription>
@@ -230,7 +230,7 @@ export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpen
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl" keyboardAvoid>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Add skills catalog</DialogTitle>
<DialogDescription>
@@ -156,7 +156,7 @@ export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, on
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Install skill</DialogTitle>
<DialogDescription>
@@ -144,7 +144,7 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
};
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -148,7 +148,7 @@ export const UsagePage: React.FC = () => {
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
<ScrollableOverlay outerClassName="h-full" className="w-full">
<div className="mx-auto w-full max-w-3xl p-3 sm:p-6 sm:pt-8">
{/* Header */}
@@ -178,7 +178,7 @@ const SessionFolderItemBase = <TSessionNode,>({
{renaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
data-keyboard-avoid="true"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onSubmit={(event) => {
@@ -259,6 +259,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
? 'group-hover:pr-5'
: 'group-hover:pr-5 group-focus-within:pr-5');
const suppressNextSelectRef = React.useRef(false);
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
const session = node.session;
const liveSession = useSession(session.id);
@@ -356,7 +357,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<div className="flex min-w-0 flex-1 flex-col gap-0">
<form
className="flex w-full items-center gap-2"
data-keyboard-avoid="true"
onSubmit={(event) => {
event.preventDefault();
handleSaveEdit();
@@ -516,6 +517,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
suppressNextSelectRef.current = true;
}
};
const handleRowPointerDown = (event: React.PointerEvent<HTMLButtonElement>) => {
if (mobileVariant && event.pointerType === 'touch') {
setIsTouchPressed(true);
}
};
const handleRowPointerEnd = (event: React.PointerEvent<HTMLButtonElement>) => {
if (mobileVariant && event.pointerType === 'touch') {
setIsTouchPressed(false);
}
};
const sessionMenuContent = (
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
@@ -639,15 +650,19 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
<TooltipTrigger asChild>
<button
type="button"
disabled={isMissingDirectory}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
disabled={isMissingDirectory}
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: revealPaddingClass,
@@ -700,15 +715,19 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
) : (
<button
type="button"
disabled={isMissingDirectory}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
disabled={isMissingDirectory}
onPointerDown={handleRowPointerDown}
onPointerUp={handleRowPointerEnd}
onPointerCancel={handleRowPointerEnd}
onMouseDown={handleRowMouseDown}
onClick={(event) => handleRowSelect(event)}
onDoubleClick={(e) => {
e.stopPropagation();
handleSessionDoubleClick();
}}
className={cn(
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]',
isTouchPressed && 'bg-interactive-hover/70',
mobileVariant
? (isVSCode ? revealPaddingClass : 'pr-7')
: revealPaddingClass
@@ -76,7 +76,6 @@ interface TerminalViewportProps {
className?: string;
enableTouchScroll?: boolean;
autoFocus?: boolean;
keyboardAvoidTargetId?: string;
}
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
@@ -92,7 +91,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
className,
enableTouchScroll,
autoFocus = true,
keyboardAvoidTargetId,
},
ref
) => {
@@ -1514,7 +1512,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
tabIndex={-1}
enterKeyHint="send"
data-terminal-hidden-input="true"
data-keyboard-avoid-target-id={keyboardAvoidTargetId}
aria-label="Terminal input"
aria-hidden="true"
style={{
@@ -1542,7 +1539,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
tabIndex={-1}
enterKeyHint="send"
data-terminal-hidden-input="true"
data-keyboard-avoid-target-id={keyboardAvoidTargetId}
aria-label="Terminal input"
aria-hidden="true"
style={{
@@ -81,7 +81,6 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
'mx-auto max-w-lg',
className
)}
data-keyboard-avoid="true"
onClick={(event) => event.stopPropagation()}
>
{(() => {
@@ -12,7 +12,6 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
disableHorizontal?: boolean;
observeMutations?: boolean;
fillContainer?: boolean;
keyboardAvoid?: boolean;
/** Prevent scroll from propagating to parent when at boundaries */
preventOverscroll?: boolean;
useScrollShadow?: boolean;
@@ -34,7 +33,6 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
disableHorizontal = false,
observeMutations = true,
fillContainer = true,
keyboardAvoid = false,
preventOverscroll = false,
useScrollShadow = false,
scrollShadowSize,
@@ -51,7 +49,6 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
preventOverscroll && "overscroll-none",
outerClassName
)}
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
>
{useScrollShadow ? (
<ScrollShadow
-3
View File
@@ -80,7 +80,6 @@ DialogOverlay.displayName = "DialogOverlay";
type DialogContentProps = Omit<React.ComponentProps<typeof BaseDialog.Popup>, "children"> & {
showCloseButton?: boolean
keyboardAvoid?: boolean
children?: React.ReactNode
onOpenAutoFocus?: (event: Event) => void
onCloseAutoFocus?: (event: Event) => void
@@ -90,7 +89,6 @@ function DialogContent({
className,
children,
showCloseButton = true,
keyboardAvoid = false,
onOpenAutoFocus,
onCloseAutoFocus,
...props
@@ -104,7 +102,6 @@ function DialogContent({
<BaseDialog.Popup
data-slot="dialog-content"
data-state-slot="dialog"
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
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",
className
@@ -2150,10 +2150,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
const rootStyles = getComputedStyle(document.documentElement);
const keyboardInset = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-inset')) || 0;
const keyboardHomeIndicator = Number.parseFloat(rootStyles.getPropertyValue('--oc-keyboard-home-indicator')) || 0;
const occludedBottom = keyboardInset + keyboardHomeIndicator;
const layoutHeight = document.documentElement.clientHeight || window.innerHeight;
const occludedBottom = Math.max(0, layoutHeight - (viewport.offsetTop + viewport.height));
if (occludedBottom <= 0) {
return;
}
@@ -2965,8 +2963,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<div
className={cn('relative h-full', shouldMaskEditorForPendingNavigation && 'overflow-hidden')}
ref={editorWrapperRef}
data-keyboard-avoid="none"
style={isMobile ? { height: 'calc(100% - var(--oc-keyboard-inset, 0px))' } : undefined}
>
<div className={cn('h-full', shouldMaskEditorForPendingNavigation && 'invisible')}>
<CodeMirrorEditor
+1 -1
View File
@@ -1996,7 +1996,7 @@ export const GitView: React.FC = () => {
}
return (
<div className={cn('flex h-full flex-col overflow-hidden', 'bg-sidebar')} data-keyboard-avoid="true">
<div className={cn('flex h-full flex-col overflow-hidden', 'bg-sidebar')}>
<GitHeader
status={status}
localBranches={localBranches}
@@ -604,7 +604,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
// No sidebar available; fall back to direct content.
const fallback = renderPageContent(settingsSlug);
return (
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
<div className="flex-1 min-h-0 overflow-hidden bg-background">
<ErrorBoundary>{fallback}</ErrorBoundary>
</div>
);
@@ -622,7 +622,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const content = renderPageContent(settingsSlug);
return (
<div className="flex-1 min-h-0 overflow-hidden bg-background" data-keyboard-avoid="true">
<div className="flex-1 min-h-0 overflow-hidden bg-background">
<ErrorBoundary>{content}</ErrorBoundary>
</div>
);
@@ -143,7 +143,6 @@ export const TerminalView: React.FC = () => {
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
const keyboardAvoidTargetId = React.useId();
const streamCleanupRef = React.useRef<(() => void) | null>(null);
const activeTerminalIdRef = React.useRef<string | null>(null);
@@ -1069,8 +1068,6 @@ export const TerminalView: React.FC = () => {
<div
className="relative flex-1 overflow-hidden"
style={{ backgroundColor: xtermTheme.background }}
id={keyboardAvoidTargetId}
data-keyboard-avoid="true"
>
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
{shouldRenderViewport ? (
@@ -1088,7 +1085,6 @@ export const TerminalView: React.FC = () => {
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
keyboardAvoidTargetId={keyboardAvoidTargetId}
/>
) : null}
</div>
@@ -278,7 +278,7 @@ export const AgentGroupDetail: React.FC<AgentGroupDetailProps> = ({
</div>
<Dialog open={Boolean(worktreeDialog)} onOpenChange={(open) => { if (!open) setWorktreeDialog(null); }}>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{worktreeDialog?.kind === 'remove' ? 'Remove worktree' : 'Remove other worktrees'}
@@ -135,7 +135,7 @@ const AgentGroupItem: React.FC<AgentGroupItemProps> = ({ group, isSelected, isBu
</div>
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent className="max-w-md" keyboardAvoid>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Delete agent group</DialogTitle>
<DialogDescription>
@@ -53,7 +53,7 @@ export const CommitSection: React.FC<CommitSectionProps> = ({
const contentClassName = 'flex flex-col gap-3 px-0 pt-1 pb-3';
return (
<section className={containerClassName} data-keyboard-avoid="true">
<section className={containerClassName}>
<div className={headerClassName}>
<h3 className="typography-ui-header font-semibold text-foreground">Commit</h3>
</div>
-4
View File
@@ -223,10 +223,6 @@ textarea[data-chat-input="true"]:focus-visible {
-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 */
[data-scroll-shadow="true"] {
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
-7
View File
@@ -482,7 +482,6 @@ interface UIStore {
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
isMobile: boolean;
isKeyboardOpen: boolean;
isQuickOpenOpen: boolean;
isCommandPaletteOpen: boolean;
isHelpDialogOpen: boolean;
@@ -637,7 +636,6 @@ interface UIStore {
setPadding: (size: number) => void;
setCornerRadius: (radius: number) => void;
setInputBarOffset: (offset: number) => void;
setKeyboardOpen: (open: boolean) => void;
applyTypography: () => void;
applyPadding: () => void;
updateProportionalSidebarWidths: () => void;
@@ -724,7 +722,6 @@ export const useUIStore = create<UIStore>()(
pendingFileNavigation: null,
pendingFileFocusPath: null,
isMobile: false,
isKeyboardOpen: false,
isQuickOpenOpen: false,
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
@@ -1470,10 +1467,6 @@ export const useUIStore = create<UIStore>()(
set({ inputBarOffset: offset });
},
setKeyboardOpen: (open) => {
set({ isKeyboardOpen: open });
},
toggleFavoriteModel: (providerID, modelID) => {
set((state) => {
const exists = state.favoriteModels.some(
-4
View File
@@ -7,10 +7,6 @@
--oc-safe-area-bottom-visual: 0px;
--oc-safe-area-left: 0px;
--oc-header-height: 56px;
--oc-visual-viewport-offset-top: 0px;
--oc-keyboard-inset: 0px;
--oc-keyboard-avoid-offset: 0px;
--oc-keyboard-home-indicator: 0px;
--oc-wco-left-inset: 0px;
--oc-wco-right-inset: 0px;
--oc-wco-titlebar-height: 0px;
-36
View File
@@ -165,16 +165,6 @@
overflow: hidden;
}
/* Prevent iOS keyboard from scrolling the page */
:root.device-mobile:not(.desktop-runtime) body,
:root.device-mobile:not(.desktop-runtime) #root {
position: fixed;
inset: 0;
width: 100%;
overflow: hidden;
overscroll-behavior-y: none;
}
/* Fix main layout container */
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
height: 100vh;
@@ -182,16 +172,6 @@
min-height: 0;
}
/* Fix header positioning */
:root.device-mobile:not(.desktop-runtime) .header-safe-area {
position: relative;
top: auto;
left: auto;
right: auto;
width: 100%;
z-index: 10;
}
/* Fix main content area */
:root.mobile-pointer:not(.desktop-runtime) .flex-1.overflow-hidden {
min-height: 0;
@@ -287,22 +267,6 @@
padding-bottom: var(--oc-safe-area-bottom-visual);
}
/* iOS keyboard home indicator safe area - used when keyboard is open */
:root.device-mobile:not(.desktop-runtime) .ios-keyboard-safe-area {
padding-bottom: calc(var(--oc-keyboard-home-indicator, 34px) + var(--oc-safe-area-bottom-visual, 0px)) !important;
}
/* Prevent keyboard-induced page scroll on iOS PWA */
:root.device-mobile:not(.desktop-runtime) body {
min-height: 100vh;
min-height: -webkit-fill-available;
position: fixed;
inset: 0;
width: 100%;
overflow: hidden;
overscroll-behavior-y: none;
}
/* Fix iOS viewport issues */
:root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen {
min-height: 100vh;