feat: enhance mobile experience by adjusting layout and keyboard handling

This commit is contained in:
Bohdan Triapitsyn
2025-12-18 11:20:37 +02:00
parent db673d8dc4
commit d28c4e9da6
7 changed files with 283 additions and 17 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased] ## [Unreleased]
- MacOS app menu entries for Check for update and for creating bug/request in Help section - MacOS app menu entries for Check for update and for creating bug/request in Help section
- For Mobile added settings, improved terminal scrolling, fixed app layout positioning
## [1.2.3] - 2025-12-17 ## [1.2.3] - 2025-12-17
@@ -145,7 +145,10 @@ export const ChatContainer: React.FC = () => {
if (!currentSessionId) { if (!currentSessionId) {
return ( return (
<div className="flex flex-col h-full bg-background"> <div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated /> <OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div> </div>
@@ -157,7 +160,10 @@ export const ChatContainer: React.FC = () => {
const hasMessagesEntry = messages.has(currentSessionId); const hasMessagesEntry = messages.has(currentSessionId);
if (!hasMessagesEntry) { if (!hasMessagesEntry) {
return ( return (
<div className="flex flex-col h-full bg-background gap-0"> <div
className="flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 overflow-y-auto p-4 bg-background"> <div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-column space-y-4"> <div className="chat-column space-y-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
@@ -179,7 +185,10 @@ export const ChatContainer: React.FC = () => {
if (sessionMessages.length === 0 && !streamingMessageId) { if (sessionMessages.length === 0 && !streamingMessageId) {
return ( return (
<div className="flex flex-col h-full bg-background transform-gpu"> <div
className="flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated /> <OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div> </div>
@@ -191,7 +200,10 @@ export const ChatContainer: React.FC = () => {
} }
return ( return (
<div className="flex flex-col h-full bg-background"> <div
className="flex flex-col h-full bg-background"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="relative flex-1 min-h-0"> <div className="relative flex-1 min-h-0">
<div className="absolute inset-0"> <div className="absolute inset-0">
+82 -2
View File
@@ -78,6 +78,51 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const [showAbortStatus, setShowAbortStatus] = React.useState(false); const [showAbortStatus, setShowAbortStatus] = React.useState(false);
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null); const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const prevWasAbortedRef = React.useRef(false); const prevWasAbortedRef = React.useRef(false);
const sendTriggeredByPointerDownRef = React.useRef(false);
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 currentAgent = React.useMemo(() => { const currentAgent = React.useMemo(() => {
if (!currentAgentName) { if (!currentAgentName) {
@@ -201,7 +246,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
setMessage(''); setMessage('');
if (isMobile) {
textareaRef.current?.blur();
}
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName) await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
.catch((error: unknown) => { .catch((error: unknown) => {
const rawMessage = const rawMessage =
error instanceof Error error instanceof Error
@@ -235,7 +285,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
toast.error(rawMessage || 'Message failed to send. Attachments restored.'); toast.error(rawMessage || 'Message failed to send. Attachments restored.');
}); });
textareaRef.current?.focus(); if (!isMobile) {
textareaRef.current?.focus();
}
}; };
@@ -768,8 +820,35 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
</button> </button>
) : ( ) : (
<button <button
type='submit' type={isMobile ? 'button' : 'submit'}
disabled={!hasContent || !currentSessionId} disabled={!hasContent || !currentSessionId}
onPointerDownCapture={(event) => {
if (!isMobile || event.pointerType !== 'touch') {
return;
}
if (!hasContent || !currentSessionId) {
return;
}
sendTriggeredByPointerDownRef.current = true;
event.preventDefault();
event.stopPropagation();
void handleSubmit();
}}
onClick={(event) => {
if (!isMobile) {
return;
}
if (sendTriggeredByPointerDownRef.current) {
sendTriggeredByPointerDownRef.current = false;
return;
}
event.preventDefault();
void handleSubmit();
}}
className={cn( className={cn(
iconButtonBaseClass, iconButtonBaseClass,
hasContent && currentSessionId hasContent && currentSessionId
@@ -983,6 +1062,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
onChange={handleTextChange} onChange={handleTextChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onPointerDownCapture={handleTextareaPointerDownCapture}
placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"} placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"}
disabled={!currentSessionId} disabled={!currentSessionId}
+30 -3
View File
@@ -127,18 +127,40 @@ export const Header: React.FC = () => {
const contextUsage = getContextUsage(contextLimit, outputLimit); const contextUsage = getContextUsage(contextLimit, outputLimit);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const blurActiveElement = React.useCallback(() => {
if (typeof document === 'undefined') {
return;
}
const active = document.activeElement as HTMLElement | null;
if (!active) {
return;
}
const tagName = active.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
if (isInput || active.isContentEditable) {
active.blur();
}
}, []);
const handleOpenSessionSwitcher = React.useCallback(() => { const handleOpenSessionSwitcher = React.useCallback(() => {
if (isMobile) { if (isMobile) {
blurActiveElement();
setSessionSwitcherOpen(!isSessionSwitcherOpen); setSessionSwitcherOpen(!isSessionSwitcherOpen);
return; return;
} }
toggleSidebar(); toggleSidebar();
}, [isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
const handleOpenSettings = React.useCallback(() => { const handleOpenSettings = React.useCallback(() => {
if (isMobile) {
blurActiveElement();
}
setSessionSwitcherOpen(false); setSessionSwitcherOpen(false);
setSettingsDialogOpen(true); setSettingsDialogOpen(true);
}, [setSessionSwitcherOpen, setSettingsDialogOpen]); }, [blurActiveElement, isMobile, setSessionSwitcherOpen, setSettingsDialogOpen]);
const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground'; const headerIconButtonClass = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground';
@@ -397,7 +419,12 @@ export const Header: React.FC = () => {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
onClick={() => setActiveMainTab(tab.id)} onClick={() => {
if (isMobile) {
blurActiveElement();
}
setActiveMainTab(tab.id);
}}
aria-label={tab.label} aria-label={tab.label}
aria-selected={isActive} aria-selected={isActive}
role="tab" role="tab"
@@ -78,6 +78,122 @@ 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;
const forceKeyboardClosed = () => {
stickyKeyboardInset = 0;
ignoreOpenUntilZero = true;
root.style.setProperty('--oc-keyboard-inset', '0px');
};
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 {
const closingByHeight = height > previousHeight + 6;
if (measuredInset === 0) {
stickyKeyboardInset = 0;
} else if (closingByHeight) {
forceKeyboardClosed();
} else if (measuredInset > stickyKeyboardInset) {
stickyKeyboardInset = measuredInset;
}
}
root.style.setProperty('--oc-keyboard-inset', `${stickyKeyboardInset}px`);
previousHeight = height;
// 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);
}
}
};
updateVisualViewport();
const viewport = window.visualViewport;
viewport?.addEventListener('resize', updateVisualViewport);
viewport?.addEventListener('scroll', updateVisualViewport);
window.addEventListener('resize', updateVisualViewport);
window.addEventListener('orientationchange', updateVisualViewport);
document.addEventListener('focusin', updateVisualViewport, true);
const handleFocusOut = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const tagName = target.tagName;
const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT';
if (isInput || target.isContentEditable) {
forceKeyboardClosed();
}
};
document.addEventListener('focusout', handleFocusOut, true);
return () => {
viewport?.removeEventListener('resize', updateVisualViewport);
viewport?.removeEventListener('scroll', updateVisualViewport);
window.removeEventListener('resize', updateVisualViewport);
window.removeEventListener('orientationchange', updateVisualViewport);
document.removeEventListener('focusin', updateVisualViewport, true);
document.removeEventListener('focusout', handleFocusOut, true);
};
}, [isMobile]);
const secondaryView = React.useMemo(() => { const secondaryView = React.useMemo(() => {
switch (activeMainTab) { switch (activeMainTab) {
case 'git': case 'git':
@@ -110,7 +226,10 @@ export const MainLayout: React.FC = () => {
{isMobile ? ( {isMobile ? (
<> <>
<Header /> <Header />
<div className="flex flex-1 overflow-hidden bg-background"> <div
className="flex flex-1 overflow-hidden bg-background"
style={{ paddingTop: 'var(--oc-header-height, 56px)' }}
>
<main className="flex-1 overflow-hidden bg-background relative"> <main className="flex-1 overflow-hidden bg-background relative">
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}> <div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
<ErrorBoundary><ChatView /></ErrorBoundary> <ErrorBoundary><ChatView /></ErrorBoundary>
+25 -6
View File
@@ -9,6 +9,9 @@
--oc-safe-area-bottom: 0px; --oc-safe-area-bottom: 0px;
--oc-safe-area-bottom-visual: 0px; --oc-safe-area-bottom-visual: 0px;
--oc-safe-area-left: 0px; --oc-safe-area-left: 0px;
--oc-header-height: 56px;
--oc-visual-viewport-offset-top: 0px;
--oc-keyboard-inset: 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);
@@ -1028,6 +1031,16 @@ html:not(.dark) .chat-scroll {
overflow: hidden; 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 */ /* Fix main layout container */
:root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen { :root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen {
height: 100vh; height: 100vh;
@@ -1036,9 +1049,12 @@ html:not(.dark) .chat-scroll {
} }
/* Fix header positioning */ /* Fix header positioning */
:root.mobile-pointer:not(.desktop-runtime) .header-safe-area { :root.device-mobile:not(.desktop-runtime) .header-safe-area {
position: sticky; position: fixed;
top: 0; top: var(--oc-visual-viewport-offset-top, 0px);
left: 0;
right: 0;
width: 100%;
z-index: 50; z-index: 50;
} }
@@ -1116,12 +1132,15 @@ html:not(.dark) .chat-scroll {
padding-bottom: var(--oc-safe-area-bottom-visual) !important; padding-bottom: var(--oc-safe-area-bottom-visual) !important;
} }
/* Ensure proper body spacing for iOS */ /* Prevent keyboard-induced page scroll on iOS PWA */
body { body {
min-height: 100vh; min-height: 100vh;
min-height: -webkit-fill-available; min-height: -webkit-fill-available;
overflow-y: auto; position: fixed;
-webkit-overflow-scrolling: touch; inset: 0;
width: 100%;
overflow: hidden;
overscroll-behavior-y: none;
} }
/* Fix iOS viewport issues */ /* Fix iOS viewport issues */
+9
View File
@@ -40,6 +40,15 @@ const setRootDeviceAttributes = (
const isMobile = deviceType === 'mobile'; const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet'; const isTablet = deviceType === 'tablet';
root.classList.remove('device-mobile', 'device-tablet', 'device-desktop');
root.classList.add(
deviceType === 'mobile'
? 'device-mobile'
: deviceType === 'tablet'
? 'device-tablet'
: 'device-desktop'
);
if (isDesktopRuntime) { if (isDesktopRuntime) {
root.classList.add('desktop-runtime'); root.classList.add('desktop-runtime');
root.style.setProperty('--is-mobile', '0'); root.style.setProperty('--is-mobile', '0');