Files
claudecodeui/src/components/shell/hooks/useShellRuntime.ts
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

165 lines
4.4 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import type { FitAddon } from '@xterm/addon-fit';
import type { Terminal } from '@xterm/xterm';
import { useShellConnection } from './useShellConnection';
import { useShellTerminal } from './useShellTerminal';
import type { UseShellRuntimeOptions, UseShellRuntimeResult } from '../types/types';
import { copyTextToClipboard } from '../../../utils/clipboard';
export function useShellRuntime({
selectedProject,
selectedSession,
initialCommand,
isPlainShell,
minimal,
autoConnect,
isRestarting,
onProcessComplete,
}: UseShellRuntimeOptions): UseShellRuntimeResult {
const terminalContainerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [authUrl, setAuthUrl] = useState('');
const [authUrlVersion, setAuthUrlVersion] = useState(0);
const selectedProjectRef = useRef(selectedProject);
const selectedSessionRef = useRef(selectedSession);
const initialCommandRef = useRef(initialCommand);
const isPlainShellRef = useRef(isPlainShell);
const onProcessCompleteRef = useRef(onProcessComplete);
const authUrlRef = useRef('');
const lastSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
// Keep mutable values in refs so websocket handlers always read current data.
useEffect(() => {
selectedProjectRef.current = selectedProject;
selectedSessionRef.current = selectedSession;
initialCommandRef.current = initialCommand;
isPlainShellRef.current = isPlainShell;
onProcessCompleteRef.current = onProcessComplete;
}, [selectedProject, selectedSession, initialCommand, isPlainShell, onProcessComplete]);
const setCurrentAuthUrl = useCallback((nextAuthUrl: string) => {
authUrlRef.current = nextAuthUrl;
setAuthUrl(nextAuthUrl);
setAuthUrlVersion((previous) => previous + 1);
}, []);
const closeSocket = useCallback(() => {
const activeSocket = wsRef.current;
if (!activeSocket) {
return;
}
if (
activeSocket.readyState === WebSocket.OPEN ||
activeSocket.readyState === WebSocket.CONNECTING
) {
activeSocket.close();
}
wsRef.current = null;
}, []);
const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => {
if (!url) {
return false;
}
const popup = window.open(url, '_blank');
if (popup) {
try {
popup.opener = null;
} catch {
// Ignore cross-origin restrictions when trying to null opener.
}
return true;
}
return false;
}, []);
const copyAuthUrlToClipboard = useCallback(async (url = authUrlRef.current) => {
if (!url) {
return false;
}
return copyTextToClipboard(url);
}, []);
const { isInitialized, clearTerminalScreen, disposeTerminal } = useShellTerminal({
terminalContainerRef,
terminalRef,
fitAddonRef,
wsRef,
selectedProject,
minimal,
isRestarting,
initialCommandRef,
isPlainShellRef,
authUrlRef,
copyAuthUrlToClipboard,
closeSocket,
});
const { isConnected, isConnecting, connectToShell, disconnectFromShell } = useShellConnection({
wsRef,
terminalRef,
fitAddonRef,
selectedProjectRef,
selectedSessionRef,
initialCommandRef,
isPlainShellRef,
onProcessCompleteRef,
isInitialized,
autoConnect,
closeSocket,
clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
});
useEffect(() => {
if (!isRestarting) {
return;
}
disconnectFromShell();
disposeTerminal();
}, [disconnectFromShell, disposeTerminal, isRestarting]);
useEffect(() => {
if (selectedProject) {
return;
}
disconnectFromShell();
disposeTerminal();
}, [disconnectFromShell, disposeTerminal, selectedProject]);
useEffect(() => {
const currentSessionId = selectedSession?.id ?? null;
if (lastSessionIdRef.current !== currentSessionId && isInitialized) {
disconnectFromShell();
}
lastSessionIdRef.current = currentSessionId;
}, [disconnectFromShell, isInitialized, selectedSession?.id]);
return {
terminalContainerRef,
terminalRef,
wsRef,
isConnected,
isInitialized,
isConnecting,
authUrl,
authUrlVersion,
connectToShell,
disconnectFromShell,
openAuthUrlInBrowser,
copyAuthUrlToClipboard,
};
}