diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 1d537d77..6f7cca4d 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -58,6 +58,7 @@ import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallback import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { useI18n } from '@/lib/i18n'; +import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. const OnboardingScreen = lazyWithChunkRecovery(() => @@ -222,6 +223,7 @@ function App({ apis }: AppProps) { const [initRetryEpoch, setInitRetryEpoch] = React.useState(0); const [manualInitRetrying, setManualInitRetrying] = React.useState(false); const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled); + const mobileKeyboardMode = useUIStore((state) => state.mobileKeyboardMode); const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const [bootInjectionStatus, setBootInjectionStatus] = React.useState(() => { @@ -246,6 +248,10 @@ function App({ apis }: AppProps) { }; }, [showMemoryDebug]); + React.useEffect(() => { + applyMobileKeyboardMode(mobileKeyboardMode); + }, [mobileKeyboardMode]); + React.useEffect(() => { setIsVSCodeRuntime(apis.runtime.isVSCode); }, [apis.runtime.isVSCode]); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index a36aa7ad..23fb8eee 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => { const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); - const { isMobile } = useDeviceInfo(); + const { isMobile, isTablet } = useDeviceInfo(); const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const sidebarWidth = useUIStore((state) => state.sidebarWidth); const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth); @@ -289,19 +289,24 @@ export const MainLayout: React.FC = () => { rightSidebarAutoClosedRef.current = false; } - const shouldCloseBottomTerminal = - height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT; - const canAutoOpenBottomTerminal = - height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; + // Touch devices frequently resize when the on-screen keyboard opens. + // Treat bottom-terminal auto-collapse/restore as desktop-only so + // keyboard viewport changes do not churn terminal layout state. + if (!isMobile && !isTablet) { + const shouldCloseBottomTerminal = + height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT; + const canAutoOpenBottomTerminal = + height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT; - if (shouldCloseBottomTerminal) { - if (state.isBottomTerminalOpen) { - setBottomTerminalOpen(false); - bottomTerminalAutoClosedRef.current = true; + if (shouldCloseBottomTerminal) { + if (state.isBottomTerminalOpen) { + setBottomTerminalOpen(false); + bottomTerminalAutoClosedRef.current = true; + } + } else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) { + setBottomTerminalOpen(true); + bottomTerminalAutoClosedRef.current = false; } - } else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) { - setBottomTerminalOpen(true); - bottomTerminalAutoClosedRef.current = false; } }; @@ -324,7 +329,7 @@ export const MainLayout: React.FC = () => { window.clearTimeout(timeoutId); } }; - }, [setBottomTerminalOpen, setRightSidebarOpen]); + }, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]); React.useEffect(() => { if (typeof window === 'undefined') { @@ -351,7 +356,7 @@ export const MainLayout: React.FC = () => { return () => { unsubscribe(); }; - }, []); + }, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]); const secondaryView = React.useMemo(() => { switch (activeMainTab) { diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index c3554787..2b75d79b 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -115,6 +115,7 @@ const VisualSectionContent: React.FC = () => { 'theme', 'pwaInstallName', 'pwaOrientation', + 'mobileKeyboardMode', 'timeFormat', 'weekStart', 'fontSize', diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 93b29462..d2d02d89 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -26,6 +26,7 @@ import { updateDesktopSettings } from '@/lib/persistence'; import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; import { useI18n, type Locale } from '@/lib/i18n'; import { useConfigStore } from '@/stores/useConfigStore'; +import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; import { setDirectoryShowHidden, useDirectoryShowHidden, @@ -115,6 +116,19 @@ const PWA_ORIENTATION_OPTIONS: Option<'system' | 'portrait' | 'landscape'>[] = [ }, ]; +const MOBILE_KEYBOARD_MODE_OPTIONS: Option[] = [ + { + id: 'native', + labelKey: 'settings.openchamber.visual.option.mobileKeyboardMode.native.label', + descriptionKey: 'settings.openchamber.visual.option.mobileKeyboardMode.native.description', + }, + { + id: 'resize-content', + labelKey: 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label', + descriptionKey: 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description', + }, +]; + type PwaInstallNameWindow = Window & { __OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string; __OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape'; @@ -220,7 +234,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; +export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -260,6 +274,8 @@ export const OpenChamberVisualSettings: React.FC const setPadding = useUIStore(state => state.setPadding); const inputBarOffset = useUIStore(state => state.inputBarOffset); const setInputBarOffset = useUIStore(state => state.setInputBarOffset); + const mobileKeyboardMode = useUIStore(state => state.mobileKeyboardMode); + const setMobileKeyboardMode = useUIStore(state => state.setMobileKeyboardMode); const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); const diffViewMode = useUIStore(state => state.diffViewMode); @@ -490,6 +506,7 @@ export const OpenChamberVisualSettings: React.FC const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode; const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode; + const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent(); const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); const selectedTimeFormatLabel = React.useMemo(() => { @@ -504,6 +521,10 @@ export const OpenChamberVisualSettings: React.FC const option = PWA_ORIENTATION_OPTIONS.find((item) => item.id === pwaOrientation); return option ? tUnsafe(option.labelKey) : undefined; }, [pwaOrientation, tUnsafe]); + const selectedMobileKeyboardModeLabel = React.useMemo(() => { + const option = MOBILE_KEYBOARD_MODE_OPTIONS.find((item) => item.id === mobileKeyboardMode); + return option ? tUnsafe(option.labelKey) : undefined; + }, [mobileKeyboardMode, tUnsafe]); const applyPwaInstallName = React.useCallback(async (value: string) => { if (typeof window === 'undefined') { @@ -547,7 +568,7 @@ export const OpenChamberVisualSettings: React.FC }, []); React.useEffect(() => { - if (typeof window === 'undefined' || (!showPwaInstallNameSetting && !showPwaOrientationSetting)) { + if (typeof window === 'undefined' || (!showPwaInstallNameSetting && !showPwaOrientationSetting && !showMobileKeyboardModeSetting)) { return; } @@ -572,6 +593,7 @@ export const OpenChamberVisualSettings: React.FC const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : ''; const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64); const orientation = normalizePwaOrientation(settings?.pwaOrientation); + const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode); if (!cancelled) { if (showPwaInstallNameSetting) { @@ -580,6 +602,9 @@ export const OpenChamberVisualSettings: React.FC if (showPwaOrientationSetting) { setPwaOrientation(orientation); } + if (showMobileKeyboardModeSetting) { + setMobileKeyboardMode(nextMobileKeyboardMode); + } } } catch { if (!cancelled) { @@ -589,6 +614,9 @@ export const OpenChamberVisualSettings: React.FC if (showPwaOrientationSetting) { setPwaOrientation('system'); } + if (showMobileKeyboardModeSetting) { + setMobileKeyboardMode('native'); + } } } }; @@ -598,7 +626,7 @@ export const OpenChamberVisualSettings: React.FC return () => { cancelled = true; }; - }, [showPwaInstallNameSetting, showPwaOrientationSetting]); + }, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]); return (
@@ -768,7 +796,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(showPwaInstallNameSetting || showPwaOrientationSetting) && ( + {(showPwaInstallNameSetting || showPwaOrientationSetting || showMobileKeyboardModeSetting) && (
{showPwaInstallNameSetting && ( @@ -858,6 +886,52 @@ export const OpenChamberVisualSettings: React.FC
)} + + {showMobileKeyboardModeSetting && ( +
+
+ {t('settings.openchamber.visual.field.mobileKeyboardMode')} + {t('settings.openchamber.visual.field.mobileKeyboardModeHint')} +
+
+ + +
+
+ )} )} diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index c25cd7b9..dcacdff1 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'; -import { isMobileDeviceViaCSS } from '@/lib/device'; import type { TerminalTheme } from '@/lib/terminalTheme'; import { getGhosttyTerminalOptions } from '@/lib/terminalTheme'; import type { TerminalChunk } from '@/stores/useTerminalStore'; @@ -77,6 +76,7 @@ interface TerminalViewportProps { className?: string; enableTouchScroll?: boolean; autoFocus?: boolean; + isVisible?: boolean; } const TerminalViewport = React.forwardRef( @@ -92,6 +92,7 @@ const TerminalViewport = React.forwardRef { @@ -117,37 +118,72 @@ const TerminalViewport = React.forwardRef(null); const lastInputEventAtRef = React.useRef(null); - const refocusTimeoutRef = React.useRef(null); const keydownProbeTimeoutRef = React.useRef(null); const lastObservedValueRef = React.useRef(''); const cursorBlinkStateRef = React.useRef(null); + const focusArmedRef = React.useRef(!enableTouchScroll); + const previousVisibleRef = React.useRef(isVisible); const [, forceRender] = React.useReducer((x) => x + 1, 0); const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0); - inputHandlerRef.current = onInput; resizeHandlerRef.current = onResize; const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent); - const prefersTouchOnlyPointer = typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(pointer: coarse)').matches - && window.matchMedia('(hover: none)').matches; - const useHiddenInputOverlay = Boolean(enableTouchScroll) - && (isAndroid || isMobileDeviceViaCSS() || prefersTouchOnlyPointer); + // Touch devices need a dedicated editable surface so special keys like + // Backspace and arrows are captured reliably without relying on Ghostty's + // internal mobile text handling. + const useHiddenInputOverlay = Boolean(enableTouchScroll); + + React.useEffect(() => { + if (!enableTouchScroll) { + focusArmedRef.current = true; + previousVisibleRef.current = isVisible; + return; + } + + const becameVisible = !previousVisibleRef.current && isVisible; + if (becameVisible) { + focusArmedRef.current = false; + } + previousVisibleRef.current = isVisible; + }, [enableTouchScroll, isVisible]); const disableTerminalTextareas = React.useCallback(() => { + if (!useHiddenInputOverlay) { + return; + } + const container = containerRef.current; const hiddenInput = hiddenInputRef.current; if (!container) { return; } - const editableNodes = Array.from(container.querySelectorAll('[contenteditable="true"]')); + const editableNodes: HTMLElement[] = []; + if (container.getAttribute('contenteditable') === 'true') { + editableNodes.push(container); + } + editableNodes.push(...Array.from(container.querySelectorAll('[contenteditable="true"]'))); editableNodes.forEach((node) => { + node.setAttribute('data-terminal-disabled-contenteditable', 'true'); + node.setAttribute('contenteditable', 'false'); + node.setAttribute('aria-hidden', 'true'); + node.tabIndex = -1; node.style.setProperty('caret-color', 'transparent'); + node.style.color = 'transparent'; + node.style.setProperty('-webkit-text-fill-color', 'transparent'); + node.style.background = 'transparent'; node.style.outline = 'none'; + node.style.boxShadow = 'none'; + node.style.textShadow = 'none'; + node.style.setProperty('user-select', 'none'); + node.style.setProperty('-webkit-user-select', 'none'); }); + container.tabIndex = -1; + container.removeAttribute('role'); + container.removeAttribute('aria-multiline'); + const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[]; textareas.forEach((textarea) => { textarea.style.setProperty('caret-color', 'transparent'); @@ -161,10 +197,6 @@ const TerminalViewport = React.forwardRef void }> = []; let restorePatchedScrollToBottom: (() => void) | null = null; + let restoreContainerFocus: (() => void) | null = null; const container = containerRef.current; if (!container) { @@ -937,6 +972,20 @@ const TerminalViewport = React.forwardRef) => { + void args; + focusHiddenInput(); + }) as typeof container.focus; + restoreContainerFocus = () => { + patchedContainer.focus = originalFocus as typeof container.focus; + }; + } + const handleTerminalTextareaFocus = () => { setTerminalCursorBlink(true); }; @@ -948,6 +997,20 @@ const TerminalViewport = React.forwardRef { const target = event.target as Node | null; if (target && container.contains(target)) { + if (useHiddenInputOverlay && target instanceof HTMLElement) { + window.setTimeout(() => { + target.blur(); + }, 0); + setTerminalCursorBlink(false); + return; + } + if (enableTouchScroll && !focusArmedRef.current && target instanceof HTMLElement) { + window.setTimeout(() => { + target.blur(); + }, 0); + setTerminalCursorBlink(false); + return; + } setTerminalCursorBlink(true); return; } @@ -972,6 +1035,10 @@ const TerminalViewport = React.forwardRef {}; + } + const terminalWithViewport = terminal as unknown as TerminalWithViewport; if (typeof terminalWithViewport.scrollToBottom === 'function') { const originalScrollToBottom = terminalWithViewport.scrollToBottom.bind(terminalWithViewport); @@ -1011,7 +1078,12 @@ const TerminalViewport = React.forwardRef { disableTerminalTextareas(); }); - localTextareaObserver.observe(container, { childList: true, subtree: true }); + localTextareaObserver.observe(container, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['contenteditable', 'role', 'tabindex', 'aria-label'], + }); } const viewport = findScrollableViewport(container); @@ -1088,6 +1160,7 @@ const TerminalViewport = React.forwardRef { @@ -1192,23 +1265,25 @@ const TerminalViewport = React.forwardRef { - refocusTimeoutRef.current = null; - focusHiddenInput(); - }, 0); - } + const isInsideTerminal = Boolean(related && container?.contains(related)); + + // Respect explicit focus changes to external editable controls (chat input, settings inputs, etc.) + // so terminal focus logic doesn't fight user intent and trigger keyboard show/hide loops. + if (isInput && !isHiddenInput && !isInsideTerminal) { + return; } + + // Do not auto-refocus here. In hidden-input mode, forcing focus back can + // create a focus ping-pong with Ghostty's internal textbox on mobile. + void isInsideTerminal; + void isHiddenInput; }, - [useHiddenInputOverlay, focusHiddenInput] + [useHiddenInputOverlay] ); const handleHiddenBeforeInput = React.useCallback( @@ -1333,6 +1408,26 @@ const TerminalViewport = React.forwardRef = { + ArrowUp: '\u001b[A', + ArrowDown: '\u001b[B', + ArrowRight: '\u001b[C', + ArrowLeft: '\u001b[D', + Escape: '\u001b', + Tab: '\t', + Delete: '\u001b[3~', + Home: '\u001b[H', + End: '\u001b[F', + }; + + const specialKeySequence = specialKeySequences[event.key]; + if (specialKeySequence) { + event.preventDefault(); + inputHandlerRef.current(specialKeySequence); + clearEditableValue(event.currentTarget as HTMLElement); + return; + } + const target = event.currentTarget as HTMLElement; const nativeEvent = event.nativeEvent as KeyboardEvent | undefined; if (nativeEvent?.isComposing) { @@ -1466,6 +1561,7 @@ const TerminalViewport = React.forwardRef { if (!useHiddenInputOverlay || enableTouchScroll) { @@ -1477,10 +1573,10 @@ const TerminalViewport = React.forwardRef { + if (enableTouchScroll) { + return; + } if (useHiddenInputOverlay) { - if (enableTouchScroll) { - return; - } if (hasCopyableSelectionInViewport()) { return; } diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index 6fd71ebf..70d6ea56 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -382,7 +382,7 @@ export const SortableTabsStrip: React.FC = ({
setTabRef(item.id, element)} className={cn( - 'group flex h-full items-center', + 'group flex h-full min-w-0 flex-nowrap items-center', (isScrollable || useIntrinsicPillSizing) ? 'shrink-0' : usesActivePillIndicator @@ -403,8 +403,8 @@ export const SortableTabsStrip: React.FC = ({ onClick={() => onSelect(item.id)} className={cn( usesActivePillIndicator - ? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0' - : 'flex h-full min-w-0 items-center typography-micro', + ? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 min-w-0 flex-nowrap items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0' + : 'flex h-full min-w-0 flex-nowrap items-center typography-micro', usesActivePillIndicator && activePillLowercase ? 'lowercase' : null, usesActivePillIndicator && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'), usesActivePillIndicator @@ -443,7 +443,7 @@ export const SortableTabsStrip: React.FC = ({ { event.stopPropagation(); }} @@ -462,7 +462,7 @@ export const SortableTabsStrip: React.FC = ({ {shouldShowLabel ? {item.label} : null} ) : ( - + {item.icon ? ( = ({ { event.stopPropagation(); }} diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 1bf65e0a..8ddb99c0 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -91,11 +91,13 @@ export const TerminalView: React.FC = () => { const terminalFontSize = useUIStore(state => state.terminalFontSize); const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight); const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded); - const { isMobile, hasTouchInput } = useDeviceInfo(); + const { isMobile, isTablet, hasTouchOnlyPointer } = useDeviceInfo(); + const isTouchTerminal = isMobile || isTablet; + const useTouchTerminalInput = (isTouchTerminal || hasTouchOnlyPointer) && runtime.platform === 'web'; // Tabs are supported for web + desktop runtimes, including mobile (not VSCode). const enableTabs = runtime.platform !== 'vscode'; const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop); - const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop; + const showQuickKeys = isTouchTerminal || showTerminalQuickKeysOnDesktop; const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); @@ -176,11 +178,21 @@ export const TerminalView: React.FC = () => { const rehydratedSnapshotTakenRef = React.useRef(false); const focusTerminalWhenWindowActive = React.useCallback(() => { + if (useTouchTerminalInput) { + return; + } if (typeof document !== 'undefined' && !document.hasFocus()) { return; } terminalControllerRef.current?.focus(); - }, []); + }, [useTouchTerminalInput]); + + const focusTerminalController = React.useCallback(() => { + if (useTouchTerminalInput) { + return; + } + terminalControllerRef.current?.focus(); + }, [useTouchTerminalInput]); React.useEffect(() => { if (!terminalHydrated) { @@ -546,7 +558,7 @@ export const TerminalView: React.FC = () => { ]); React.useEffect(() => { - if (!isTerminalVisible) { + if (!isTerminalVisible || useTouchTerminalInput) { return; } @@ -562,7 +574,7 @@ export const TerminalView: React.FC = () => { return () => { window.cancelAnimationFrame(rafId); }; - }, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]); + }, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput]); const handleRestart = React.useCallback(async () => { if (!effectiveDirectory) return; @@ -675,10 +687,10 @@ export const TerminalView: React.FC = () => { if (modifierConsumed) { setActiveModifier(null); - terminalControllerRef.current?.focus(); + focusTerminalController(); } }, - [activeModifier, isReconnectPending, setActiveModifier, t, terminal] + [activeModifier, focusTerminalController, isReconnectPending, setActiveModifier, t, terminal] ); const handleViewportResize = React.useCallback( @@ -699,9 +711,9 @@ export const TerminalView: React.FC = () => { const handleModifierToggle = React.useCallback( (modifier: Modifier) => { setActiveModifier((current) => (current === modifier ? null : modifier)); - terminalControllerRef.current?.focus(); + focusTerminalController(); }, - [setActiveModifier] + [focusTerminalController, setActiveModifier] ); const handleMobileKeyPress = React.useCallback( @@ -712,9 +724,9 @@ export const TerminalView: React.FC = () => { } handleViewportInput(sequence); setActiveModifier(null); - terminalControllerRef.current?.focus(); + focusTerminalController(); }, - [activeModifier, handleViewportInput, setActiveModifier] + [activeModifier, focusTerminalController, handleViewportInput, setActiveModifier] ); React.useEffect(() => { @@ -778,7 +790,7 @@ export const TerminalView: React.FC = () => { event.stopPropagation(); handleViewportInput(controlCode); setActiveModifier(null); - terminalControllerRef.current?.focus(); + focusTerminalController(); } } }; @@ -791,6 +803,7 @@ export const TerminalView: React.FC = () => { activeModifier, handleMobileKeyPress, handleViewportInput, + focusTerminalController, showQuickKeys, setActiveModifier, terminalSessionId, @@ -817,20 +830,19 @@ export const TerminalView: React.FC = () => { const xtermTheme = React.useMemo(() => convertThemeToXterm(currentTheme), [currentTheme]); - const terminalSessionKey = React.useMemo(() => { + const terminalViewportKey = React.useMemo(() => { const directoryPart = effectiveDirectory ?? 'no-dir'; const tabPart = activeTabId ?? 'no-tab'; - const terminalPart = terminalSessionId ?? `pending-${tabPart}`; + const terminalPart = terminalSessionId ?? 'no-terminal'; return `${directoryPart}::${tabPart}::${terminalPart}`; }, [effectiveDirectory, activeTabId, terminalSessionId]); const viewportSessionKey = React.useMemo(() => { - const base = terminalSessionId ?? terminalSessionKey; - return `${base}::layout-${viewportLayoutVersion}`; - }, [terminalSessionId, terminalSessionKey, viewportLayoutVersion]); + return `${terminalViewportKey}::layout-${viewportLayoutVersion}`; + }, [terminalViewportKey, viewportLayoutVersion]); React.useEffect(() => { - if (isMobile || !isBottomTerminalOpen || !isTerminalVisible) { + if (useTouchTerminalInput || !isBottomTerminalOpen || !isTerminalVisible) { return; } @@ -846,10 +858,10 @@ export const TerminalView: React.FC = () => { return () => { window.clearTimeout(timeoutId); }; - }, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]); + }, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]); React.useEffect(() => { - if (!isTerminalVisible) { + if (!isTerminalVisible || useTouchTerminalInput) { return; } const controller = terminalControllerRef.current; @@ -871,10 +883,10 @@ export const TerminalView: React.FC = () => { }; } fitOnce(); - }, [focusTerminalWhenWindowActive, isTerminalVisible, terminalSessionKey, terminalSessionId]); + }, [focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput, terminalViewportKey, terminalSessionId]); React.useEffect(() => { - if (isMobile || !isTerminalVisible || !isBottomTerminalOpen) { + if (useTouchTerminalInput || !isTerminalVisible || !isBottomTerminalOpen) { return; } @@ -899,7 +911,7 @@ export const TerminalView: React.FC = () => { } fitOnce(); - }, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]); + }, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]); if (!hasActiveContext) { return ( @@ -924,8 +936,8 @@ export const TerminalView: React.FC = () => { } const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending; - const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport; - const showBottomDockControls = !isMobile && isBottomTerminalOpen && !isTerminalActive; + const shouldRenderViewport = hasOpenedTerminalViewport; + const showBottomDockControls = !isTouchTerminal && isBottomTerminalOpen && !isTerminalActive; const quickKeysControls = ( <>
@@ -1126,7 +1138,6 @@ export const TerminalView: React.FC = () => {
{shouldRenderViewport ? ( { terminalControllerRef.current = controller; }} @@ -1137,8 +1148,9 @@ export const TerminalView: React.FC = () => { theme={xtermTheme} fontFamily={resolvedFontStack} fontSize={terminalFontSize} - enableTouchScroll={hasTouchInput} - autoFocus={isTerminalVisible} + enableTouchScroll={useTouchTerminalInput} + autoFocus={!useTouchTerminalInput && isTerminalVisible} + isVisible={isTerminalVisible} /> ) : null}
diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 7f1634e6..958156a1 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1261,45 +1261,6 @@ html:not(.dark) .chat-scroll { background: transparent; } -/* Hide system caret in terminal - targets both Ghostty internal input and our custom touch input */ -.terminal-viewport-container textarea, -.terminal-viewport-container input { - caret-color: transparent !important; -} - -.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] { - caret-color: transparent !important; - outline: none !important; -} - -[contenteditable="true"][aria-label="Terminal input"] { - caret-color: transparent !important; - outline: none !important; -} - -/* Keep Ghostty's internal focus textarea fully non-painting on desktop. */ -.terminal-viewport-container textarea:not([data-terminal-hidden-input="true"]) { - opacity: 0 !important; - font-size: 0 !important; - line-height: 0 !important; - color: transparent !important; - -webkit-text-fill-color: transparent !important; - background: transparent !important; - border: 0 !important; - outline: none !important; - box-shadow: none !important; - text-shadow: none !important; -} - -textarea[aria-label="Terminal input"], -input[aria-label="Terminal input"] { - caret-color: transparent !important; - color: transparent !important; - -webkit-text-fill-color: transparent !important; - opacity: 0 !important; - outline: none !important; -} - /* Terminal touch input is portaled to document.body; keep it fully non-painting across engines. */ input[data-terminal-hidden-input="true"], textarea[data-terminal-hidden-input="true"] { @@ -1326,6 +1287,45 @@ textarea[data-terminal-hidden-input="true"]::placeholder { color: transparent !important; } +/* Hide browser/system caret for Ghostty's own input surfaces. + Terminal cursor is rendered by the canvas renderer. */ +.terminal-viewport-container [contenteditable="true"][aria-label="Terminal input"] { + caret-color: transparent !important; + outline: none !important; +} + +textarea[aria-label="Terminal input"], +input[aria-label="Terminal input"] { + caret-color: transparent !important; + outline: none !important; +} + +/* When touch terminal input uses the portaled overlay, keep Ghostty's + internal editable surfaces fully non-painting to avoid duplicate cursors. */ +.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]), +.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]), +.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] { + caret-color: transparent !important; + color: transparent !important; + -webkit-text-fill-color: transparent !important; + background: transparent !important; + border: 0 !important; + outline: none !important; + box-shadow: none !important; + text-shadow: none !important; +} + +.terminal-viewport-container[data-hidden-input-overlay-active="true"] textarea:not([data-terminal-hidden-input="true"]), +.terminal-viewport-container[data-hidden-input-overlay-active="true"] input:not([data-terminal-hidden-input="true"]), +.terminal-viewport-container[data-hidden-input-overlay-active="true"] [contenteditable="true"] { + opacity: 0 !important; + font-size: 0 !important; + line-height: 0 !important; + pointer-events: none !important; + user-select: none !important; + -webkit-user-select: none !important; +} + /* Mobile Terminal Optimizations */ @media (max-width: 1024px) { .terminal-viewport-container { diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 86e48a4a..d1e350fd 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -594,6 +594,7 @@ export interface SettingsPayload { gitProviderId?: string; gitModelId?: string; pwaAppName?: string; + mobileKeyboardMode?: 'native' | 'resize-content'; [key: string]: unknown; } diff --git a/packages/ui/src/lib/appearanceAutoSave.ts b/packages/ui/src/lib/appearanceAutoSave.ts index 0099890e..5bc6e25d 100644 --- a/packages/ui/src/lib/appearanceAutoSave.ts +++ b/packages/ui/src/lib/appearanceAutoSave.ts @@ -2,6 +2,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { updateDesktopSettings } from '@/lib/persistence'; import type { DesktopSettings } from '@/lib/desktop'; import type { MonoFontOption, UiFontOption } from '@/lib/fontOptions'; +import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; type AppearanceSlice = { showReasoningTraces: boolean; @@ -32,6 +33,7 @@ type AppearanceSlice = { padding: number; cornerRadius: number; inputBarOffset: number; + mobileKeyboardMode: MobileKeyboardMode; diffLayoutPreference: 'dynamic' | 'inline' | 'side-by-side'; diffViewMode: 'single' | 'stacked'; gitChangesViewMode: 'flat' | 'tree'; @@ -70,6 +72,7 @@ export const startAppearanceAutoSave = (): void => { padding: useUIStore.getState().padding, cornerRadius: useUIStore.getState().cornerRadius, inputBarOffset: useUIStore.getState().inputBarOffset, + mobileKeyboardMode: useUIStore.getState().mobileKeyboardMode, diffLayoutPreference: useUIStore.getState().diffLayoutPreference, diffViewMode: useUIStore.getState().diffViewMode, gitChangesViewMode: useUIStore.getState().gitChangesViewMode, @@ -120,6 +123,7 @@ export const startAppearanceAutoSave = (): void => { padding: state.padding, cornerRadius: state.cornerRadius, inputBarOffset: state.inputBarOffset, + mobileKeyboardMode: state.mobileKeyboardMode, diffLayoutPreference: state.diffLayoutPreference, diffViewMode: state.diffViewMode, gitChangesViewMode: state.gitChangesViewMode, @@ -196,6 +200,9 @@ export const startAppearanceAutoSave = (): void => { if (current.inputBarOffset !== previous.inputBarOffset) { diff.inputBarOffset = current.inputBarOffset; } + if (current.mobileKeyboardMode !== previous.mobileKeyboardMode) { + diff.mobileKeyboardMode = current.mobileKeyboardMode; + } if (current.diffLayoutPreference !== previous.diffLayoutPreference) { diff.diffLayoutPreference = current.diffLayoutPreference; } diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index e35cb455..7a1450fd 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -1,4 +1,5 @@ import type { ProjectEntry } from '@/lib/api/types'; +import type { MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; export type AssistantNotificationPayload = { title?: string; @@ -121,6 +122,7 @@ export type DesktopSettings = { gitModelId?: string; pwaAppName?: string; pwaOrientation?: 'system' | 'portrait' | 'landscape'; + mobileKeyboardMode?: MobileKeyboardMode; inputSpellcheckEnabled?: boolean; showToolFileIcons?: boolean; showExpandedBashTools?: boolean; diff --git a/packages/ui/src/lib/device.ts b/packages/ui/src/lib/device.ts index d630c662..b6b0b7c7 100644 --- a/packages/ui/src/lib/device.ts +++ b/packages/ui/src/lib/device.ts @@ -11,6 +11,7 @@ export interface DeviceInfo { screenWidth: number; breakpoint: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; hasTouchInput: boolean; + hasTouchOnlyPointer: boolean; } export const CSS_DEVICE_VARIABLES = { @@ -100,6 +101,7 @@ export function getDeviceInfo(): DeviceInfo { const { isExplicitTablet } = getNavigatorDeviceHints(maxTouchPoints); const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0; + const hasTouchOnlyPointer = prefersCoarsePointer || noHover; const isTabletWidth = width > BREAKPOINTS.md && width <= BREAKPOINTS.lg; const isMobileWidth = width <= BREAKPOINTS.md; @@ -140,6 +142,7 @@ export function getDeviceInfo(): DeviceInfo { screenWidth: width, breakpoint, hasTouchInput, + hasTouchOnlyPointer, }; } @@ -231,6 +234,7 @@ export function useDeviceInfo(): DeviceInfo { screenWidth: 1024, breakpoint: 'lg', hasTouchInput: false, + hasTouchOnlyPointer: false, }; } return getDeviceInfo(); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 2d69d411..30db1dd6 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { 'settings.openchamber.visual.field.pwaInstallOrientationAria': 'PWA install orientation', 'settings.openchamber.visual.field.selectOrientationPlaceholder': 'Select orientation', 'settings.openchamber.visual.actions.resetInstallOrientationAria': 'Reset install orientation', + 'settings.openchamber.visual.field.mobileKeyboardMode': 'Mobile Keyboard Behavior', + 'settings.openchamber.visual.field.mobileKeyboardModeHint': 'Default browser behavior is safest. Resize content asks supported browsers to shrink the app when the on-screen keyboard opens.', + 'settings.openchamber.visual.field.mobileKeyboardModeAria': 'Mobile keyboard behavior', + 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': 'Select keyboard behavior', + 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': 'Reset mobile keyboard behavior', 'settings.openchamber.visual.field.interfaceFontSize': 'Interface Font Size', 'settings.openchamber.visual.field.interfaceFont': 'Interface Font', 'settings.openchamber.visual.field.selectInterfaceFontAria': 'Select interface font', @@ -1460,6 +1465,10 @@ export const settingsDict = { 'settings.openchamber.visual.option.pwaOrientation.portrait.description': 'Install the app locked to portrait.', 'settings.openchamber.visual.option.pwaOrientation.landscape.label': 'Landscape lock', 'settings.openchamber.visual.option.pwaOrientation.landscape.description': 'Install the app locked to landscape.', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.label': 'Follow browser', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.description': 'Use the browser default for panning and viewport changes when the keyboard opens.', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label': 'Resize content', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description': 'Ask supported browsers to shrink the app layout instead of relying only on panning.', 'settings.openchamber.visual.option.userMessageRendering.markdown.label': 'Markdown', 'settings.openchamber.visual.option.userMessageRendering.markdown.description': 'Render user text with markdown formatting.', 'settings.openchamber.visual.option.userMessageRendering.plain.label': 'Plain text', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 2f6686a3..305676b0 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { "settings.openchamber.visual.field.pwaInstallOrientationAria": "Orientación de instalación de PWA", "settings.openchamber.visual.field.selectOrientationPlaceholder": "Seleccionar orientación", "settings.openchamber.visual.actions.resetInstallOrientationAria": "Restablecer orientación de instalación", + "settings.openchamber.visual.field.mobileKeyboardMode": "Comportamiento del teclado móvil", + "settings.openchamber.visual.field.mobileKeyboardModeHint": "El comportamiento predeterminado del navegador es el más seguro. Redimensionar el contenido pide a los navegadores compatibles reducir la app cuando se abre el teclado.", + "settings.openchamber.visual.field.mobileKeyboardModeAria": "Comportamiento del teclado móvil", + "settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder": "Seleccionar comportamiento del teclado", + "settings.openchamber.visual.actions.resetMobileKeyboardModeAria": "Restablecer comportamiento del teclado móvil", "settings.openchamber.visual.field.interfaceFontSize": "Tamaño de fuente de la interfaz", "settings.openchamber.visual.field.interfaceFont": "Fuente de la interfaz", "settings.openchamber.visual.field.selectInterfaceFontAria": "Seleccionar fuente de interfaz", @@ -1460,6 +1465,10 @@ export const settingsDict = { "settings.openchamber.visual.option.pwaOrientation.portrait.description": "Instalar la aplicación bloqueada en vertical.", "settings.openchamber.visual.option.pwaOrientation.landscape.label": "Bloqueo horizontal", "settings.openchamber.visual.option.pwaOrientation.landscape.description": "Instalar la aplicación bloqueada en horizontal.", + "settings.openchamber.visual.option.mobileKeyboardMode.native.label": "Seguir el navegador", + "settings.openchamber.visual.option.mobileKeyboardMode.native.description": "Usar el comportamiento predeterminado del navegador para el paneo y los cambios del viewport al abrir el teclado.", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label": "Redimensionar contenido", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description": "Pedir a los navegadores compatibles que reduzcan el diseño de la app en lugar de depender solo del paneo.", "settings.openchamber.visual.option.userMessageRendering.markdown.label": "Markdown", "settings.openchamber.visual.option.userMessageRendering.markdown.description": "Renderiza el texto del usuario con formato markdown.", "settings.openchamber.visual.option.userMessageRendering.plain.label": "Texto plano", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 38703777..96f1b462 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { 'settings.openchamber.visual.field.pwaInstallOrientationAria': 'PWA 설치 방향', 'settings.openchamber.visual.field.selectOrientationPlaceholder': '방향 선택', 'settings.openchamber.visual.actions.resetInstallOrientationAria': '설치 방향 초기화', + 'settings.openchamber.visual.field.mobileKeyboardMode': '모바일 키보드 동작', + 'settings.openchamber.visual.field.mobileKeyboardModeHint': '기본 브라우저 동작이 가장 안전합니다. 콘텐츠 크기 조정은 지원되는 브라우저에 키보드가 열릴 때 앱을 줄이도록 요청합니다.', + 'settings.openchamber.visual.field.mobileKeyboardModeAria': '모바일 키보드 동작', + 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '키보드 동작 선택', + 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '모바일 키보드 동작 초기화', 'settings.openchamber.visual.field.interfaceFontSize': '인터페이스 폰트 크기', 'settings.openchamber.visual.field.interfaceFont': '인터페이스 폰트', 'settings.openchamber.visual.field.selectInterfaceFontAria': '인터페이스 폰트 선택', @@ -1460,6 +1465,10 @@ export const settingsDict = { 'settings.openchamber.visual.option.pwaOrientation.portrait.description': '앱을 세로로 고정해 설치합니다.', 'settings.openchamber.visual.option.pwaOrientation.landscape.label': '가로 고정', 'settings.openchamber.visual.option.pwaOrientation.landscape.description': '앱을 가로로 고정해 설치합니다.', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.label': '브라우저 따르기', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.description': '키보드가 열릴 때 브라우저 기본 패닝 및 뷰포트 동작을 사용합니다.', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label': '콘텐츠 크기 조정', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description': '지원되는 브라우저에 패닝만 의존하지 않고 앱 레이아웃을 줄이도록 요청합니다.', 'settings.openchamber.visual.option.userMessageRendering.markdown.label': 'Markdown', 'settings.openchamber.visual.option.userMessageRendering.markdown.description': '사용자 텍스트를 Markdown 서식으로 렌더링합니다.', 'settings.openchamber.visual.option.userMessageRendering.plain.label': 'Plain text', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index c57b4212..6d2b912d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { "settings.openchamber.visual.field.pwaInstallOrientationAria": "Orientação de instalação da PWA", "settings.openchamber.visual.field.selectOrientationPlaceholder": "Selecionar orientação", "settings.openchamber.visual.actions.resetInstallOrientationAria": "Redefinir orientação de instalação", + "settings.openchamber.visual.field.mobileKeyboardMode": "Comportamento do teclado móvel", + "settings.openchamber.visual.field.mobileKeyboardModeHint": "O comportamento padrão do navegador é o mais seguro. Redimensionar conteúdo pede aos navegadores compatíveis que encolham o app quando o teclado abre.", + "settings.openchamber.visual.field.mobileKeyboardModeAria": "Comportamento do teclado móvel", + "settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder": "Selecionar comportamento do teclado", + "settings.openchamber.visual.actions.resetMobileKeyboardModeAria": "Redefinir comportamento do teclado móvel", "settings.openchamber.visual.field.interfaceFontSize": "Tamanho base da interface", "settings.openchamber.visual.field.interfaceFont": "Fonte da interface", "settings.openchamber.visual.field.selectInterfaceFontAria": "Selecionar fonte da interface", @@ -1460,6 +1465,10 @@ export const settingsDict = { "settings.openchamber.visual.option.pwaOrientation.portrait.description": "Instalar o aplicativo bloqueado na vertical.", "settings.openchamber.visual.option.pwaOrientation.landscape.label": "Bloqueio horizontal", "settings.openchamber.visual.option.pwaOrientation.landscape.description": "Instalar o aplicativo bloqueado na horizontal.", + "settings.openchamber.visual.option.mobileKeyboardMode.native.label": "Seguir navegador", + "settings.openchamber.visual.option.mobileKeyboardMode.native.description": "Usar o padrão do navegador para pan e mudanças de viewport quando o teclado abrir.", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label": "Redimensionar conteúdo", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description": "Pedir aos navegadores compatíveis que encolham o layout do app em vez de depender só do pan.", "settings.openchamber.visual.option.userMessageRendering.markdown.label": "Markdown", "settings.openchamber.visual.option.userMessageRendering.markdown.description": "Renderiza o texto do usuário com formato markdown.", "settings.openchamber.visual.option.userMessageRendering.plain.label": "Texto simples", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 24e253b8..8c8c24e2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { "settings.openchamber.visual.field.pwaInstallOrientationAria": "Орієнтація встановлення PWA", "settings.openchamber.visual.field.selectOrientationPlaceholder": "Вибрати орієнтацію", "settings.openchamber.visual.actions.resetInstallOrientationAria": "Скинути орієнтацію встановлення", + "settings.openchamber.visual.field.mobileKeyboardMode": "Поведінка мобільної клавіатури", + "settings.openchamber.visual.field.mobileKeyboardModeHint": "Типова поведінка браузера є найбезпечнішою. Режим зміни розміру просить сумісні браузери зменшувати застосунок, коли відкривається клавіатура.", + "settings.openchamber.visual.field.mobileKeyboardModeAria": "Поведінка мобільної клавіатури", + "settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder": "Вибрати поведінку клавіатури", + "settings.openchamber.visual.actions.resetMobileKeyboardModeAria": "Скинути поведінку мобільної клавіатури", "settings.openchamber.visual.field.interfaceFontSize": "Розмір шрифту інтерфейсу", "settings.openchamber.visual.field.interfaceFont": "Шрифт інтерфейсу", "settings.openchamber.visual.field.selectInterfaceFontAria": "Вибрати шрифт інтерфейсу", @@ -1460,6 +1465,10 @@ export const settingsDict = { "settings.openchamber.visual.option.pwaOrientation.portrait.description": "Встановлювати застосунок у портретній орієнтації.", "settings.openchamber.visual.option.pwaOrientation.landscape.label": "Зафіксувати альбомну", "settings.openchamber.visual.option.pwaOrientation.landscape.description": "Встановлювати застосунок в альбомній орієнтації.", + "settings.openchamber.visual.option.mobileKeyboardMode.native.label": "За браузером", + "settings.openchamber.visual.option.mobileKeyboardMode.native.description": "Використовувати типову поведінку браузера для панорамування та змін viewport, коли відкривається клавіатура.", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label": "Змінювати розмір вмісту", + "settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description": "Просити сумісні браузери зменшувати макет застосунку замість покладатися лише на панорамування.", "settings.openchamber.visual.option.userMessageRendering.markdown.label": "Markdown", "settings.openchamber.visual.option.userMessageRendering.markdown.description": "Виводити текст користувача з форматуванням розмітки.", "settings.openchamber.visual.option.userMessageRendering.plain.label": "Звичайний текст", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 2ea0879c..fbdba8e2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1384,6 +1384,11 @@ export const settingsDict = { 'settings.openchamber.visual.field.pwaInstallOrientationAria': 'PWA 安装方向', 'settings.openchamber.visual.field.selectOrientationPlaceholder': '选择方向', 'settings.openchamber.visual.actions.resetInstallOrientationAria': '重置安装方向', + 'settings.openchamber.visual.field.mobileKeyboardMode': '移动键盘行为', + 'settings.openchamber.visual.field.mobileKeyboardModeHint': '默认浏览器行为最稳妥。调整内容大小会请求受支持的浏览器在键盘打开时缩小应用布局。', + 'settings.openchamber.visual.field.mobileKeyboardModeAria': '移动键盘行为', + 'settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder': '选择键盘行为', + 'settings.openchamber.visual.actions.resetMobileKeyboardModeAria': '重置移动键盘行为', 'settings.openchamber.visual.field.interfaceFontSize': '界面字体大小', 'settings.openchamber.visual.field.interfaceFont': '界面字体', 'settings.openchamber.visual.field.selectInterfaceFontAria': '选择界面字体', @@ -1460,6 +1465,10 @@ export const settingsDict = { 'settings.openchamber.visual.option.pwaOrientation.portrait.description': '安装后应用锁定为竖屏。', 'settings.openchamber.visual.option.pwaOrientation.landscape.label': '锁定横屏', 'settings.openchamber.visual.option.pwaOrientation.landscape.description': '安装后应用锁定为横屏。', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.label': '跟随浏览器', + 'settings.openchamber.visual.option.mobileKeyboardMode.native.description': '键盘打开时使用浏览器默认的平移和视口变化行为。', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label': '调整内容大小', + 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description': '请求受支持的浏览器缩小应用布局,而不是只依赖平移。', 'settings.openchamber.visual.option.userMessageRendering.markdown.label': 'Markdown', 'settings.openchamber.visual.option.userMessageRendering.markdown.description': '用 Markdown 格式渲染用户文本。', 'settings.openchamber.visual.option.userMessageRendering.plain.label': '纯文本', diff --git a/packages/ui/src/lib/mobileKeyboardMode.ts b/packages/ui/src/lib/mobileKeyboardMode.ts new file mode 100644 index 00000000..683af29e --- /dev/null +++ b/packages/ui/src/lib/mobileKeyboardMode.ts @@ -0,0 +1,96 @@ +export type MobileKeyboardMode = 'native' | 'resize-content'; + +export const MOBILE_KEYBOARD_MODE_STORAGE_KEY = 'openchamber.mobileKeyboardMode'; +export const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]'; +export const VIEWPORT_CONTENT_BASE = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; + +export const supportsMobileKeyboardResizeContent = (): boolean => { + if (typeof navigator === 'undefined') { + return true; + } + + const userAgent = navigator.userAgent || ''; + const platform = navigator.platform || ''; + const maxTouchPoints = navigator.maxTouchPoints ?? 0; + const isIOS = /iPhone|iPad|iPod/i.test(userAgent) + || ((/Macintosh|MacIntel/i.test(userAgent) || /MacIntel/i.test(platform)) && maxTouchPoints > 1); + + return !isIOS; +}; + +const getSupportedMobileKeyboardMode = (mode: MobileKeyboardMode): MobileKeyboardMode => { + if (mode === 'resize-content' && !supportsMobileKeyboardResizeContent()) { + return 'native'; + } + return mode; +}; + +export function normalizeMobileKeyboardMode(value: unknown): MobileKeyboardMode; +export function normalizeMobileKeyboardMode(value: unknown, fallback: MobileKeyboardMode): MobileKeyboardMode; +export function normalizeMobileKeyboardMode(value: unknown, fallback: undefined): MobileKeyboardMode | undefined; +export function normalizeMobileKeyboardMode( + value: unknown, + fallback: MobileKeyboardMode | undefined = 'native', +): MobileKeyboardMode | undefined { + if (value === 'native' || value === 'resize-content') { + return value; + } + return fallback; +} + +export const getViewportContentForMobileKeyboardMode = (value: unknown): string => { + const mode = getSupportedMobileKeyboardMode(normalizeMobileKeyboardMode(value)); + return mode === 'resize-content' + ? `${VIEWPORT_CONTENT_BASE}, interactive-widget=resizes-content` + : VIEWPORT_CONTENT_BASE; +}; + +export const getStoredMobileKeyboardMode = (): MobileKeyboardMode => { + if (typeof window === 'undefined') { + return 'native'; + } + + try { + return getSupportedMobileKeyboardMode(normalizeMobileKeyboardMode(localStorage.getItem(MOBILE_KEYBOARD_MODE_STORAGE_KEY))); + } catch { + return 'native'; + } +}; + +export const setStoredMobileKeyboardMode = (value: unknown): MobileKeyboardMode => { + const mode = getSupportedMobileKeyboardMode(normalizeMobileKeyboardMode(value)); + + if (typeof window !== 'undefined') { + try { + if (mode === 'native') { + localStorage.removeItem(MOBILE_KEYBOARD_MODE_STORAGE_KEY); + } else { + localStorage.setItem(MOBILE_KEYBOARD_MODE_STORAGE_KEY, mode); + } + } catch { + // Ignore storage failures in restricted browsing contexts. + } + } + + return mode; +}; + +export const applyMobileKeyboardMode = (value: unknown): MobileKeyboardMode => { + const mode = setStoredMobileKeyboardMode(value); + + if (typeof document === 'undefined') { + return mode; + } + + document.documentElement.setAttribute('data-oc-mobile-keyboard-mode', mode); + + const viewportMeta = document.querySelector(VIEWPORT_META_SELECTOR); + if (viewportMeta instanceof HTMLMetaElement) { + const nextContent = getViewportContentForMobileKeyboardMode(mode); + if (viewportMeta.getAttribute('content') !== nextContent) { + viewportMeta.setAttribute('content', nextContent); + } + } + + return mode; +}; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 0e5806d5..1df996bc 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -7,6 +7,7 @@ import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; import { setFilesViewShowGitignored } from '@/lib/filesViewShowGitignored'; import { loadAppearancePreferences, applyAppearancePreferences } from '@/lib/appearancePersistence'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; +import { normalizeMobileKeyboardMode, setStoredMobileKeyboardMode } from '@/lib/mobileKeyboardMode'; const persistToLocalStorage = (settings: DesktopSettings) => { if (typeof window === 'undefined') { @@ -92,6 +93,9 @@ const persistToLocalStorage = (settings: DesktopSettings) => { localStorage.removeItem('openchamber.pwaName'); } } + if (typeof settings.mobileKeyboardMode === 'string') { + setStoredMobileKeyboardMode(settings.mobileKeyboardMode); + } }; type PersistApi = { @@ -464,6 +468,12 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) { store.setInputBarOffset(settings.inputBarOffset); } + if (typeof settings.mobileKeyboardMode === 'string') { + const mode = normalizeMobileKeyboardMode(settings.mobileKeyboardMode, store.mobileKeyboardMode); + if (mode !== store.mobileKeyboardMode) { + store.setMobileKeyboardMode(mode); + } + } if (Array.isArray(settings.favoriteModels)) { const current = store.favoriteModels; @@ -889,6 +899,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) { result.inputBarOffset = candidate.inputBarOffset; } + if (typeof candidate.mobileKeyboardMode === 'string') { + const mode = normalizeMobileKeyboardMode(candidate.mobileKeyboardMode, undefined); + if (mode) { + result.mobileKeyboardMode = mode; + } + } const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64); if (favoriteModels) { diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 7ae93bc9..41ae772e 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -152,7 +152,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ title: 'Appearance', group: 'appearance', kind: 'single', - keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'terminal', 'pwa', 'install name', 'app shortcuts'], + keywords: ['theme', 'font', 'spacing', 'padding', 'corner radius', 'radius', 'input bar', 'keyboard', 'viewport', 'mobile', 'terminal', 'pwa', 'install name', 'app shortcuts'], }, { slug: 'chat', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0ddc8d3b..d3b1bfb2 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -5,6 +5,7 @@ import { getSafeStorage } from './utils/safeStorage'; import { SEMANTIC_TYPOGRAPHY, getTypographyVariable, type SemanticTypographyKey } from '@/lib/typography'; import type { ShortcutCombo } from '@/lib/shortcuts'; import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions'; +import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode'; export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files'; export type RightSidebarTab = 'git' | 'files' | 'context'; @@ -521,6 +522,7 @@ interface UIStore { padding: number; cornerRadius: number; inputBarOffset: number; + mobileKeyboardMode: MobileKeyboardMode; favoriteModels: Array<{ providerID: string; modelID: string }>; hiddenModels: Array<{ providerID: string; modelID: string }>; @@ -645,6 +647,7 @@ interface UIStore { setPadding: (size: number) => void; setCornerRadius: (radius: number) => void; setInputBarOffset: (offset: number) => void; + setMobileKeyboardMode: (mode: MobileKeyboardMode) => void; applyTypography: () => void; applyPadding: () => void; updateProportionalSidebarWidths: () => void; @@ -772,6 +775,7 @@ export const useUIStore = create()( padding: 100, cornerRadius: 18, inputBarOffset: 0, + mobileKeyboardMode: getStoredMobileKeyboardMode(), favoriteModels: [], hiddenModels: [], collapsedModelProviders: [], @@ -1513,6 +1517,10 @@ export const useUIStore = create()( set({ inputBarOffset: offset }); }, + setMobileKeyboardMode: (mode) => { + set((state) => state.mobileKeyboardMode === mode ? state : { mobileKeyboardMode: mode }); + }, + toggleFavoriteModel: (providerID, modelID) => { set((state) => { const exists = state.favoriteModels.some( diff --git a/packages/web/index.html b/packages/web/index.html index 662d1203..07f2b132 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -28,7 +28,9 @@ const defaultShortName = 'OpenChamber'; const pwaNameStorageKey = 'openchamber.pwaName'; const pwaOrientationStorageKey = 'openchamber.pwaOrientation'; + const mobileKeyboardModeStorageKey = 'openchamber.mobileKeyboardMode'; const pwaRecentSessionsStorageKey = 'openchamber.pwaRecentSessions'; + const viewportBaseContent = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover'; const normalizePwaName = (value, fallback) => { if (typeof value !== 'string') { @@ -55,6 +57,57 @@ return fallback; }; + const normalizeMobileKeyboardMode = (value, fallback = 'native') => { + if (value === 'native' || value === 'resize-content') { + return value; + } + return fallback; + }; + + const supportsMobileKeyboardResizeContent = () => { + const userAgent = navigator.userAgent || ''; + const platform = navigator.platform || ''; + const maxTouchPoints = navigator.maxTouchPoints || 0; + const isIOS = /iPhone|iPad|iPod/i.test(userAgent) + || ((/Macintosh|MacIntel/i.test(userAgent) || /MacIntel/i.test(platform)) && maxTouchPoints > 1); + + return !isIOS; + }; + + const getSupportedMobileKeyboardMode = (mode) => { + if (mode === 'resize-content' && !supportsMobileKeyboardResizeContent()) { + return 'native'; + } + return mode; + }; + + const getViewportContentForMobileKeyboardMode = (value) => { + const mode = getSupportedMobileKeyboardMode(normalizeMobileKeyboardMode(value, 'native')); + return mode === 'resize-content' + ? `${viewportBaseContent}, interactive-widget=resizes-content` + : viewportBaseContent; + }; + + const applyStoredMobileKeyboardMode = () => { + let mode = 'native'; + try { + mode = getSupportedMobileKeyboardMode(normalizeMobileKeyboardMode(localStorage.getItem(mobileKeyboardModeStorageKey), 'native')); + if (mode === 'native') { + localStorage.removeItem(mobileKeyboardModeStorageKey); + } + } catch { + mode = 'native'; + } + + document.documentElement.setAttribute('data-oc-mobile-keyboard-mode', mode); + const viewportMeta = document.querySelector('meta[name="viewport"]'); + if (viewportMeta) { + viewportMeta.setAttribute('content', getViewportContentForMobileKeyboardMode(mode)); + } + }; + + applyStoredMobileKeyboardMode(); + const getStoredInstallName = () => { try { const storedName = localStorage.getItem(pwaNameStorageKey); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index cd509dab..e19b0ee7 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -19,6 +19,7 @@ export const createSettingsHelpers = (dependencies) => { const PWA_APP_NAME_MAX_LENGTH = 64; const PWA_ORIENTATION_VALUES = new Set(['system', 'portrait', 'landscape']); + const MOBILE_KEYBOARD_MODE_VALUES = new Set(['native', 'resize-content']); const normalizePwaAppName = (value, fallback = '') => { if (typeof value !== 'string') { @@ -42,6 +43,17 @@ export const createSettingsHelpers = (dependencies) => { return fallback; }; + const normalizeMobileKeyboardMode = (value, fallback = 'native') => { + if (typeof value !== 'string') { + return fallback; + } + const normalized = value.trim(); + if (MOBILE_KEYBOARD_MODE_VALUES.has(normalized)) { + return normalized; + } + return fallback; + }; + const sanitizeSettingsUpdate = (payload) => { if (!payload || typeof payload !== 'object') { return {}; @@ -310,6 +322,12 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.pwaOrientation === 'string') { result.pwaOrientation = normalizePwaOrientation(candidate.pwaOrientation, undefined); } + if (typeof candidate.mobileKeyboardMode === 'string') { + const mode = normalizeMobileKeyboardMode(candidate.mobileKeyboardMode, undefined); + if (mode) { + result.mobileKeyboardMode = mode; + } + } if (typeof candidate.toolCallExpansion === 'string') { const mode = candidate.toolCallExpansion.trim(); if (mode === 'collapsed' || mode === 'activity' || mode === 'detailed' || mode === 'changes') { @@ -650,12 +668,14 @@ export const createSettingsHelpers = (dependencies) => { const hasManagedRemoteTunnelToken = typeof settings?.managedRemoteTunnelToken === 'string' && settings.managedRemoteTunnelToken.trim().length > 0; const pwaAppName = normalizePwaAppName(settings?.pwaAppName, ''); const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system'); + const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native'); return { ...sanitized, hasManagedRemoteTunnelToken, ...(pwaAppName ? { pwaAppName } : {}), pwaOrientation, + mobileKeyboardMode, approvedDirectories: approved, securityScopedBookmarks: bookmarks, pinnedDirectories: normalizeStringArray(settings.pinnedDirectories), @@ -672,6 +692,7 @@ export const createSettingsHelpers = (dependencies) => { return { normalizePwaAppName, normalizePwaOrientation, + normalizeMobileKeyboardMode, sanitizeSettingsUpdate, mergePersistedSettings, formatSettingsResponse, diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 4d19aa0d..e71866c4 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -51,4 +51,24 @@ describe('settings helpers', () => { desktopLanAccessEnabled: false, }); }); + + it('accepts mobileKeyboardMode as a persisted shared setting', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'native' })).toEqual({ + mobileKeyboardMode: 'native', + }); + expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'resize-content' })).toEqual({ + mobileKeyboardMode: 'resize-content', + }); + expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: ' resize-content ' })).toEqual({ + mobileKeyboardMode: 'resize-content', + }); + }); + + it('rejects invalid mobileKeyboardMode values', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ mobileKeyboardMode: 'fixed-layout' })).toEqual({}); + }); });