fix(ui): add opt-in mobile keyboard resize mode and stabilize touch terminal input (#1107)
* fix(ui): add opt-in mobile keyboard resize mode * fix(ui): stabilize touch terminal input and tab layout * fix(ui): narrow touch terminal handling to mobile and tablet * fix(ui): refine touch terminal input handling * fix(ui): re-key terminal viewport on session id * fix(ui): avoid touch-laptop terminal overlay regression * fix(ui): hide ghostty system caret surfaces * fix(settings): normalize mobile keyboard mode sanitization * fix(ui): honor terminal quick keys toggle on mobile * fix(ui): gate mobile keyboard resize mode on iOS --------- Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
vhqtvn
Bohdan Triapitsyn
parent
63b4a5b996
commit
962ee41bba
@@ -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<BootInjectionStatus>(() => {
|
||||
@@ -246,6 +248,10 @@ function App({ apis }: AppProps) {
|
||||
};
|
||||
}, [showMemoryDebug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
applyMobileKeyboardMode(mobileKeyboardMode);
|
||||
}, [mobileKeyboardMode]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
||||
}, [apis.runtime.isVSCode]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -115,6 +115,7 @@ const VisualSectionContent: React.FC = () => {
|
||||
'theme',
|
||||
'pwaInstallName',
|
||||
'pwaOrientation',
|
||||
'mobileKeyboardMode',
|
||||
'timeFormat',
|
||||
'weekStart',
|
||||
'fontSize',
|
||||
|
||||
@@ -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<MobileKeyboardMode>[] = [
|
||||
{
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
}, []);
|
||||
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
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<OpenChamberVisualSettingsProps>
|
||||
if (showPwaOrientationSetting) {
|
||||
setPwaOrientation(orientation);
|
||||
}
|
||||
if (showMobileKeyboardModeSetting) {
|
||||
setMobileKeyboardMode(nextMobileKeyboardMode);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
@@ -589,6 +614,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
if (showPwaOrientationSetting) {
|
||||
setPwaOrientation('system');
|
||||
}
|
||||
if (showMobileKeyboardModeSetting) {
|
||||
setMobileKeyboardMode('native');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -598,7 +626,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showPwaInstallNameSetting, showPwaOrientationSetting]);
|
||||
}, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -768,7 +796,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(showPwaInstallNameSetting || showPwaOrientationSetting) && (
|
||||
{(showPwaInstallNameSetting || showPwaOrientationSetting || showMobileKeyboardModeSetting) && (
|
||||
<section className="px-2 pb-2 pt-0 space-y-2">
|
||||
|
||||
{showPwaInstallNameSetting && (
|
||||
@@ -858,6 +886,52 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showMobileKeyboardModeSetting && (
|
||||
<div className="py-1.5 space-y-1.5">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.mobileKeyboardMode')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.mobileKeyboardModeHint')}</span>
|
||||
</div>
|
||||
<div className="flex w-full max-w-[18rem] items-center gap-2">
|
||||
<Select
|
||||
value={mobileKeyboardMode}
|
||||
onValueChange={(value) => {
|
||||
const mode = normalizeMobileKeyboardMode(value);
|
||||
setMobileKeyboardMode(mode);
|
||||
void updateDesktopSettings({ mobileKeyboardMode: mode });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger aria-label={t('settings.openchamber.visual.field.mobileKeyboardModeAria')} className="w-full">
|
||||
<SelectValue placeholder={t('settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder')}>
|
||||
{selectedMobileKeyboardModeLabel}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOBILE_KEYBOARD_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
{tUnsafe(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setMobileKeyboardMode('native');
|
||||
void updateDesktopSettings({ mobileKeyboardMode: 'native' });
|
||||
}}
|
||||
disabled={mobileKeyboardMode === 'native'}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.openchamber.visual.actions.resetMobileKeyboardModeAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<RiRestartLine className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<TerminalController, TerminalViewportProps>(
|
||||
@@ -92,6 +92,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
className,
|
||||
enableTouchScroll,
|
||||
autoFocus = true,
|
||||
isVisible = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -117,37 +118,72 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const ignoreNextInputRef = React.useRef(false);
|
||||
const lastBeforeInputRef = React.useRef<{ type: string; at: number } | null>(null);
|
||||
const lastInputEventAtRef = React.useRef<number | null>(null);
|
||||
const refocusTimeoutRef = React.useRef<number | null>(null);
|
||||
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
|
||||
const lastObservedValueRef = React.useRef('');
|
||||
const cursorBlinkStateRef = React.useRef<boolean | null>(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<HTMLElement>('[contenteditable="true"]'));
|
||||
const editableNodes: HTMLElement[] = [];
|
||||
if (container.getAttribute('contenteditable') === 'true') {
|
||||
editableNodes.push(container);
|
||||
}
|
||||
editableNodes.push(...Array.from(container.querySelectorAll<HTMLElement>('[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<TerminalController, TerminalViewportPr
|
||||
textarea.style.fontSize = '0';
|
||||
textarea.style.lineHeight = '0';
|
||||
|
||||
if (!useHiddenInputOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textarea === hiddenInput) {
|
||||
return;
|
||||
}
|
||||
@@ -706,7 +738,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
if (wasTap) {
|
||||
if (useHiddenInputOverlay) {
|
||||
focusHiddenInput(event.clientX, event.clientY);
|
||||
} else {
|
||||
} else if (enableTouchScroll && !focusArmedRef.current) {
|
||||
focusArmedRef.current = true;
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
@@ -860,7 +893,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const point = event.changedTouches?.[0];
|
||||
if (useHiddenInputOverlay) {
|
||||
focusHiddenInput(point?.clientX, point?.clientY);
|
||||
} else {
|
||||
} else if (enableTouchScroll && !focusArmedRef.current) {
|
||||
focusArmedRef.current = true;
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
@@ -929,6 +963,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
let localTextareaObserver: MutationObserver | null = null;
|
||||
let localDisposables: Array<{ dispose: () => 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<TerminalController, TerminalViewportPr
|
||||
|
||||
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
||||
|
||||
if (useHiddenInputOverlay) {
|
||||
const originalFocus = container.focus.bind(container);
|
||||
const patchedContainer = container as HTMLDivElement & {
|
||||
focus: typeof container.focus;
|
||||
};
|
||||
patchedContainer.focus = ((...args: Parameters<HTMLElement['focus']>) => {
|
||||
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<TerminalController, TerminalViewportPr
|
||||
const handleDocumentFocusIn = (event: FocusEvent) => {
|
||||
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<TerminalController, TerminalViewportPr
|
||||
const terminal = new GhosttyTerminal(options);
|
||||
followOutputRef.current = true;
|
||||
|
||||
if (useHiddenInputOverlay) {
|
||||
terminal.focus = () => {};
|
||||
}
|
||||
|
||||
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<TerminalController, TerminalViewportPr
|
||||
localTextareaObserver = new MutationObserver(() => {
|
||||
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<TerminalController, TerminalViewportPr
|
||||
}
|
||||
localResizeObserver?.disconnect();
|
||||
localTextareaObserver?.disconnect();
|
||||
restoreContainerFocus?.();
|
||||
|
||||
localTerminal?.dispose();
|
||||
terminalRef.current = null;
|
||||
@@ -1097,7 +1170,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
cursorBlinkStateRef.current = null;
|
||||
resetWriteState();
|
||||
};
|
||||
}, [disableTerminalTextareas, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||
}, [disableTerminalTextareas, enableTouchScroll, fitTerminal, focusHiddenInput, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -1192,23 +1265,25 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const related = event.relatedTarget as HTMLElement | null;
|
||||
const relatedTag = related?.tagName;
|
||||
const isInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || related?.isContentEditable;
|
||||
const isHiddenInput = related?.getAttribute('data-terminal-hidden-input') === 'true';
|
||||
if (isInput && !isHiddenInput) {
|
||||
if (refocusTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(refocusTimeoutRef.current);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
refocusTimeoutRef.current = window.setTimeout(() => {
|
||||
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<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const specialKeySequences: Record<string, string> = {
|
||||
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<TerminalController, TerminalViewportPr
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
||||
data-hidden-input-overlay-active={useHiddenInputOverlay ? 'true' : undefined}
|
||||
style={{ backgroundColor: theme.background }}
|
||||
onTouchStart={(event) => {
|
||||
if (!useHiddenInputOverlay || enableTouchScroll) {
|
||||
@@ -1477,10 +1573,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (useHiddenInputOverlay) {
|
||||
if (enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (hasCopyableSelectionInViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +382,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
<div
|
||||
ref={(element) => 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<SortableTabsStripProps> = ({
|
||||
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<SortableTabsStripProps> = ({
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
@@ -462,7 +462,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
{shouldShowLabel ? <span className="animated-tabs__label truncate">{item.label}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
<span className={cn('flex min-w-0 items-center gap-1.5', !isScrollable && 'justify-center')}>
|
||||
<span className={cn('flex min-w-0 flex-nowrap items-center gap-1.5', !isScrollable && 'justify-center')}>
|
||||
{item.icon ? (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -475,7 +475,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
<Button
|
||||
@@ -1033,10 +1045,10 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
|
||||
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'pl-3 pr-1.5 py-1' : 'pl-3 pr-1.5 py-1')}>
|
||||
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isTouchTerminal ? 'px-3 py-1.5' : 'pl-3 pr-1.5 py-1')}>
|
||||
{enableTabs && directoryTerminalState ? (
|
||||
<div className="flex items-center gap-2 pl-1 pr-1">
|
||||
<div className={cn('min-w-0 flex-1', isMobile ? 'h-8' : 'h-7')}>
|
||||
<div className={cn('min-w-0 flex-1', isTouchTerminal ? 'h-8' : 'h-7')}>
|
||||
<SortableTabsStrip
|
||||
items={terminalTabItems}
|
||||
activeId={activeTabId}
|
||||
@@ -1052,11 +1064,11 @@ export const TerminalView: React.FC = () => {
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
className={cn('shrink-0', isMobile ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
|
||||
className={cn('shrink-0', isTouchTerminal ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
|
||||
onClick={handleCreateTab}
|
||||
title={t('terminalView.tabs.newTabTitle')}
|
||||
>
|
||||
<RiAddLine size={isMobile ? 18 : 16} />
|
||||
<RiAddLine size={isTouchTerminal ? 18 : 16} />
|
||||
</Button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 overflow-visible">
|
||||
@@ -1126,7 +1138,6 @@ export const TerminalView: React.FC = () => {
|
||||
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
|
||||
{shouldRenderViewport ? (
|
||||
<TerminalViewport
|
||||
key={viewportSessionKey}
|
||||
ref={(controller) => {
|
||||
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}
|
||||
</div>
|
||||
|
||||
+39
-39
@@ -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 {
|
||||
|
||||
@@ -594,6 +594,7 @@ export interface SettingsPayload {
|
||||
gitProviderId?: string;
|
||||
gitModelId?: string;
|
||||
pwaAppName?: string;
|
||||
mobileKeyboardMode?: 'native' | 'resize-content';
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Звичайний текст",
|
||||
|
||||
@@ -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': '纯文本',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<UIStore>()(
|
||||
padding: 100,
|
||||
cornerRadius: 18,
|
||||
inputBarOffset: 0,
|
||||
mobileKeyboardMode: getStoredMobileKeyboardMode(),
|
||||
favoriteModels: [],
|
||||
hiddenModels: [],
|
||||
collapsedModelProviders: [],
|
||||
@@ -1513,6 +1517,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ inputBarOffset: offset });
|
||||
},
|
||||
|
||||
setMobileKeyboardMode: (mode) => {
|
||||
set((state) => state.mobileKeyboardMode === mode ? state : { mobileKeyboardMode: mode });
|
||||
},
|
||||
|
||||
toggleFavoriteModel: (providerID, modelID) => {
|
||||
set((state) => {
|
||||
const exists = state.favoriteModels.some(
|
||||
|
||||
Reference in New Issue
Block a user