feat: add terminal shortcuts panel for mobile (#411)

* feat: add terminal shortcuts panel for mobile users

Slide-out panel providing touch-friendly shortcut buttons (Esc, Tab,
Shift+Tab, Arrow Up/Down) and scroll-to-bottom for the terminal.
Integrates into the new modular shell architecture by exposing
terminalRef and wsRef from useShellRuntime hook and reusing the
existing sendSocketMessage utility.

Includes localization keys for en, ja, ko, and zh-CN.

* fix: replace dual touch/click handlers with unified pointer events

Prevents double-fire on touch devices by removing onTouchEnd handlers
and using a single onClick for all interactions (mouse, touch, keyboard).
onPointerDown with preventDefault handles focus steal prevention.
Also clears pending close timer before scheduling a new one to avoid
stale timeout overlap.

Addresses CodeRabbit review feedback on PR #411.

---------

Co-authored-by: Haileyesus <118998054+blackmammoth@users.noreply.github.com>
This commit is contained in:
PaloSP
2026-03-04 09:41:00 +01:00
committed by GitHub
parent 4ee88f0eb0
commit b0a3fdf95f
8 changed files with 243 additions and 0 deletions

View File

@@ -149,6 +149,8 @@ export function useShellRuntime({
return {
terminalContainerRef,
terminalRef,
wsRef,
isConnected,
isInitialized,
isConnecting,

View File

@@ -61,6 +61,8 @@ export type ShellSharedRefs = {
export type UseShellRuntimeResult = {
terminalContainerRef: RefObject<HTMLDivElement>;
terminalRef: MutableRefObject<Terminal | null>;
wsRef: MutableRefObject<WebSocket | null>;
isConnected: boolean;
isInitialized: boolean;
isConnecting: boolean;

View File

@@ -9,6 +9,7 @@ import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay';
import ShellEmptyState from './subcomponents/ShellEmptyState';
import ShellHeader from './subcomponents/ShellHeader';
import ShellMinimalView from './subcomponents/ShellMinimalView';
import TerminalShortcutsPanel from './subcomponents/TerminalShortcutsPanel';
type ShellProps = {
selectedProject?: Project | null;
@@ -39,6 +40,8 @@ export default function Shell({
const {
terminalContainerRef,
terminalRef,
wsRef,
isConnected,
isInitialized,
isConnecting,
@@ -157,6 +160,12 @@ export default function Shell({
/>
)}
</div>
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}
isConnected={isConnected}
/>
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react';
import {
ChevronLeft,
ChevronRight,
Keyboard,
ArrowDownToLine,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { Terminal } from '@xterm/xterm';
import { sendSocketMessage } from '../../utils/socket';
const SHORTCUTS = [
{ id: 'escape', labelKey: 'escape', sequence: '\x1b', hint: 'Esc' },
{ id: 'tab', labelKey: 'tab', sequence: '\t', hint: 'Tab' },
{ id: 'shift-tab', labelKey: 'shiftTab', sequence: '\x1b[Z', hint: '\u21e7Tab' },
{ id: 'arrow-up', labelKey: 'arrowUp', sequence: '\x1b[A', hint: '\u2191' },
{ id: 'arrow-down', labelKey: 'arrowDown', sequence: '\x1b[B', hint: '\u2193' },
] as const;
type TerminalShortcutsPanelProps = {
wsRef: MutableRefObject<WebSocket | null>;
terminalRef: MutableRefObject<Terminal | null>;
isConnected: boolean;
};
const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault();
export default function TerminalShortcutsPanel({
wsRef,
terminalRef,
isConnected,
}: TerminalShortcutsPanelProps) {
const { t } = useTranslation('settings');
const [isOpen, setIsOpen] = useState(false);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleToggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const handleShortcutAction = useCallback((action: () => void) => {
action();
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 50);
}, []);
const sendInput = useCallback(
(data: string) => {
sendSocketMessage(wsRef.current, { type: 'input', data });
},
[wsRef],
);
const scrollToBottom = useCallback(() => {
terminalRef.current?.scrollToBottom();
}, [terminalRef]);
return (
<>
{/* Pull Tab */}
<button
type="button"
onPointerDown={preventFocusSteal}
onClick={handleToggle}
className={`fixed ${
isOpen ? 'right-64' : 'right-0'
} z-50 transition-all duration-150 ease-out bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-l-md p-2 hover:bg-gray-100 dark:hover:bg-gray-700 shadow-lg cursor-pointer`}
style={{ top: '50%', transform: 'translateY(-50%)' }}
aria-label={
isOpen
? t('terminalShortcuts.handle.closePanel')
: t('terminalShortcuts.handle.openPanel')
}
>
{isOpen ? (
<ChevronRight className="h-5 w-5 text-gray-600 dark:text-gray-400" />
) : (
<ChevronLeft className="h-5 w-5 text-gray-600 dark:text-gray-400" />
)}
</button>
{/* Panel */}
<div
className={`fixed top-0 right-0 h-full w-64 bg-background border-l border-border shadow-xl transform transition-transform duration-150 ease-out z-40 ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<Keyboard className="h-5 w-5 text-gray-600 dark:text-gray-400" />
{t('terminalShortcuts.title')}
</h3>
</div>
{/* Content — conditionally rendered so buttons remount with clean CSS states */}
{isOpen && (
<div className="flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-background">
{/* Shortcut Keys */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
{t('terminalShortcuts.sectionKeys')}
</h4>
{SHORTCUTS.map((shortcut) => (
<button
type="button"
key={shortcut.id}
onPointerDown={preventFocusSteal}
onClick={() => handleShortcutAction(() => sendInput(shortcut.sequence))}
disabled={!isConnected}
className="w-full flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<span className="text-sm text-gray-900 dark:text-white">
{t(`terminalShortcuts.${shortcut.labelKey}`)}
</span>
<kbd className="px-2 py-0.5 text-xs font-mono bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600">
{shortcut.hint}
</kbd>
</button>
))}
</div>
{/* Navigation */}
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">
{t('terminalShortcuts.sectionNavigation')}
</h4>
<button
type="button"
onPointerDown={preventFocusSteal}
onClick={() => handleShortcutAction(scrollToBottom)}
disabled={!isConnected}
className="w-full flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600"
>
<span className="text-sm text-gray-900 dark:text-white">
{t('terminalShortcuts.scrollDown')}
</span>
<ArrowDownToLine className="h-4 w-4 text-gray-600 dark:text-gray-400" />
</button>
</div>
</div>
)}
</div>
</div>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
onPointerDown={preventFocusSteal}
onClick={handleToggle}
/>
)}
</>
);
}

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "Terminal Shortcuts",
"sectionKeys": "Keys",
"sectionNavigation": "Navigation",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "Arrow Up",
"arrowDown": "Arrow Down",
"scrollDown": "Scroll Down",
"handle": {
"closePanel": "Close shortcuts panel",
"openPanel": "Open shortcuts panel"
}
},
"mainTabs": {
"label": "Settings",
"agents": "Agents",

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "ターミナルショートカット",
"sectionKeys": "キー",
"sectionNavigation": "ナビゲーション",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "上矢印",
"arrowDown": "下矢印",
"scrollDown": "下にスクロール",
"handle": {
"closePanel": "ショートカットパネルを閉じる",
"openPanel": "ショートカットパネルを開く"
}
},
"mainTabs": {
"label": "設定",
"agents": "エージェント",

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "터미널 단축키",
"sectionKeys": "키",
"sectionNavigation": "탐색",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "위쪽 화살표",
"arrowDown": "아래쪽 화살표",
"scrollDown": "아래로 스크롤",
"handle": {
"closePanel": "단축키 패널 닫기",
"openPanel": "단축키 패널 열기"
}
},
"mainTabs": {
"label": "설정",
"agents": "에이전트",

View File

@@ -83,6 +83,21 @@
}
}
},
"terminalShortcuts": {
"title": "终端快捷键",
"sectionKeys": "按键",
"sectionNavigation": "导航",
"escape": "Escape",
"tab": "Tab",
"shiftTab": "Shift+Tab",
"arrowUp": "上箭头",
"arrowDown": "下箭头",
"scrollDown": "滚动到底部",
"handle": {
"closePanel": "关闭快捷键面板",
"openPanel": "打开快捷键面板"
}
},
"mainTabs": {
"label": "设置",
"agents": "智能体",