Files
claudecodeui/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx
PaloSP b0a3fdf95f 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>
2026-03-04 11:41:00 +03:00

171 lines
6.6 KiB
TypeScript

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}
/>
)}
</>
);
}