Files
claudecodeui/src/components/shell/view/Shell.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

172 lines
5.3 KiB
TypeScript

import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import '@xterm/xterm/css/xterm.css';
import type { Project, ProjectSession } from '../../../types/app';
import { SHELL_RESTART_DELAY_MS } from '../constants/constants';
import { useShellRuntime } from '../hooks/useShellRuntime';
import { getSessionDisplayName } from '../utils/auth';
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;
selectedSession?: ProjectSession | null;
initialCommand?: string | null;
isPlainShell?: boolean;
onProcessComplete?: ((exitCode: number) => void) | null;
minimal?: boolean;
autoConnect?: boolean;
isActive?: boolean;
};
export default function Shell({
selectedProject = null,
selectedSession = null,
initialCommand = null,
isPlainShell = false,
onProcessComplete = null,
minimal = false,
autoConnect = false,
isActive,
}: ShellProps) {
const { t } = useTranslation('chat');
const [isRestarting, setIsRestarting] = useState(false);
// Keep the public API stable for existing callers that still pass `isActive`.
void isActive;
const {
terminalContainerRef,
terminalRef,
wsRef,
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
} = useShellRuntime({
selectedProject,
selectedSession,
initialCommand,
isPlainShell,
minimal,
autoConnect,
isRestarting,
onProcessComplete,
});
const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]);
const sessionDisplayNameShort = useMemo(
() => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null),
[sessionDisplayName],
);
const sessionDisplayNameLong = useMemo(
() => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null),
[sessionDisplayName],
);
const handleRestartShell = useCallback(() => {
setIsRestarting(true);
window.setTimeout(() => {
setIsRestarting(false);
}, SHELL_RESTART_DELAY_MS);
}, []);
if (!selectedProject) {
return (
<ShellEmptyState
title={t('shell.selectProject.title')}
description={t('shell.selectProject.description')}
/>
);
}
if (minimal) {
return (
<ShellMinimalView
terminalContainerRef={terminalContainerRef}
authUrl={authUrl}
authUrlVersion={authUrlVersion}
initialCommand={initialCommand}
isConnected={isConnected}
openAuthUrlInBrowser={openAuthUrlInBrowser}
copyAuthUrlToClipboard={copyAuthUrlToClipboard}
/>
);
}
const readyDescription = isPlainShell
? t('shell.runCommand', {
command: initialCommand || t('shell.defaultCommand'),
projectName: selectedProject.displayName,
})
: selectedSession
? t('shell.resumeSession', { displayName: sessionDisplayNameLong })
: t('shell.startSession');
const connectingDescription = isPlainShell
? t('shell.runCommand', {
command: initialCommand || t('shell.defaultCommand'),
projectName: selectedProject.displayName,
})
: t('shell.startCli', { projectName: selectedProject.displayName });
const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null;
const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription;
return (
<div className="h-full flex flex-col bg-gray-900 w-full">
<ShellHeader
isConnected={isConnected}
isInitialized={isInitialized}
isRestarting={isRestarting}
hasSession={Boolean(selectedSession)}
sessionDisplayNameShort={sessionDisplayNameShort}
onDisconnect={disconnectFromShell}
onRestart={handleRestartShell}
statusNewSessionText={t('shell.status.newSession')}
statusInitializingText={t('shell.status.initializing')}
statusRestartingText={t('shell.status.restarting')}
disconnectLabel={t('shell.actions.disconnect')}
disconnectTitle={t('shell.actions.disconnectTitle')}
restartLabel={t('shell.actions.restart')}
restartTitle={t('shell.actions.restartTitle')}
disableRestart={isRestarting || isConnected}
/>
<div className="flex-1 p-2 overflow-hidden relative">
<div
ref={terminalContainerRef}
className="h-full w-full focus:outline-none"
style={{ outline: 'none' }}
/>
{overlayMode && (
<ShellConnectionOverlay
mode={overlayMode}
description={overlayDescription}
loadingLabel={t('shell.loading')}
connectLabel={t('shell.actions.connect')}
connectTitle={t('shell.actions.connectTitle')}
connectingLabel={t('shell.connecting')}
onConnect={connectToShell}
/>
)}
</div>
<TerminalShortcutsPanel
wsRef={wsRef}
terminalRef={terminalRef}
isConnected={isConnected}
/>
</div>
);
}