mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-15 01:19:31 +00:00
feat: Implement slash command menu with fixed positioning and dark mode (#211)
* feat: Add token budget tracking and multiple improvements ## Features - **Token Budget Visualization**: Added real-time token usage tracking with pie chart display showing percentage used (blue < 50%, orange < 75%, red ≥ 75%) - **Show Thinking Toggle**: Added quick settings option to show/hide reasoning sections in messages - **Cache Clearing Utility**: Added `/clear-cache.html` page for clearing service workers, caches, and storage ## Improvements - **Package Upgrades**: Migrated from deprecated `xterm` to `@xterm/*` scoped packages - **Testing Setup**: Added Playwright for end-to-end testing - **Build Optimization**: Implemented code splitting for React, CodeMirror, and XTerm vendors to improve initial load time - **Deployment Scripts**: Added `scripts/start.sh` and `scripts/stop.sh` for cleaner server management with automatic port conflict resolution - **Vite Update**: Upgraded Vite from 7.0.5 to 7.1.8 ## Bug Fixes - Fixed static file serving to properly handle routes vs assets - Fixed session state reset to preserve token budget on initial load - Updated default Vite dev server port to 5173 (Vite's standard) ## Technical Details - Token budget is parsed from Claude CLI `modelUsage` field in result messages - Budget updates are sent via WebSocket as `token-budget` events - Calculation includes input, output, cache read, and cache creation tokens - Token budget state persists during active sessions but resets on session switch * feat: Add session processing state persistence Fixes issue where "Thinking..." banner and stop button disappear when switching between sessions. Users can now navigate freely while Claude is processing without losing the ability to monitor or stop the session. Features: - Processing state tracked in processingSessions Set (App.jsx) - Backend session status queries via check-session-status WebSocket message - UI state (banner + stop button) restored when returning to processing sessions - Works after page reload by querying backend's authoritative process maps - Proper cleanup when sessions complete in background Backend Changes: - Added sessionId to claude-complete, cursor-result, session-aborted messages - Exported isClaudeSessionActive, isCursorSessionActive helper functions - Exported getActiveClaudeSessions, getActiveCursorSessions for status queries - Added check-session-status and get-active-sessions WebSocket handlers Frontend Changes: - processingSessions state tracking in App.jsx - onSessionProcessing/onSessionNotProcessing callbacks - Session status check on session load and switch - Completion handlers only update UI if message is for current session - Always clean up processing state regardless of which session is active * feat: Make context window size configurable via environment variables Removes hardcoded 160k token limit and makes it configurable through environment variables. This allows easier adjustment for different Claude models or use cases. Changes: - Added CONTEXT_WINDOW env var for backend (default: 160000) - Added VITE_CONTEXT_WINDOW env var for frontend (default: 160000) - Updated .env.example with documentation - Replaced hardcoded values in token usage calculations - Replaced hardcoded values in pie chart display Why 160k? Claude Code reserves ~40k tokens for auto-compact feature, leaving 160k available for actual usage from the 200k context window. * fix: Decode HTML entities in chat message display HTML entities like < and > were showing as-is instead of being decoded to < and > characters. Added decodeHtmlEntities helper function to properly display angle brackets and other special characters. Applied to: - Regular message content - Streaming content deltas - Session history loading - Both string and array content types * refactor: Align package.json with main branch standards - Revert to main branch's package.json scripts structure - Remove custom scripts/start.sh and scripts/stop.sh - Update xterm dependencies to scoped @xterm packages (required for code compatibility) - Replace xterm with @xterm/xterm - Replace xterm-addon-fit with @xterm/addon-fit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Replace CLI implementation with Claude Agents SDK This commit completes the migration to the Claude Agents SDK, removing the legacy CLI-based implementation and making the SDK the exclusive integration method. Changes: - Remove claude-cli.js legacy implementation - Add claude-sdk.js with full SDK integration - Remove CLAUDE_USE_SDK feature flag (SDK is now always used) - Update server/index.js to use SDK functions directly - Add .serena/ to .gitignore for AI assistant cache Benefits: - Better performance (no child process overhead) - Native session management with interrupt support - Cleaner codebase without CLI/SDK branching - Full feature parity with previous CLI implementation - Maintains compatibility with Cursor integration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update server/claude-sdk.js Whoops. This is correct. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Left my test code in, but that's fixed. Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Prevent stale token-usage data from updating state on session switch - Add AbortController to cancel in-flight token-usage requests when session/project changes - Capture session/project IDs before fetch and verify they match before updating state - Handle AbortError gracefully without logging as error - Prevents race condition where old session data overwrites current session's token budget 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update src/components/TokenUsagePie.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Implement slash command menu with fixed positioning and dark mode support - Add CommandMenu component with grouped command display - Implement command routes for listing, loading, and executing commands - Add command parser utility for argument and file processing - Fix menu positioning using fixed positioning relative to viewport - Add dark mode support with proper text contrast - Preserve metadata badge colors in dark mode - Support built-in, project, and user-level commands - Add keyboard navigation and selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update server/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/utils/commandParser.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/routes/commands.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/components/ChatInterface.jsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/index.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update server/utils/commandParser.js Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Add responsive width constraints to CommandMenu - Use min() function to cap width at viewport - 32px - Add maxWidth constraint for better mobile support - Update package-lock.json with new dependencies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Security and stability improvements for command execution and file operations Security Fixes: - Replace blocking fs.existsSync/readFileSync with async fs.promises.readFile in token usage endpoint - Implement comprehensive command injection protection using shell-quote parser - Validate commands against exact allowlist matches (no dangerous prefix matching) - Detect and block shell operators (&&, ||, |, ;, etc.) and metacharacters - Execute commands with execFile (shell: false) to prevent shell interpretation - Add argument validation to reject dangerous characters Bug Fixes: - Remove premature handleCommandSelect call from selectCommand to prevent double-counting usage - Add block scoping to 'session-aborted' switch case to prevent variable conflicts - Fix case fall-through by properly scoping const declarations with braces Technical Details: - server/index.js: Replace sync file ops with await fsPromises.readFile() - server/utils/commandParser.js: Complete security overhaul with shell-quote integration - src/components/ChatInterface.jsx: Command selection now only inserts text, execution happens on send 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Wrap orphaned token-usage endpoint code in proper async handler - Fixed syntax error caused by orphaned code at lines 1097-1114 - Added proper app.get endpoint definition for token-usage API - Wrapped code in async (req, res) handler with authentication middleware - Preserves all security features (async file reads, path validation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * security: Add path traversal protection to file operation endpoints - Constrain file reads to project root directory - Constrain binary file serving to project root - Constrain file writes to project root - Use extractProjectDirectory to get actual project path - Validate resolved paths start with normalized project root - Prevent authenticated users from accessing files outside their projects Fixes path traversal vulnerability in: - GET /api/projects/:projectName/file (read endpoint) - GET /api/projects/:projectName/files/content (binary serve endpoint) - PUT /api/projects/:projectName/file (save endpoint) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Use WebSocket.OPEN constant instead of instance properties - Import WebSocket from 'ws' library - Change all instances from client.OPEN/ws.OPEN to WebSocket.OPEN - Fixed 4 occurrences: lines 111, 784, 831, 868 - Ensures correct WebSocket state checking using library constant Addresses CodeRabbit security review feedback. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve token usage tracking and fix race conditions - Use cumulative tokens from SDK instead of per-request tokens for accurate session totals - Add configurable context window budget via CONTEXT_WINDOW env var (default 160000) - Fix race condition where stale token usage data could overwrite current session data - Replace polling with one-time fetch on session load + post-message update - Add comprehensive debug logging for token budget flow - Show token percentage on all screen sizes (remove sm:inline hiding) - Add .mcp.json to .gitignore - Add ARCHITECTURE.md and slash-command-tasks.md documentation Technical improvements: - Token budget now fetched after message completion instead of WebSocket - Removed interval polling that could conflict with WebSocket updates - Added session/project validation before updating state - Improved input placeholder to wrap on small screens 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve CommandMenu positioning for mobile devices - Add responsive positioning logic that detects mobile screens (< 640px) - On mobile: Position menu from bottom (80px above input) with full width - On desktop: Use calculated top position with boundary checks - Ensure menu stays within viewport on all screen sizes - Use Math.max/min to prevent menu from going off-screen - Apply consistent positioning to both empty and populated menu states Technical changes: - Add getMenuPosition() function to calculate responsive styles - Mobile: bottom-anchored, full-width with 16px margins - Desktop: top-anchored with viewport boundary constraints - Spread menuPosition styles into both menu render cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Add click-outside detection and improve CommandMenu positioning - Add useEffect hook to detect clicks outside the menu and close it - Fix mobile positioning to use calculated position from textarea instead of hardcoded bottom value - Ensure menu appears just above the input on mobile with proper spacing - Keep full-width layout on mobile screens (< 640px) - Maintain viewport boundary checks on both mobile and desktop Technical changes: - Add mousedown event listener to document when menu is open - Check if click target is outside menuRef and call onClose - Remove hardcoded `bottom: '80px'` in favor of calculated `top` position - Use Math.max to ensure menu stays at least 16px from top edge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * debug: Add console logging and improve mobile positioning logic - Add console logs to debug positioning and rendering - Improve mobile positioning with better space calculations - Check if there's enough space above textarea before positioning - Position from top of viewport if insufficient space above input - Ensure menu stays within visible viewport boundaries Debugging additions: - Log isOpen, commandsLength, position, and menuPosition - Log mobile positioning calculations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Use bottom positioning for CommandMenu on mobile - Change mobile positioning from top-based to bottom-based (90px from bottom) - This ensures menu always appears just above input, regardless of keyboard state - Add maxHeight: '50vh' to prevent menu from taking up too much space - Remove complex position calculations that didn't work well with mobile keyboard - Remove debug console.log statements - Menu now correctly appears above input on all mobile screen sizes Technical changes: - Mobile: Use fixed bottom positioning instead of calculated top - Desktop: Continue using top positioning for consistency - Simplified positioning logic for better maintainability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Filter Invalid API key messages from session titles API error messages were appearing as session titles because they come from assistant messages with isApiErrorMessage flag, but the filter only checked user messages. Updated assistant message handling to: - Skip messages with isApiErrorMessage: true flag - Filter messages starting with "Invalid API key" Also improved session title logic to prefer last user message over last assistant message for better context. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Fix Temporal Dead Zone error by reordering function declarations Reordered function declarations in ChatInterface.jsx to resolve ReferenceError where executeCommand tried to call handleBuiltInCommand and handleCustomCommand before they were initialized. - Moved handleBuiltInCommand before executeCommand (now at line 1441) - Moved handleCustomCommand before executeCommand (now at line 1533) - executeCommand now at line 1564, after its dependencies This fixes the "cannot access uninitialized variable" error that was preventing the chat interface from loading. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve session handling, message routing, and mobile UX Session Handling: - Fix new session message routing by allowing messages through when currentSessionId is null - Improve claude-complete event handling to update UI state for new sessions - Add session loading ref to prevent duplicate scroll triggers during session switches - Add extensive debug logging in claude-sdk.js to track session lifecycle Mobile UX: - Only close sidebar on mobile when switching between different projects - Keep sidebar open when clicking sessions within the same project - Add project context to session objects for better tracking Command Execution: - Auto-submit commands to Claude for processing after selection - Set command content in input and programmatically submit form Scroll Behavior: - Fix scroll behavior during session loading with isLoadingSessionRef - Prevent double-scroll effect when switching sessions - Ensure smooth scroll to bottom after messages fully render Message Filtering: - Update global message types to include 'claude-complete' - Allow messages through for new sessions (when currentSessionId is null) - Improve session-specific message filtering logic Dependencies: - Update @esbuild/darwin-arm64 to direct dependency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Improve session handling, message routing, and mobile UX - Remove stale Playwright debug files (.playwright-mcp/) - Clean up slash-command-fix-progress.md tracking file - Improve session switching stability in ClaudeStatus component - Fix message routing to ensure responses go to correct session - Enhance mobile UX for CommandMenu with better positioning - Stabilize sidebar session management - Fix Temporal Dead Zone errors in ChatInterface 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Filter sessions containing Task Master subtask JSON from session list - Change filtering from startsWith to includes for {"subtasks": pattern - Apply filtering in parseJsonlSessions (line 781) for JSONL parsing - Apply filtering in getSessions (line 630) before returning to API - Fix inconsistent filter logic - use OR pattern for both user and assistant messages - Add filtering for "CRITICAL: You MUST respond with ONLY a JSON" messages - Prevents Task Master JSON responses from appearing as session titles in sidebar 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: Filter JSON response sessions from session list - Change session filtering to use general pattern `startsWith('{ "')` - Catches all Task Master JSON responses (subtasks, complexity analysis, tasks) - Apply filter in both parseJsonlSessions() and getSessions() functions - Prevents JSON responses from appearing as session titles in UI 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * Update websocket.js * Update projects.js * Update CommandMenu.jsx --------- Co-authored-by: viper151 <simosmik@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
46
src/App.jsx
46
src/App.jsx
@@ -70,6 +70,10 @@ function AppContent() {
|
||||
// This allows us to restore the "Thinking..." banner when switching back to a processing session
|
||||
const [processingSessions, setProcessingSessions] = useState(new Set());
|
||||
|
||||
// External Message Update Trigger: Incremented when external CLI modifies current session's JSONL
|
||||
// Triggers ChatInterface to reload messages without switching sessions
|
||||
const [externalMessageUpdate, setExternalMessageUpdate] = useState(0);
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
|
||||
// Detect if running as PWA
|
||||
@@ -164,7 +168,32 @@ function AppContent() {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
|
||||
if (latestMessage.type === 'projects_updated') {
|
||||
|
||||
|
||||
// External Session Update Detection: Check if the changed file is the current session's JSONL
|
||||
// If so, and the session is not active, trigger a message reload in ChatInterface
|
||||
if (latestMessage.changedFile && selectedSession && selectedProject) {
|
||||
// Extract session ID from changedFile (format: "project-name/session-id.jsonl")
|
||||
const changedFileParts = latestMessage.changedFile.split('/');
|
||||
if (changedFileParts.length >= 2) {
|
||||
const filename = changedFileParts[changedFileParts.length - 1];
|
||||
const changedSessionId = filename.replace('.jsonl', '');
|
||||
|
||||
// Check if this is the currently-selected session
|
||||
if (changedSessionId === selectedSession.id) {
|
||||
const isSessionActive = activeSessions.has(selectedSession.id);
|
||||
|
||||
if (!isSessionActive) {
|
||||
// Session is not active - safe to reload messages
|
||||
console.log('🔄 External CLI update detected for current session:', changedSessionId);
|
||||
setExternalMessageUpdate(prev => prev + 1);
|
||||
} else {
|
||||
// Session is active - skip reload to avoid interrupting user
|
||||
console.log('⏸️ External update paused - session is active:', changedSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Session Protection Logic: Allow additions but prevent changes during active conversations
|
||||
// This allows new sessions/projects to appear in sidebar while protecting active chat messages
|
||||
// We check for two types of active sessions:
|
||||
@@ -332,7 +361,7 @@ function AppContent() {
|
||||
if (activeTab !== 'git' && activeTab !== 'preview') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
|
||||
|
||||
// For Cursor sessions, we need to set the session ID differently
|
||||
// since they're persistent and not created by Claude
|
||||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||||
@@ -340,9 +369,17 @@ function AppContent() {
|
||||
// Cursor sessions have persistent IDs
|
||||
sessionStorage.setItem('cursorSessionId', session.id);
|
||||
}
|
||||
|
||||
|
||||
// Only close sidebar on mobile if switching to a different project
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
const sessionProjectName = session.__projectName;
|
||||
const currentProjectName = selectedProject?.name;
|
||||
|
||||
// Close sidebar if clicking a session from a different project
|
||||
// Keep it open if clicking a session from the same project
|
||||
if (sessionProjectName !== currentProjectName) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
}
|
||||
navigate(`/session/${session.id}`);
|
||||
};
|
||||
@@ -694,6 +731,7 @@ function AppContent() {
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
@@ -13,7 +13,7 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
setFakeTokens(0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
@@ -21,21 +21,23 @@ function ClaudeStatus({ status, onAbort, isLoading, provider = 'claude' }) {
|
||||
// Simulate token count increasing over time (roughly 30-50 tokens per second)
|
||||
setFakeTokens(Math.floor(elapsed * (30 + Math.random() * 20)));
|
||||
}, 1000);
|
||||
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoading]);
|
||||
|
||||
|
||||
// Animate the status indicator
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setAnimationPhase(prev => (prev + 1) % 4);
|
||||
}, 500);
|
||||
|
||||
|
||||
return () => 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
|
||||
|
||||
344
src/components/CommandMenu.jsx
Normal file
344
src/components/CommandMenu.jsx
Normal file
@@ -0,0 +1,344 @@
|
||||
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
|
||||
* @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 positioning
|
||||
const getMenuPosition = () => {
|
||||
const isMobile = window.innerWidth < 640;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const menuHeight = 300; // Max height of menu
|
||||
|
||||
if (isMobile) {
|
||||
// On mobile, calculate bottom position dynamically to appear above the input
|
||||
// Use the bottom value which is calculated as: window.innerHeight - textarea.top + spacing
|
||||
const inputBottom = position.bottom || 90; // Use provided bottom or default
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Scroll selected item into view
|
||||
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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="command-menu command-menu-empty"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '20px',
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
No commands available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add frequent commands as a special group if provided
|
||||
const hasFrequentCommands = frequentCommands.length > 0;
|
||||
|
||||
// Group commands by namespace
|
||||
const groupedCommands = commands.reduce((groups, command) => {
|
||||
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: '⭐ Frequently Used',
|
||||
builtin: 'Built-in Commands',
|
||||
project: 'Project Commands',
|
||||
user: 'User Commands',
|
||||
other: 'Other Commands'
|
||||
};
|
||||
|
||||
// Calculate global index for each command
|
||||
let globalIndex = 0;
|
||||
const commandsWithIndex = [];
|
||||
orderedNamespaces.forEach(namespace => {
|
||||
groupedCommands[namespace].forEach(command => {
|
||||
commandsWithIndex.push({
|
||||
...command,
|
||||
globalIndex: globalIndex++,
|
||||
namespace
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
role="listbox"
|
||||
aria-label="Available commands"
|
||||
className="command-menu"
|
||||
style={{
|
||||
...menuPosition,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
zIndex: 1000,
|
||||
padding: '8px',
|
||||
opacity: isOpen ? 1 : 0,
|
||||
transform: isOpen ? 'translateY(0)' : 'translateY(-10px)',
|
||||
transition: 'opacity 150ms ease-in-out, transform 150ms ease-in-out'
|
||||
}}
|
||||
>
|
||||
{orderedNamespaces.map((namespace) => (
|
||||
<div key={namespace} className="command-group">
|
||||
{orderedNamespaces.length > 1 && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
color: '#6b7280',
|
||||
padding: '8px 12px 4px',
|
||||
letterSpacing: '0.05em'
|
||||
}}
|
||||
>
|
||||
{namespaceLabels[namespace] || namespace}
|
||||
</div>
|
||||
)}
|
||||
{groupedCommands[namespace].map((command) => {
|
||||
const cmdWithIndex = commandsWithIndex.find(c => c.name === command.name && c.namespace === namespace);
|
||||
const isSelected = cmdWithIndex && cmdWithIndex.globalIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${namespace}-${command.name}`}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className="command-item"
|
||||
onMouseEnter={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, true)}
|
||||
onClick={() => onSelect && onSelect(command, cmdWithIndex.globalIndex, 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'
|
||||
}}
|
||||
onMouseDown={(e) => e.preventDefault()} // Prevent textarea blur
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: command.description ? '4px' : 0
|
||||
}}
|
||||
>
|
||||
{/* Command icon based on namespace */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
{namespace === 'builtin' && '⚡'}
|
||||
{namespace === 'project' && '📁'}
|
||||
{namespace === 'user' && '👤'}
|
||||
{namespace === 'other' && '📝'}
|
||||
</span>
|
||||
|
||||
{/* Command name */}
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#111827',
|
||||
fontFamily: 'monospace'
|
||||
}}
|
||||
>
|
||||
{command.name}
|
||||
</span>
|
||||
|
||||
{/* Command metadata badge */}
|
||||
{command.metadata?.type && (
|
||||
<span
|
||||
className="command-metadata-badge"
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#6b7280',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{command.metadata.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command description */}
|
||||
{command.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: '#6b7280',
|
||||
marginLeft: '24px',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{command.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
color: '#3b82f6',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600
|
||||
}}
|
||||
>
|
||||
↵
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Default light mode styles */}
|
||||
<style>{`
|
||||
.command-menu {
|
||||
background-color: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.command-menu {
|
||||
background-color: #1f2937 !important;
|
||||
border: 1px solid #374151 !important;
|
||||
}
|
||||
.command-menu-empty {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
.command-item[aria-selected="true"] {
|
||||
background-color: #1e40af !important;
|
||||
}
|
||||
.command-item span:not(.command-metadata-badge) {
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
.command-metadata-badge {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
.command-item div {
|
||||
color: #d1d5db !important;
|
||||
}
|
||||
.command-group > div:first-child {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommandMenu;
|
||||
@@ -55,7 +55,8 @@ function MainContent({
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
showThinking, // Show thinking/reasoning sections
|
||||
autoScrollToBottom, // Auto-scroll to bottom when new messages arrive
|
||||
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
|
||||
sendByCtrlEnter, // Send by Ctrl+Enter mode for East Asian language input
|
||||
externalMessageUpdate // Trigger for external CLI updates to current session
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
@@ -433,6 +434,7 @@ function MainContent({
|
||||
showThinking={showThinking}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
externalMessageUpdate={externalMessageUpdate}
|
||||
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -301,15 +301,20 @@ function Sidebar({
|
||||
};
|
||||
|
||||
const toggleProject = (projectName) => {
|
||||
const newExpanded = new Set(expandedProjects);
|
||||
if (newExpanded.has(projectName)) {
|
||||
newExpanded.delete(projectName);
|
||||
} else {
|
||||
const newExpanded = new Set();
|
||||
// If clicking the already-expanded project, collapse it (newExpanded stays empty)
|
||||
// If clicking a different project, expand only that one
|
||||
if (!expandedProjects.has(projectName)) {
|
||||
newExpanded.add(projectName);
|
||||
}
|
||||
setExpandedProjects(newExpanded);
|
||||
};
|
||||
|
||||
// Wrapper to attach project context when session is clicked
|
||||
const handleSessionClick = (session, projectName) => {
|
||||
onSessionSelect({ ...session, __projectName: projectName });
|
||||
};
|
||||
|
||||
// Starred projects utility functions
|
||||
const toggleStarProject = (projectName) => {
|
||||
const newStarred = new Set(starredProjects);
|
||||
@@ -1029,7 +1034,7 @@ function Sidebar({
|
||||
{project.displayName}
|
||||
</h3>
|
||||
{tasksEnabled && (
|
||||
<TaskIndicator
|
||||
<TaskIndicator
|
||||
status={(() => {
|
||||
const projectConfigured = project.taskmaster?.hasTaskmaster;
|
||||
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
|
||||
@@ -1037,9 +1042,9 @@ function Sidebar({
|
||||
if (projectConfigured) return 'taskmaster-only';
|
||||
if (mcpConfigured) return 'mcp-only';
|
||||
return 'not-configured';
|
||||
})()}
|
||||
})()}
|
||||
size="xs"
|
||||
className="flex-shrink-0 ml-2"
|
||||
className="hidden md:inline-flex flex-shrink-0 ml-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1337,11 +1342,11 @@ function Sidebar({
|
||||
)}
|
||||
onClick={() => {
|
||||
handleProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
handleSessionClick(session, project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
handleProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
handleSessionClick(session, project.name);
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1404,8 +1409,8 @@ function Sidebar({
|
||||
"w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
|
||||
selectedSession?.id === session.id && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => onSessionSelect(session)}
|
||||
onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
|
||||
onClick={() => handleSessionClick(session, project.name)}
|
||||
onTouchEnd={handleTouchClick(() => handleSessionClick(session, project.name))}
|
||||
>
|
||||
<div className="flex items-start gap-2 min-w-0 w-full">
|
||||
{isCursorSession ? (
|
||||
|
||||
@@ -43,8 +43,8 @@ function TokenUsagePie({ used, total }) {
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span className="hidden sm:inline" title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
||||
{percentage.toFixed(0)}%
|
||||
<span title={`${used.toLocaleString()} / ${total.toLocaleString()} tokens`}>
|
||||
{percentage.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -443,18 +443,18 @@
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
|
||||
.dark .chat-input-placeholder::placeholder {
|
||||
color: rgb(75 85 99) !important;
|
||||
opacity: 1 !important;
|
||||
-webkit-text-fill-color: rgb(75 85 99) !important;
|
||||
}
|
||||
|
||||
|
||||
.chat-input-placeholder::-webkit-input-placeholder {
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
|
||||
.dark .chat-input-placeholder::-webkit-input-placeholder {
|
||||
color: rgb(75 85 99) !important;
|
||||
opacity: 1 !important;
|
||||
@@ -722,9 +722,15 @@
|
||||
|
||||
/* Improved textarea styling */
|
||||
.chat-input-placeholder {
|
||||
display: block !important;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||||
}
|
||||
|
||||
/* Ensure container fits textarea tightly */
|
||||
.chat-input-placeholder:not(:focus) {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
11
src/main.jsx
11
src/main.jsx
@@ -3,6 +3,17 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
// Clean up stale service workers on app load to prevent caching issues after builds
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(registrations => {
|
||||
registrations.forEach(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}).catch(err => {
|
||||
console.warn('Failed to unregister service workers:', err);
|
||||
});
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -106,4 +106,4 @@ export function useWebSocket() {
|
||||
messages,
|
||||
isConnected
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user