From 200332f4f5c4fd7257978c240116468f6420a201 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Fri, 20 Feb 2026 10:39:16 +0300 Subject: [PATCH] fix: make shell and standalone shell feature based components - In addition, use one copy handler throughout the app. --- src/components/LoginModal.jsx | 2 +- src/components/NextTaskBanner.jsx | 2 +- src/components/Shell.jsx | 692 ------------------ src/components/StandaloneShell.jsx | 105 --- src/components/TaskDetail.jsx | 3 +- src/components/TaskList.jsx | 2 +- src/components/TaskMasterSetupWizard.jsx | 3 +- .../chat/tools/components/OneLineDisplay.tsx | 49 +- .../chat/view/subcomponents/Markdown.tsx | 40 +- .../markdown/MarkdownCodeBlock.tsx | 11 +- .../main-content/view/MainContent.tsx | 6 +- .../settings/hooks/useCredentialsSettings.ts | 3 +- src/components/shell/constants/constants.ts | 63 ++ .../shell/hooks/useShellConnection.ts | 215 ++++++ src/components/shell/hooks/useShellRuntime.ts | 166 +++++ .../shell/hooks/useShellTerminal.ts | 233 ++++++ src/components/shell/types/types.ts | 73 ++ src/components/shell/utils/auth.ts | 24 + src/components/shell/utils/socket.ts | 32 + src/components/shell/utils/terminalStyles.ts | 29 + src/components/shell/view/Shell.tsx | 162 ++++ .../subcomponents/ShellConnectionOverlay.tsx | 59 ++ .../view/subcomponents/ShellEmptyState.tsx | 25 + .../shell/view/subcomponents/ShellHeader.tsx | 87 +++ .../view/subcomponents/ShellMinimalView.tsx | 113 +++ .../view/modals/VersionUpgradeModal.tsx | 7 +- .../standalone-shell/view/StandaloneShell.tsx | 74 ++ .../StandaloneShellEmptyState.tsx | 24 + .../subcomponents/StandaloneShellHeader.tsx | 30 + src/utils/clipboard.ts | 50 ++ 30 files changed, 1478 insertions(+), 906 deletions(-) delete mode 100644 src/components/Shell.jsx delete mode 100644 src/components/StandaloneShell.jsx create mode 100644 src/components/shell/constants/constants.ts create mode 100644 src/components/shell/hooks/useShellConnection.ts create mode 100644 src/components/shell/hooks/useShellRuntime.ts create mode 100644 src/components/shell/hooks/useShellTerminal.ts create mode 100644 src/components/shell/types/types.ts create mode 100644 src/components/shell/utils/auth.ts create mode 100644 src/components/shell/utils/socket.ts create mode 100644 src/components/shell/utils/terminalStyles.ts create mode 100644 src/components/shell/view/Shell.tsx create mode 100644 src/components/shell/view/subcomponents/ShellConnectionOverlay.tsx create mode 100644 src/components/shell/view/subcomponents/ShellEmptyState.tsx create mode 100644 src/components/shell/view/subcomponents/ShellHeader.tsx create mode 100644 src/components/shell/view/subcomponents/ShellMinimalView.tsx create mode 100644 src/components/standalone-shell/view/StandaloneShell.tsx create mode 100644 src/components/standalone-shell/view/subcomponents/StandaloneShellEmptyState.tsx create mode 100644 src/components/standalone-shell/view/subcomponents/StandaloneShellHeader.tsx create mode 100644 src/utils/clipboard.ts diff --git a/src/components/LoginModal.jsx b/src/components/LoginModal.jsx index 5c3af51..f391c6d 100644 --- a/src/components/LoginModal.jsx +++ b/src/components/LoginModal.jsx @@ -1,5 +1,5 @@ import { X } from 'lucide-react'; -import StandaloneShell from './StandaloneShell'; +import StandaloneShell from './standalone-shell/view/StandaloneShell'; import { IS_PLATFORM } from '../constants/config'; /** diff --git a/src/components/NextTaskBanner.jsx b/src/components/NextTaskBanner.jsx index 2a55cb8..49eb941 100644 --- a/src/components/NextTaskBanner.jsx +++ b/src/components/NextTaskBanner.jsx @@ -3,7 +3,7 @@ import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, import { cn } from '../lib/utils'; import { useTaskMaster } from '../contexts/TaskMasterContext'; import { api } from '../utils/api'; -import Shell from './Shell'; +import Shell from './shell/view/Shell'; import TaskDetail from './TaskDetail'; const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => { diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx deleted file mode 100644 index 4e6dc3a..0000000 --- a/src/components/Shell.jsx +++ /dev/null @@ -1,692 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { Terminal } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import { WebglAddon } from '@xterm/addon-webgl'; -import { WebLinksAddon } from '@xterm/addon-web-links'; -import '@xterm/xterm/css/xterm.css'; -import { useTranslation } from 'react-i18next'; -import { IS_PLATFORM } from '../constants/config'; - -const xtermStyles = ` - .xterm .xterm-screen { - outline: none !important; - } - .xterm:focus .xterm-screen { - outline: none !important; - } - .xterm-screen:focus { - outline: none !important; - } -`; - -if (typeof document !== 'undefined') { - const styleSheet = document.createElement('style'); - styleSheet.type = 'text/css'; - styleSheet.innerText = xtermStyles; - document.head.appendChild(styleSheet); -} - -function fallbackCopyToClipboard(text) { - if (!text || typeof document === 'undefined') return false; - - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.style.pointerEvents = 'none'; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - - let copied = false; - try { - copied = document.execCommand('copy'); - } catch { - copied = false; - } finally { - document.body.removeChild(textarea); - } - - return copied; -} - -const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device'; - -function isCodexLoginCommand(command) { - return typeof command === 'string' && /\bcodex\s+login\b/i.test(command); -} - -function Shell({ selectedProject, selectedSession, initialCommand, isPlainShell = false, onProcessComplete, minimal = false, autoConnect = false }) { - const { t } = useTranslation('chat'); - const terminalRef = useRef(null); - const terminal = useRef(null); - const fitAddon = useRef(null); - const ws = useRef(null); - const [isConnected, setIsConnected] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const [isRestarting, setIsRestarting] = useState(false); - const [lastSessionId, setLastSessionId] = useState(null); - const [isConnecting, setIsConnecting] = useState(false); - const [authUrl, setAuthUrl] = useState(''); - const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle'); - const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false); - - const selectedProjectRef = useRef(selectedProject); - const selectedSessionRef = useRef(selectedSession); - const initialCommandRef = useRef(initialCommand); - const isPlainShellRef = useRef(isPlainShell); - const onProcessCompleteRef = useRef(onProcessComplete); - const authUrlRef = useRef(''); - - useEffect(() => { - selectedProjectRef.current = selectedProject; - selectedSessionRef.current = selectedSession; - initialCommandRef.current = initialCommand; - isPlainShellRef.current = isPlainShell; - onProcessCompleteRef.current = onProcessComplete; - }); - - const openAuthUrlInBrowser = useCallback((url = authUrlRef.current) => { - if (!url) return false; - - const popup = window.open(url, '_blank', 'noopener,noreferrer'); - 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; - - let copied = false; - try { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(url); - copied = true; - } - } catch { - copied = false; - } - - if (!copied) { - copied = fallbackCopyToClipboard(url); - } - - return copied; - }, []); - - const connectWebSocket = useCallback(async () => { - if (isConnecting || isConnected) return; - - try { - let wsUrl; - - if (IS_PLATFORM) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/shell`; - } else { - const token = localStorage.getItem('auth-token'); - if (!token) { - console.error('No authentication token found for Shell WebSocket connection'); - return; - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - wsUrl = `${protocol}//${window.location.host}/shell?token=${encodeURIComponent(token)}`; - } - - ws.current = new WebSocket(wsUrl); - - ws.current.onopen = () => { - setIsConnected(true); - setIsConnecting(false); - authUrlRef.current = ''; - setAuthUrl(''); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - - setTimeout(() => { - if (fitAddon.current && terminal.current) { - fitAddon.current.fit(); - - ws.current.send(JSON.stringify({ - type: 'init', - projectPath: selectedProjectRef.current.fullPath || selectedProjectRef.current.path, - sessionId: isPlainShellRef.current ? null : selectedSessionRef.current?.id, - hasSession: isPlainShellRef.current ? false : !!selectedSessionRef.current, - provider: isPlainShellRef.current ? 'plain-shell' : (selectedSessionRef.current?.__provider || 'claude'), - cols: terminal.current.cols, - rows: terminal.current.rows, - initialCommand: initialCommandRef.current, - isPlainShell: isPlainShellRef.current - })); - } - }, 100); - }; - - ws.current.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - - if (data.type === 'output') { - let output = data.data; - - if (isPlainShellRef.current && onProcessCompleteRef.current) { - const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); - if (cleanOutput.includes('Process exited with code 0')) { - onProcessCompleteRef.current(0); - } else if (cleanOutput.match(/Process exited with code (\d+)/)) { - const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]); - if (exitCode !== 0) { - onProcessCompleteRef.current(exitCode); - } - } - } - - if (terminal.current) { - terminal.current.write(output); - } - } else if (data.type === 'auth_url' && data.url) { - authUrlRef.current = data.url; - setAuthUrl(data.url); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - } else if (data.type === 'url_open') { - if (data.url) { - authUrlRef.current = data.url; - setAuthUrl(data.url); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - } - } - } catch (error) { - console.error('[Shell] Error handling WebSocket message:', error, event.data); - } - }; - - ws.current.onclose = (event) => { - setIsConnected(false); - setIsConnecting(false); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - - if (terminal.current) { - terminal.current.clear(); - terminal.current.write('\x1b[2J\x1b[H'); - } - }; - - ws.current.onerror = (error) => { - setIsConnected(false); - setIsConnecting(false); - }; - } catch (error) { - setIsConnected(false); - setIsConnecting(false); - } - }, [isConnecting, isConnected, openAuthUrlInBrowser]); - - const connectToShell = useCallback(() => { - if (!isInitialized || isConnected || isConnecting) return; - setIsConnecting(true); - connectWebSocket(); - }, [isInitialized, isConnected, isConnecting, connectWebSocket]); - - const disconnectFromShell = useCallback(() => { - if (ws.current) { - ws.current.close(); - ws.current = null; - } - - if (terminal.current) { - terminal.current.clear(); - terminal.current.write('\x1b[2J\x1b[H'); - } - - setIsConnected(false); - setIsConnecting(false); - authUrlRef.current = ''; - setAuthUrl(''); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - }, []); - - const sessionDisplayName = useMemo(() => { - if (!selectedSession) return null; - return selectedSession.__provider === 'cursor' - ? (selectedSession.name || 'Untitled Session') - : (selectedSession.summary || 'New Session'); - }, [selectedSession]); - - const sessionDisplayNameShort = useMemo(() => { - if (!sessionDisplayName) return null; - return sessionDisplayName.slice(0, 30); - }, [sessionDisplayName]); - - const sessionDisplayNameLong = useMemo(() => { - if (!sessionDisplayName) return null; - return sessionDisplayName.slice(0, 50); - }, [sessionDisplayName]); - - const restartShell = () => { - setIsRestarting(true); - - if (ws.current) { - ws.current.close(); - ws.current = null; - } - - if (terminal.current) { - terminal.current.dispose(); - terminal.current = null; - fitAddon.current = null; - } - - setIsConnected(false); - setIsInitialized(false); - authUrlRef.current = ''; - setAuthUrl(''); - setAuthUrlCopyStatus('idle'); - setIsAuthPanelHidden(false); - - setTimeout(() => { - setIsRestarting(false); - }, 200); - }; - - useEffect(() => { - const currentSessionId = selectedSession?.id || null; - - if (lastSessionId !== null && lastSessionId !== currentSessionId && isInitialized) { - disconnectFromShell(); - } - - setLastSessionId(currentSessionId); - }, [selectedSession?.id, isInitialized, disconnectFromShell]); - - useEffect(() => { - if (!terminalRef.current || !selectedProject || isRestarting || terminal.current) { - return; - } - - - terminal.current = new Terminal({ - cursorBlink: true, - fontSize: 14, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - allowProposedApi: true, - allowTransparency: false, - convertEol: true, - scrollback: 10000, - tabStopWidth: 4, - windowsMode: false, - macOptionIsMeta: true, - macOptionClickForcesSelection: true, - theme: { - background: '#1e1e1e', - foreground: '#d4d4d4', - cursor: '#ffffff', - cursorAccent: '#1e1e1e', - selection: '#264f78', - selectionForeground: '#ffffff', - black: '#000000', - red: '#cd3131', - green: '#0dbc79', - yellow: '#e5e510', - blue: '#2472c8', - magenta: '#bc3fbc', - cyan: '#11a8cd', - white: '#e5e5e5', - brightBlack: '#666666', - brightRed: '#f14c4c', - brightGreen: '#23d18b', - brightYellow: '#f5f543', - brightBlue: '#3b8eea', - brightMagenta: '#d670d6', - brightCyan: '#29b8db', - brightWhite: '#ffffff', - extendedAnsi: [ - '#000000', '#800000', '#008000', '#808000', - '#000080', '#800080', '#008080', '#c0c0c0', - '#808080', '#ff0000', '#00ff00', '#ffff00', - '#0000ff', '#ff00ff', '#00ffff', '#ffffff' - ] - } - }); - - fitAddon.current = new FitAddon(); - const webglAddon = new WebglAddon(); - const webLinksAddon = new WebLinksAddon(); - - terminal.current.loadAddon(fitAddon.current); - // Disable xterm link auto-detection in minimal (login) mode to avoid partial wrapped URL links. - if (!minimal) { - terminal.current.loadAddon(webLinksAddon); - } - // Note: ClipboardAddon removed - we handle clipboard operations manually in attachCustomKeyEventHandler - - try { - terminal.current.loadAddon(webglAddon); - } catch (error) { - console.warn('[Shell] WebGL renderer unavailable, using Canvas fallback'); - } - - terminal.current.open(terminalRef.current); - - terminal.current.attachCustomKeyEventHandler((event) => { - const activeAuthUrl = isCodexLoginCommand(initialCommandRef.current) - ? CODEX_DEVICE_AUTH_URL - : authUrlRef.current; - - if ( - event.type === 'keydown' && - minimal && - isPlainShellRef.current && - activeAuthUrl && - !event.ctrlKey && - !event.metaKey && - !event.altKey && - event.key?.toLowerCase() === 'c' - ) { - copyAuthUrlToClipboard(activeAuthUrl).catch(() => {}); - } - - if ( - event.type === 'keydown' && - (event.ctrlKey || event.metaKey) && - event.key?.toLowerCase() === 'c' && - terminal.current.hasSelection() - ) { - event.preventDefault(); - event.stopPropagation(); - document.execCommand('copy'); - return false; - } - - if ( - event.type === 'keydown' && - (event.ctrlKey || event.metaKey) && - event.key?.toLowerCase() === 'v' - ) { - // Block native browser/xterm paste so clipboard data is only sent after - // the explicit clipboard-read flow resolves (avoids duplicate pastes). - event.preventDefault(); - event.stopPropagation(); - - navigator.clipboard.readText().then(text => { - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify({ - type: 'input', - data: text - })); - } - }).catch(() => {}); - return false; - } - - return true; - }); - - setTimeout(() => { - if (fitAddon.current) { - fitAddon.current.fit(); - if (terminal.current && ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify({ - type: 'resize', - cols: terminal.current.cols, - rows: terminal.current.rows - })); - } - } - }, 100); - - setIsInitialized(true); - terminal.current.onData((data) => { - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify({ - type: 'input', - data: data - })); - } - }); - - const resizeObserver = new ResizeObserver(() => { - if (fitAddon.current && terminal.current) { - setTimeout(() => { - fitAddon.current.fit(); - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - ws.current.send(JSON.stringify({ - type: 'resize', - cols: terminal.current.cols, - rows: terminal.current.rows - })); - } - }, 50); - } - }); - - if (terminalRef.current) { - resizeObserver.observe(terminalRef.current); - } - - return () => { - resizeObserver.disconnect(); - - if (ws.current && (ws.current.readyState === WebSocket.OPEN || ws.current.readyState === WebSocket.CONNECTING)) { - ws.current.close(); - } - ws.current = null; - - if (terminal.current) { - terminal.current.dispose(); - terminal.current = null; - } - }; - }, [selectedProject?.path || selectedProject?.fullPath, isRestarting, minimal, copyAuthUrlToClipboard]); - - useEffect(() => { - if (!autoConnect || !isInitialized || isConnecting || isConnected) return; - connectToShell(); - }, [autoConnect, isInitialized, isConnecting, isConnected, connectToShell]); - - if (!selectedProject) { - return ( -
-
-
- - - -
-

{t('shell.selectProject.title')}

-

{t('shell.selectProject.description')}

-
-
- ); - } - - if (minimal) { - const displayAuthUrl = isCodexLoginCommand(initialCommand) - ? CODEX_DEVICE_AUTH_URL - : authUrl; - const hasAuthUrl = Boolean(displayAuthUrl); - const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden; - const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden; - - return ( -
-
- {showMobileAuthPanel && ( -
-
-
-

Open or copy the login URL:

- -
- event.currentTarget.select()} - className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" - aria-label="Authentication URL" - /> -
- - -
-
-
- )} - {showMobileAuthPanelToggle && ( -
- -
- )} -
- ); - } - - return ( -
-
-
-
-
- {selectedSession && ( - - ({sessionDisplayNameShort}...) - - )} - {!selectedSession && ( - {t('shell.status.newSession')} - )} - {!isInitialized && ( - {t('shell.status.initializing')} - )} - {isRestarting && ( - {t('shell.status.restarting')} - )} -
-
- {isConnected && ( - - )} - - -
-
-
- -
-
- - {!isInitialized && ( -
-
{t('shell.loading')}
-
- )} - - {isInitialized && !isConnected && !isConnecting && ( -
-
- -

- {isPlainShell ? - t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : - selectedSession ? - t('shell.resumeSession', { displayName: sessionDisplayNameLong }) : - t('shell.startSession') - } -

-
-
- )} - - {isConnecting && ( -
-
-
-
- {t('shell.connecting')} -
-

- {isPlainShell ? - t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName }) : - t('shell.startCli', { projectName: selectedProject.displayName }) - } -

-
-
- )} -
-
- ); -} - -export default Shell; diff --git a/src/components/StandaloneShell.jsx b/src/components/StandaloneShell.jsx deleted file mode 100644 index 9f6f9f2..0000000 --- a/src/components/StandaloneShell.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useState, useCallback } from 'react'; -import Shell from './Shell.jsx'; - -/** - * Generic Shell wrapper that can be used in tabs, modals, and other contexts. - * Provides a flexible API for both standalone and session-based usage. - * - * @param {Object} project - Project object with name, fullPath/path, displayName - * @param {Object} session - Session object (optional, for tab usage) - * @param {string} command - Initial command to run (optional) - * @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect) - * @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true) - * @param {function} onComplete - Callback when process completes (receives exitCode) - * @param {function} onClose - Callback for close button (optional) - * @param {string} title - Custom header title (optional) - * @param {string} className - Additional CSS classes - * @param {boolean} showHeader - Whether to show custom header (default: true) - * @param {boolean} compact - Use compact layout (default: false) - * @param {boolean} minimal - Use minimal mode: no header, no overlays, auto-connect (default: false) - */ -function StandaloneShell({ - project, - session = null, - command = null, - isPlainShell = null, - autoConnect = true, - onComplete = null, - onClose = null, - title = null, - className = "", - showHeader = true, - compact = false, - minimal = false -}) { - const [isCompleted, setIsCompleted] = useState(false); - - const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null); - - const handleProcessComplete = useCallback((exitCode) => { - setIsCompleted(true); - if (onComplete) { - onComplete(exitCode); - } - }, [onComplete]); - - if (!project) { - return ( -
-
-
- - - -
-

No Project Selected

-

A project is required to open a shell

-
-
- ); - } - - return ( -
- {/* Optional custom header */} - {!minimal && showHeader && title && ( -
-
-
-

{title}

- {isCompleted && ( - (Completed) - )} -
- {onClose && ( - - )} -
-
- )} - - {/* Shell component wrapper */} -
- -
-
- ); -} - -export default StandaloneShell; \ No newline at end of file diff --git a/src/components/TaskDetail.jsx b/src/components/TaskDetail.jsx index 8316983..4d88186 100644 --- a/src/components/TaskDetail.jsx +++ b/src/components/TaskDetail.jsx @@ -4,6 +4,7 @@ import { cn } from '../lib/utils'; import TaskIndicator from './TaskIndicator'; import { api } from '../utils/api'; import { useTaskMaster } from '../contexts/TaskMasterContext'; +import { copyTextToClipboard } from '../utils/clipboard'; const TaskDetail = ({ task, @@ -79,7 +80,7 @@ const TaskDetail = ({ }; const copyTaskId = () => { - navigator.clipboard.writeText(task.id.toString()); + copyTextToClipboard(task.id.toString()); }; const getStatusConfig = (status) => { diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx index 2fae577..b91bef2 100644 --- a/src/components/TaskList.jsx +++ b/src/components/TaskList.jsx @@ -4,7 +4,7 @@ import { cn } from '../lib/utils'; import TaskCard from './TaskCard'; import CreateTaskModal from './CreateTaskModal'; import { useTaskMaster } from '../contexts/TaskMasterContext'; -import Shell from './Shell'; +import Shell from './shell/view/Shell'; import { api } from '../utils/api'; import { useTranslation } from 'react-i18next'; diff --git a/src/components/TaskMasterSetupWizard.jsx b/src/components/TaskMasterSetupWizard.jsx index 813889b..6bcb173 100644 --- a/src/components/TaskMasterSetupWizard.jsx +++ b/src/components/TaskMasterSetupWizard.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react'; import { cn } from '../lib/utils'; import { api } from '../utils/api'; +import { copyTextToClipboard } from '../utils/clipboard'; const TaskMasterSetupWizard = ({ isOpen = true, @@ -175,7 +176,7 @@ const TaskMasterSetupWizard = ({ } } }`; - navigator.clipboard.writeText(mcpConfig); + copyTextToClipboard(mcpConfig); }; const renderStepContent = () => { diff --git a/src/components/chat/tools/components/OneLineDisplay.tsx b/src/components/chat/tools/components/OneLineDisplay.tsx index 52fc0f1..a73bd22 100644 --- a/src/components/chat/tools/components/OneLineDisplay.tsx +++ b/src/components/chat/tools/components/OneLineDisplay.tsx @@ -1,9 +1,9 @@ import React, { useState } from 'react'; +import { copyTextToClipboard } from '../../../../utils/clipboard'; type ActionType = 'copy' | 'open-file' | 'jump-to-results' | 'none'; interface OneLineDisplayProps { - toolName: string; icon?: string; label?: string; @@ -25,52 +25,6 @@ interface OneLineDisplayProps { toolId?: string; } -// Fallback for environments where the async Clipboard API is unavailable or blocked. -const copyWithLegacyExecCommand = (text: string): boolean => { - if (typeof document === 'undefined' || !document.body) { - return false; - } - - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - textarea.setSelectionRange(0, text.length); - - let copied = false; - try { - copied = document.execCommand('copy'); - } catch { - copied = false; - } finally { - document.body.removeChild(textarea); - } - - return copied; -}; - -const copyTextToClipboard = async (text: string): Promise => { - if ( - typeof navigator !== 'undefined' && - typeof window !== 'undefined' && - window.isSecureContext && - navigator.clipboard?.writeText - ) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // Fall back below when writeText is rejected (permissions/insecure contexts/browser limits). - } - } - - return copyWithLegacyExecCommand(text); -}; - /** * Unified one-line display for simple tool inputs and results * Used by: Bash, Read, Grep/Glob (minimized), TodoRead, etc. @@ -92,7 +46,6 @@ export const OneLineDisplay: React.FC = ({ border: 'border-gray-300 dark:border-gray-600', icon: 'text-gray-500 dark:text-gray-400' }, - resultId, toolResult, toolId }) => { diff --git a/src/components/chat/view/subcomponents/Markdown.tsx b/src/components/chat/view/subcomponents/Markdown.tsx index 7a0e43c..bbb3db1 100644 --- a/src/components/chat/view/subcomponents/Markdown.tsx +++ b/src/components/chat/view/subcomponents/Markdown.tsx @@ -7,6 +7,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { useTranslation } from 'react-i18next'; import { normalizeInlineCodeFences } from '../../utils/chatFormatting'; +import { copyTextToClipboard } from '../../../../utils/clipboard'; type MarkdownProps = { children: React.ReactNode; @@ -43,43 +44,6 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro const match = /language-(\w+)/.exec(className || ''); const language = match ? match[1] : 'text'; - const textToCopy = raw; - - const handleCopy = () => { - const doSet = () => { - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }; - try { - if (navigator && navigator.clipboard && navigator.clipboard.writeText) { - navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => { - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { - document.execCommand('copy'); - } catch {} - document.body.removeChild(ta); - doSet(); - }); - } else { - const ta = document.createElement('textarea'); - ta.value = textToCopy; - ta.style.position = 'fixed'; - ta.style.opacity = '0'; - document.body.appendChild(ta); - ta.select(); - try { - document.execCommand('copy'); - } catch {} - document.body.removeChild(ta); - doSet(); - } - } catch {} - }; return (
@@ -89,7 +53,7 @@ const CodeBlock = ({ node, inline, className, children, ...props }: CodeBlockPro +

{description}

+
+
+ ); + } + + return ( +
+
+
+
+ {connectingLabel} +
+

{description}

+
+
+ ); +} diff --git a/src/components/shell/view/subcomponents/ShellEmptyState.tsx b/src/components/shell/view/subcomponents/ShellEmptyState.tsx new file mode 100644 index 0000000..34e2bb1 --- /dev/null +++ b/src/components/shell/view/subcomponents/ShellEmptyState.tsx @@ -0,0 +1,25 @@ +type ShellEmptyStateProps = { + title: string; + description: string; +}; + +export default function ShellEmptyState({ title, description }: ShellEmptyStateProps) { + return ( +
+
+
+ + + +
+

{title}

+

{description}

+
+
+ ); +} diff --git a/src/components/shell/view/subcomponents/ShellHeader.tsx b/src/components/shell/view/subcomponents/ShellHeader.tsx new file mode 100644 index 0000000..56131fd --- /dev/null +++ b/src/components/shell/view/subcomponents/ShellHeader.tsx @@ -0,0 +1,87 @@ +type ShellHeaderProps = { + isConnected: boolean; + isInitialized: boolean; + isRestarting: boolean; + hasSession: boolean; + sessionDisplayNameShort: string | null; + onDisconnect: () => void; + onRestart: () => void; + statusNewSessionText: string; + statusInitializingText: string; + statusRestartingText: string; + disconnectLabel: string; + disconnectTitle: string; + restartLabel: string; + restartTitle: string; + disableRestart: boolean; +}; + +export default function ShellHeader({ + isConnected, + isInitialized, + isRestarting, + hasSession, + sessionDisplayNameShort, + onDisconnect, + onRestart, + statusNewSessionText, + statusInitializingText, + statusRestartingText, + disconnectLabel, + disconnectTitle, + restartLabel, + restartTitle, + disableRestart, +}: ShellHeaderProps) { + return ( +
+
+
+
+ + {hasSession && sessionDisplayNameShort && ( + ({sessionDisplayNameShort}...) + )} + + {!hasSession && {statusNewSessionText}} + + {!isInitialized && {statusInitializingText}} + + {isRestarting && {statusRestartingText}} +
+ +
+ {isConnected && ( + + )} + + +
+
+
+ ); +} diff --git a/src/components/shell/view/subcomponents/ShellMinimalView.tsx b/src/components/shell/view/subcomponents/ShellMinimalView.tsx new file mode 100644 index 0000000..bdd2d40 --- /dev/null +++ b/src/components/shell/view/subcomponents/ShellMinimalView.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { RefObject } from 'react'; +import type { AuthCopyStatus } from '../../types/types'; +import { resolveAuthUrlForDisplay } from '../../utils/auth'; + +type ShellMinimalViewProps = { + terminalContainerRef: RefObject; + authUrl: string; + authUrlVersion: number; + initialCommand: string | null | undefined; + isConnected: boolean; + openAuthUrlInBrowser: (url: string) => boolean; + copyAuthUrlToClipboard: (url: string) => Promise; +}; + +export default function ShellMinimalView({ + terminalContainerRef, + authUrl, + authUrlVersion, + initialCommand, + isConnected, + openAuthUrlInBrowser, + copyAuthUrlToClipboard, +}: ShellMinimalViewProps) { + const [authUrlCopyStatus, setAuthUrlCopyStatus] = useState('idle'); + const [isAuthPanelHidden, setIsAuthPanelHidden] = useState(false); + + const displayAuthUrl = useMemo( + () => resolveAuthUrlForDisplay(initialCommand, authUrl), + [authUrl, initialCommand], + ); + + // Keep auth panel UI state local to minimal mode and reset it when connection/url changes. + useEffect(() => { + setAuthUrlCopyStatus('idle'); + setIsAuthPanelHidden(false); + }, [authUrlVersion, displayAuthUrl, isConnected]); + + const hasAuthUrl = Boolean(displayAuthUrl); + const showMobileAuthPanel = hasAuthUrl && !isAuthPanelHidden; + const showMobileAuthPanelToggle = hasAuthUrl && isAuthPanelHidden; + + return ( +
+
+ + {showMobileAuthPanel && ( +
+
+
+

Open or copy the login URL:

+ +
+ + event.currentTarget.select()} + className="w-full rounded border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500" + aria-label="Authentication URL" + /> + +
+ + + +
+
+
+ )} + + {showMobileAuthPanelToggle && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/sidebar/view/modals/VersionUpgradeModal.tsx b/src/components/sidebar/view/modals/VersionUpgradeModal.tsx index 891bd44..e787dee 100644 --- a/src/components/sidebar/view/modals/VersionUpgradeModal.tsx +++ b/src/components/sidebar/view/modals/VersionUpgradeModal.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { authenticatedFetch } from "../../../../utils/api"; import { ReleaseInfo } from "../../../../types/sharedTypes"; +import { copyTextToClipboard } from "../../../../utils/clipboard"; interface VersionUpgradeModalProps { isOpen: boolean; @@ -22,7 +23,7 @@ export default function VersionUpgradeModal({ const [isUpdating, setIsUpdating] = useState(false); const [updateOutput, setUpdateOutput] = useState(''); const [updateError, setUpdateError] = useState(''); - + const handleUpdateNow = useCallback(async () => { setIsUpdating(true); setUpdateOutput('Starting update...\n'); @@ -170,9 +171,7 @@ export default function VersionUpgradeModal({ {!updateOutput && ( <> + )} +
+
+ ); +} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 0000000..1fd9ed5 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,50 @@ +function fallbackCopyToClipboard(text: string): boolean { + if (!text || typeof document === 'undefined') { + return false; + } + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + textarea.style.pointerEvents = 'none'; + + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + + let copied = false; + try { + copied = document.execCommand('copy'); + } catch { + copied = false; + } finally { + document.body.removeChild(textarea); + } + + return copied; +} + +export async function copyTextToClipboard(text: string): Promise { + if (!text) { + return false; + } + + let copied = false; + + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + copied = true; + } + } catch { + copied = false; + } + + if (!copied) { + copied = fallbackCopyToClipboard(text); + } + + return copied; +} \ No newline at end of file