feat: enhance mobile experience by adjusting layout and keyboard handling
This commit is contained in:
@@ -145,7 +145,10 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
if (!currentSessionId) {
|
||||
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">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
@@ -157,7 +160,10 @@ export const ChatContainer: React.FC = () => {
|
||||
const hasMessagesEntry = messages.has(currentSessionId);
|
||||
if (!hasMessagesEntry) {
|
||||
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="chat-column space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
@@ -179,7 +185,10 @@ export const ChatContainer: React.FC = () => {
|
||||
|
||||
if (sessionMessages.length === 0 && !streamingMessageId) {
|
||||
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">
|
||||
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
|
||||
</div>
|
||||
@@ -191,7 +200,10 @@ export const ChatContainer: React.FC = () => {
|
||||
}
|
||||
|
||||
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="absolute inset-0">
|
||||
|
||||
@@ -78,6 +78,51 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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(() => {
|
||||
if (!currentAgentName) {
|
||||
@@ -201,7 +246,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
setMessage('');
|
||||
|
||||
if (isMobile) {
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
|
||||
await sendMessage(sanitizedText, currentProviderId, currentModelId, currentAgentName, attachmentsToSend, agentMentionName)
|
||||
|
||||
.catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -235,7 +285,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
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
|
||||
type='submit'
|
||||
type={isMobile ? 'button' : 'submit'}
|
||||
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(
|
||||
iconButtonBaseClass,
|
||||
hasContent && currentSessionId
|
||||
@@ -983,6 +1062,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||
placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"}
|
||||
disabled={!currentSessionId}
|
||||
|
||||
|
||||
@@ -127,18 +127,40 @@ export const Header: React.FC = () => {
|
||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
||||
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(() => {
|
||||
if (isMobile) {
|
||||
blurActiveElement();
|
||||
setSessionSwitcherOpen(!isSessionSwitcherOpen);
|
||||
return;
|
||||
}
|
||||
toggleSidebar();
|
||||
}, [isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
|
||||
}, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]);
|
||||
|
||||
const handleOpenSettings = React.useCallback(() => {
|
||||
if (isMobile) {
|
||||
blurActiveElement();
|
||||
}
|
||||
setSessionSwitcherOpen(false);
|
||||
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';
|
||||
|
||||
@@ -397,7 +419,12 @@ export const Header: React.FC = () => {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveMainTab(tab.id)}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
blurActiveElement();
|
||||
}
|
||||
setActiveMainTab(tab.id);
|
||||
}}
|
||||
aria-label={tab.label}
|
||||
aria-selected={isActive}
|
||||
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(() => {
|
||||
switch (activeMainTab) {
|
||||
case 'git':
|
||||
@@ -110,7 +226,10 @@ export const MainLayout: React.FC = () => {
|
||||
{isMobile ? (
|
||||
<>
|
||||
<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">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView /></ErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user