From bf4bc361bc2cd0a5ed15cb7cc7429b937d500472 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Fri, 20 Feb 2026 07:39:25 +0300 Subject: [PATCH] refactor: claude status, command menu, and image viewer - Refactored ClaudeStatus to TypeScript and moved it to the chat view subcomponents. - Refactored CommandMenu to TypeScript, moved it to the chat view subcomponents - Refactored ImageViewer to TypeScript and moved it to the file-tree view subcomponents. - Moved FileTree to the file-tree view folder. --- src/components/CommandMenu.jsx | 367 ------------------ .../chat/view/subcomponents/ChatComposer.tsx | 7 +- .../view/subcomponents/ClaudeStatus.tsx} | 89 +++-- .../chat/view/subcomponents/CommandMenu.tsx | 258 ++++++++++++ .../file-tree/{ => view}/FileTree.tsx | 37 +- .../view/ImageViewer.tsx} | 47 +-- .../main-content/view/MainContent.tsx | 2 +- 7 files changed, 347 insertions(+), 460 deletions(-) delete mode 100644 src/components/CommandMenu.jsx rename src/components/{ClaudeStatus.jsx => chat/view/subcomponents/ClaudeStatus.tsx} (61%) create mode 100644 src/components/chat/view/subcomponents/CommandMenu.tsx rename src/components/file-tree/{ => view}/FileTree.tsx (72%) rename src/components/{ImageViewer.jsx => file-tree/view/ImageViewer.tsx} (72%) diff --git a/src/components/CommandMenu.jsx b/src/components/CommandMenu.jsx deleted file mode 100644 index d8f344d..0000000 --- a/src/components/CommandMenu.jsx +++ /dev/null @@ -1,367 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -/** - * CommandMenu - Autocomplete dropdown for slash commands - * - * @param {Array} commands - Array of command objects to display - * @param {number} selectedIndex - Currently selected command index (index in `commands`) - * @param {Function} onSelect - Callback when a command is selected - * @param {Function} onClose - Callback when menu should close - * @param {Object} position - Position object { top, left } for absolute positioning - * @param {boolean} isOpen - Whether the menu is open - * @param {Array} frequentCommands - Array of frequently used command objects - */ -const CommandMenu = ({ - commands = [], - selectedIndex = -1, - onSelect, - onClose, - position = { top: 0, left: 0 }, - isOpen = false, - frequentCommands = [], -}) => { - const menuRef = useRef(null); - const selectedItemRef = useRef(null); - - // Calculate responsive menu positioning. - // Mobile: dock above chat input. Desktop: clamp to viewport. - const getMenuPosition = () => { - const isMobile = window.innerWidth < 640; - const viewportHeight = window.innerHeight; - - if (isMobile) { - // On mobile, calculate bottom position dynamically to appear above the input. - // Use the bottom value calculated as: window.innerHeight - textarea.top + spacing. - const inputBottom = position.bottom || 90; - - return { - position: 'fixed', - bottom: `${inputBottom}px`, // Position above the input with spacing already included. - left: '16px', - right: '16px', - width: 'auto', - maxWidth: 'calc(100vw - 32px)', - maxHeight: 'min(50vh, 300px)', // Limit to smaller of 50vh or 300px. - }; - } - - // On desktop, use provided position but ensure it stays on screen. - return { - position: 'fixed', - top: `${Math.max(16, Math.min(position.top, viewportHeight - 316))}px`, - left: `${position.left}px`, - width: 'min(400px, calc(100vw - 32px))', - maxWidth: 'calc(100vw - 32px)', - maxHeight: '300px', - }; - }; - - const menuPosition = getMenuPosition(); - - // Close menu when clicking outside. - useEffect(() => { - const handleClickOutside = (event) => { - if (menuRef.current && !menuRef.current.contains(event.target) && isOpen) { - onClose(); - } - }; - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - } - - return undefined; - }, [isOpen, onClose]); - - // Keep selected keyboard item visible while navigating. - useEffect(() => { - if (selectedItemRef.current && menuRef.current) { - const menuRect = menuRef.current.getBoundingClientRect(); - const itemRect = selectedItemRef.current.getBoundingClientRect(); - - if (itemRect.bottom > menuRect.bottom) { - selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } else if (itemRect.top < menuRect.top) { - selectedItemRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - } - }, [selectedIndex]); - - if (!isOpen) { - return null; - } - - // Show a message if no commands are available. - if (commands.length === 0) { - return ( -
- No commands available -
- ); - } - - // Add frequent commands as a special group if provided. - const hasFrequentCommands = frequentCommands.length > 0; - - const getCommandKey = (command) => - `${command.name}::${command.namespace || command.type || 'other'}::${command.path || ''}`; - const frequentCommandKeys = new Set(frequentCommands.map(getCommandKey)); - - // Group commands by namespace for section rendering. - // When frequent commands are shown, avoid duplicate rows in other sections. - const groupedCommands = commands.reduce((groups, command) => { - if (hasFrequentCommands && frequentCommandKeys.has(getCommandKey(command))) { - return groups; - } - - const namespace = command.namespace || command.type || 'other'; - if (!groups[namespace]) { - groups[namespace] = []; - } - groups[namespace].push(command); - return groups; - }, {}); - - // Add frequent commands as a separate group. - if (hasFrequentCommands) { - groupedCommands.frequent = frequentCommands; - } - - // Order: frequent, builtin, project, user, other. - const namespaceOrder = hasFrequentCommands - ? ['frequent', 'builtin', 'project', 'user', 'other'] - : ['builtin', 'project', 'user', 'other']; - const orderedNamespaces = namespaceOrder.filter((ns) => groupedCommands[ns]); - - const namespaceLabels = { - frequent: '\u2B50 Frequently Used', - builtin: 'Built-in Commands', - project: 'Project Commands', - user: 'User Commands', - other: 'Other Commands', - }; - - // Keep all selection indices aligned to `commands` (filteredCommands from the hook). - // This prevents mismatches between mouse selection (rendered list) and keyboard selection. - const commandIndexByKey = new Map(); - commands.forEach((command, index) => { - const key = getCommandKey(command); - if (!commandIndexByKey.has(key)) { - commandIndexByKey.set(key, index); - } - }); - - return ( -
- {orderedNamespaces.map((namespace) => ( -
- {orderedNamespaces.length > 1 && ( -
- {namespaceLabels[namespace] || namespace} -
- )} - - {groupedCommands[namespace].map((command) => { - const commandKey = getCommandKey(command); - const commandIndex = commandIndexByKey.get(commandKey) ?? -1; - const isSelected = commandIndex === selectedIndex; - - return ( -
{ - if (onSelect && commandIndex >= 0) { - onSelect(command, commandIndex, true); - } - }} - onClick={() => { - if (onSelect) { - onSelect(command, commandIndex, false); - } - }} - style={{ - display: 'flex', - alignItems: 'flex-start', - padding: '10px 12px', - borderRadius: '6px', - cursor: 'pointer', - backgroundColor: isSelected ? '#eff6ff' : 'transparent', - transition: 'background-color 100ms ease-in-out', - marginBottom: '2px', - }} - // Prevent textarea blur when clicking a menu item. - onMouseDown={(e) => e.preventDefault()} - > -
-
- {/* Command icon based on namespace */} - - {namespace === 'builtin' && '\u26A1'} - {namespace === 'project' && '\uD83D\uDCC1'} - {namespace === 'user' && '\uD83D\uDC64'} - {namespace === 'other' && '\uD83D\uDCDD'} - {namespace === 'frequent' && '\u2B50'} - - - {/* Command name */} - - {command.name} - - - {/* Command metadata badge */} - {command.metadata?.type && ( - - {command.metadata.type} - - )} -
- - {/* Command description */} - {command.description && ( -
- {command.description} -
- )} -
- - {/* Selection indicator */} - {isSelected && ( - - {'\u21B5'} - - )} -
- ); - })} -
- ))} - - {/* Default light mode styles */} - -
- ); -}; - -export default CommandMenu; diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx index 68afec9..3e37aec 100644 --- a/src/components/chat/view/subcomponents/ChatComposer.tsx +++ b/src/components/chat/view/subcomponents/ChatComposer.tsx @@ -1,5 +1,5 @@ -import CommandMenu from '../../../CommandMenu'; -import ClaudeStatus from '../../../ClaudeStatus'; +import CommandMenu from './CommandMenu'; +import ClaudeStatus from './ClaudeStatus'; import { MicButton } from '../../../MicButton.jsx'; import ImageAttachment from './ImageAttachment'; import PermissionRequestsBanner from './PermissionRequestsBanner'; @@ -151,7 +151,6 @@ export default function ChatComposer({ onTranscript, }: ChatComposerProps) { const { t } = useTranslation('chat'); - const AnyCommandMenu = CommandMenu as any; const textareaRect = textareaRef.current?.getBoundingClientRect(); const commandMenuPosition = { top: textareaRect ? Math.max(16, textareaRect.top - 316) : 0, @@ -266,7 +265,7 @@ export default function ChatComposer({ )} - void; + isLoading: boolean; + provider?: string; +}; + +const ACTION_WORDS = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; +const SPINNER_CHARS = ['*', '+', 'x', '.']; + +export default function ClaudeStatus({ + status, + onAbort, + isLoading, + provider: _provider = 'claude', +}: ClaudeStatusProps) { const [elapsedTime, setElapsedTime] = useState(0); const [animationPhase, setAnimationPhase] = useState(0); const [fakeTokens, setFakeTokens] = useState(0); - // Update elapsed time every second useEffect(() => { if (!isLoading) { setElapsedTime(0); @@ -15,79 +33,72 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) { } const startTime = Date.now(); - // Calculate random token rate once (30-50 tokens per second) const tokenRate = 30 + Math.random() * 20; - const timer = setInterval(() => { + const timer = window.setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); setElapsedTime(elapsed); - // Simulate token count increasing over time setFakeTokens(Math.floor(elapsed * tokenRate)); }, 1000); - return () => clearInterval(timer); + return () => window.clearInterval(timer); }, [isLoading]); - // Animate the status indicator useEffect(() => { - if (!isLoading) return; + if (!isLoading) { + return; + } - const timer = setInterval(() => { - setAnimationPhase(prev => (prev + 1) % 4); + const timer = window.setInterval(() => { + setAnimationPhase((previous) => (previous + 1) % SPINNER_CHARS.length); }, 500); - return () => clearInterval(timer); + return () => window.clearInterval(timer); }, [isLoading]); - // Don't show if loading is false - // Note: showThinking only controls the reasoning accordion in messages, not this processing indicator - if (!isLoading) return null; - - // Clever action words that cycle - const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning']; - const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length; - - // Parse status data - const statusText = status?.text || actionWords[actionIndex]; + if (!isLoading) { + return null; + } + + const actionIndex = Math.floor(elapsedTime / 3) % ACTION_WORDS.length; + const statusText = status?.text || ACTION_WORDS[actionIndex]; const tokens = status?.tokens || fakeTokens; const canInterrupt = status?.can_interrupt !== false; - - // Animation characters - const spinners = ['✻', '✹', '✸', '✶']; - const currentSpinner = spinners[animationPhase]; - + const currentSpinner = SPINNER_CHARS[animationPhase]; + return (
- {/* Animated spinner */} - + {currentSpinner} - {/* Status text - compact for mobile */}
{statusText}... ({elapsedTime}s) {tokens > 0 && ( <> - · - ⚒ {tokens.toLocaleString()} + | + + tokens {tokens.toLocaleString()} + )} - · + | esc to stop
- {/* Interrupt button */} {canInterrupt && onAbort && (
@@ -71,7 +70,7 @@ function ImageViewer({ file, onClose }) {
{loading && (
-

Loading image…

+

Loading image...

)} {!loading && imageUrl && ( @@ -90,13 +89,9 @@ function ImageViewer({ file, onClose }) {
-

- {file.path} -

+

{file.path}

); } - -export default ImageViewer; diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx index 7fd7f43..998d309 100644 --- a/src/components/main-content/view/MainContent.tsx +++ b/src/components/main-content/view/MainContent.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import ChatInterface from '../../chat/view/ChatInterface'; -import FileTree from '../../file-tree/FileTree'; +import FileTree from '../../file-tree/view/FileTree'; import StandaloneShell from '../../StandaloneShell'; import GitPanel from '../../git-panel/view/GitPanel'; import ErrorBoundary from '../../ErrorBoundary';