fix: mobile goal UX — bottom-sheet dialog, keyboard-safe target button, capacitor safe-top for overlays

- the goal dialog renders as the shared MobileOverlayPanel bottom sheet
  on mobile instead of a centered dialog
- tapping the target button to ARM keeps the soft keyboard open (the next
  message is the objective; same guard as the attachment/mic buttons),
  while opening the manage sheet lets the keyboard close as usual
- capacitor: bottom-sheet overlays cap their height by the keyboard inset
  AND the top safe area — with the keyboard raised while typing inside a
  sheet, 100dvh does not shrink (native resize is off) and the panel could
  slide under the notch/status bar; applies to every MobileOverlayPanel
This commit is contained in:
Bohdan Triapitsyn
2026-07-12 17:01:40 +03:00
parent 8d956f5f9c
commit 22d5ad3814
3 changed files with 52 additions and 6 deletions
@@ -76,6 +76,20 @@ export const SessionGoalButton: React.FC<SessionGoalButtonProps> = React.memo(({
type="button" type="button"
className={cn(footerIconButtonClass, colorClass)} className={cn(footerIconButtonClass, colorClass)}
onClick={handleClick} onClick={handleClick}
// Same guard as PermissionAutoAcceptButton, but only for the ARM
// toggle: arming happens mid-typing (the next message IS the
// objective), so that tap must not dismiss the soft keyboard or
// trigger the Android keyboard-close relayout that moves the button
// before the click lands. Opening the manage sheet (goal exists) is a
// context switch — there the keyboard should close as usual.
onMouseDown={(event) => {
if (!goal) event.preventDefault();
}}
onPointerDownCapture={(event) => {
if (!goal && event.pointerType === 'touch') {
event.preventDefault();
}
}}
aria-label={label} aria-label={label}
aria-pressed={isEngaged} aria-pressed={isEngaged}
{...(withTooltip ? {} : { title: label })} {...(withTooltip ? {} : { title: label })}
@@ -18,6 +18,8 @@ import {
import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation'; import { sessionGoalStatusColor, sessionGoalStatusLabelKey } from '@/lib/sessionGoalPresentation';
import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions'; import { clearSessionGoal, setSessionGoal } from '@/lib/sessionGoalActions';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { useUIStore } from '@/stores/useUIStore';
interface SessionGoalDialogProps { interface SessionGoalDialogProps {
open: boolean; open: boolean;
@@ -31,6 +33,7 @@ interface SessionGoalDialogProps {
// (pause/resume/complete/clear) once a goal exists. // (pause/resume/complete/clear) once a goal exists.
export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) { export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }: SessionGoalDialogProps) {
const { t } = useI18n(); const { t } = useI18n();
const isMobile = useUIStore((state) => state.isMobile);
const { goal } = useSessionGoal(sessionId, directory); const { goal } = useSessionGoal(sessionId, directory);
const objectiveContent = useGoalObjectiveContent(sessionId, goal); const objectiveContent = useGoalObjectiveContent(sessionId, goal);
@@ -86,13 +89,9 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
true, true,
); );
return ( const title = goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate');
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{goal ? t('chat.goal.dialog.titleManage') : t('chat.goal.dialog.titleCreate')}</DialogTitle>
</DialogHeader>
const body = (
<div className="space-y-3"> <div className="space-y-3">
{goal && ( {goal && (
<div className="space-y-1 p-2 rounded-lg" style={{ backgroundColor: 'var(--surface-elevated)' }}> <div className="space-y-1 p-2 rounded-lg" style={{ backgroundColor: 'var(--surface-elevated)' }}>
@@ -193,6 +192,25 @@ export function SessionGoalDialog({ open, onOpenChange, sessionId, directory }:
</div> </div>
</div> </div>
</div> </div>
);
// Mobile renders the shared bottom-sheet overlay instead of a centered
// dialog — same pattern as model controls and the session status panel.
if (isMobile) {
return (
<MobileOverlayPanel open={open} title={title} onClose={() => onOpenChange(false)}>
{body}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{body}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+14
View File
@@ -615,6 +615,20 @@
transition: opacity 0.2s ease-out; transition: opacity 0.2s ease-out;
} }
/* Bottom-sheet overlays live inside a keyboard-inset surface whose bottom is
lifted by the keyboard height, but their max-height is expressed in 100dvh —
which does NOT shrink in the Capacitor app (native keyboard resize is off).
Without this cap a sheet with the keyboard raised (typing inside it) can
grow past the top safe area and slide under the notch/status bar. Subtract
both the keyboard inset and the top safe area from the available height. */
:root.oc-capacitor-app .pwa-overlay-panel {
max-height: calc(
100dvh
- var(--oc-keyboard-inset, 0px)
- max(0.75rem, env(safe-area-inset-top))
);
}
/* Full-screen scroll views (e.g. the connect/login screen) live outside the app /* Full-screen scroll views (e.g. the connect/login screen) live outside the app
shell, so shrink them by the keyboard height the same way the shell does. Capping shell, so shrink them by the keyboard height the same way the shell does. Capping
the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually the height (instead of min-height: 100dvh) is what makes overflow-y-auto actually