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 && (