4 Commits

8 changed files with 492 additions and 274 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-ui", "name": "claude-code-ui",
"version": "1.1.1", "version": "1.1.3",
"description": "A web-based UI for Claude Code CLI", "description": "A web-based UI for Claude Code CLI",
"main": "server/index.js", "main": "server/index.js",
"scripts": { "scripts": {

View File

@@ -30,7 +30,7 @@ const os = require('os');
const pty = require('node-pty'); const pty = require('node-pty');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually } = require('./projects'); const { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } = require('./projects');
const { spawnClaude, abortClaudeSession } = require('./claude-cli'); const { spawnClaude, abortClaudeSession } = require('./claude-cli');
const gitRoutes = require('./routes/git'); const gitRoutes = require('./routes/git');
@@ -76,6 +76,9 @@ function setupProjectsWatcher() {
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
// Clear project directory cache when files change
clearProjectDirectoryCache();
// Get updated projects list // Get updated projects list
const updatedProjects = await getProjects(); const updatedProjects = await getProjects();
@@ -372,47 +375,15 @@ app.get('/api/projects/:projectName/files', async (req, res) => {
try { try {
const fs = require('fs').promises; const fs = require('fs').promises;
const projectPath = path.join(process.env.HOME, '.claude', 'projects', req.params.projectName);
// Try different methods to get the actual project path
let actualPath = projectPath;
// Use extractProjectDirectory to get the actual project path
let actualPath;
try { try {
// First try to read metadata.json actualPath = await extractProjectDirectory(req.params.projectName);
const metadataPath = path.join(projectPath, 'metadata.json'); } catch (error) {
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8')); console.error('Error extracting project directory:', error);
actualPath = metadata.path || metadata.cwd; // Fallback to simple dash replacement
} catch (e) { actualPath = req.params.projectName.replace(/-/g, '/');
// Fallback: try to find the actual path by testing different dash interpretations
let testPath = req.params.projectName;
if (testPath.startsWith('-')) {
testPath = testPath.substring(1);
}
// Try to intelligently decode the path by testing which directories exist
const pathParts = testPath.split('-');
actualPath = '/' + pathParts.join('/');
// If the simple replacement doesn't work, try to find the correct path
// by testing combinations where some dashes might be part of directory names
if (!require('fs').existsSync(actualPath)) {
// Try different combinations of dash vs slash
for (let i = pathParts.length - 1; i >= 0; i--) {
let testParts = [...pathParts];
// Try joining some parts with dashes instead of slashes
for (let j = i; j < testParts.length - 1; j++) {
testParts[j] = testParts[j] + '-' + testParts[j + 1];
testParts.splice(j + 1, 1);
let testActualPath = '/' + testParts.join('/');
if (require('fs').existsSync(testActualPath)) {
actualPath = testActualPath;
break;
}
}
if (require('fs').existsSync(actualPath)) break;
}
}
} }
// Check if path exists // Check if path exists

View File

@@ -2,6 +2,17 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const readline = require('readline'); const readline = require('readline');
// Cache for extracted project directories
const projectDirectoryCache = new Map();
let cacheTimestamp = Date.now();
// Clear cache when needed (called when project files change)
function clearProjectDirectoryCache() {
projectDirectoryCache.clear();
cacheTimestamp = Date.now();
console.log('🗑️ Project directory cache cleared');
}
// Load project configuration file // Load project configuration file
async function loadProjectConfig() { async function loadProjectConfig() {
const configPath = path.join(process.env.HOME, '.claude', 'project-config.json'); const configPath = path.join(process.env.HOME, '.claude', 'project-config.json');
@@ -21,9 +32,9 @@ async function saveProjectConfig(config) {
} }
// Generate better display name from path // Generate better display name from path
async function generateDisplayName(projectName) { async function generateDisplayName(projectName, actualProjectDir = null) {
// Convert "-home-user-projects-myapp" to a readable format // Use actual project directory if provided, otherwise decode from project name
let projectPath = projectName.replace(/-/g, '/'); let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
// Try to read package.json from the project path // Try to read package.json from the project path
try { try {
@@ -54,6 +65,111 @@ async function generateDisplayName(projectName) {
return projectPath; return projectPath;
} }
// Extract the actual project directory from JSONL sessions (with caching)
async function extractProjectDirectory(projectName) {
// Check cache first
if (projectDirectoryCache.has(projectName)) {
return projectDirectoryCache.get(projectName);
}
console.log(`🔍 Extracting project directory for: ${projectName}`);
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
let extractedPath;
try {
const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
if (jsonlFiles.length === 0) {
// Fall back to decoded project name if no sessions
extractedPath = projectName.replace(/-/g, '/');
} else {
// Process all JSONL files to collect cwd values
for (const file of jsonlFiles) {
const jsonlFile = path.join(projectDir, file);
const fileStream = require('fs').createReadStream(jsonlFile);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
const entry = JSON.parse(line);
if (entry.cwd) {
// Count occurrences of each cwd
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
// Track the most recent cwd
const timestamp = new Date(entry.timestamp || 0).getTime();
if (timestamp > latestTimestamp) {
latestTimestamp = timestamp;
latestCwd = entry.cwd;
}
}
} catch (parseError) {
// Skip malformed lines
}
}
}
}
// Determine the best cwd to use
if (cwdCounts.size === 0) {
// No cwd found, fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
} else if (cwdCounts.size === 1) {
// Only one cwd, use it
extractedPath = Array.from(cwdCounts.keys())[0];
} else {
// Multiple cwd values - prefer the most recent one if it has reasonable usage
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
const maxCount = Math.max(...cwdCounts.values());
// Use most recent if it has at least 25% of the max count
if (mostRecentCount >= maxCount * 0.25) {
extractedPath = latestCwd;
} else {
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
extractedPath = cwd;
break;
}
}
}
// Fallback (shouldn't reach here)
if (!extractedPath) {
extractedPath = latestCwd || projectName.replace(/-/g, '/');
}
}
}
// Cache the result
projectDirectoryCache.set(projectName, extractedPath);
console.log(`💾 Cached project directory: ${projectName} -> ${extractedPath}`);
return extractedPath;
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name
extractedPath = projectName.replace(/-/g, '/');
// Cache the fallback result too
projectDirectoryCache.set(projectName, extractedPath);
return extractedPath;
}
}
async function getProjects() { async function getProjects() {
const claudeDir = path.join(process.env.HOME, '.claude', 'projects'); const claudeDir = path.join(process.env.HOME, '.claude', 'projects');
const config = await loadProjectConfig(); const config = await loadProjectConfig();
@@ -69,14 +185,17 @@ async function getProjects() {
existingProjects.add(entry.name); existingProjects.add(entry.name);
const projectPath = path.join(claudeDir, entry.name); const projectPath = path.join(claudeDir, entry.name);
// Extract actual project directory from JSONL sessions
const actualProjectDir = await extractProjectDirectory(entry.name);
// Get display name from config or generate one // Get display name from config or generate one
const customName = config[entry.name]?.displayName; const customName = config[entry.name]?.displayName;
const autoDisplayName = await generateDisplayName(entry.name); const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir);
const fullPath = entry.name.replace(/-/g, '/'); const fullPath = actualProjectDir;
const project = { const project = {
name: entry.name, name: entry.name,
path: projectPath, path: actualProjectDir,
displayName: customName || autoDisplayName, displayName: customName || autoDisplayName,
fullPath: fullPath, fullPath: fullPath,
isCustomName: !!customName, isCustomName: !!customName,
@@ -105,17 +224,27 @@ async function getProjects() {
// Add manually configured projects that don't exist as folders yet // Add manually configured projects that don't exist as folders yet
for (const [projectName, projectConfig] of Object.entries(config)) { for (const [projectName, projectConfig] of Object.entries(config)) {
if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) {
const fullPath = projectName.replace(/-/g, '/'); // Use the original path if available, otherwise extract from potential sessions
let actualProjectDir = projectConfig.originalPath;
const project = { if (!actualProjectDir) {
name: projectName, try {
path: null, // No physical path yet actualProjectDir = await extractProjectDirectory(projectName);
displayName: projectConfig.displayName || await generateDisplayName(projectName), } catch (error) {
fullPath: fullPath, // Fall back to decoded project name
isCustomName: !!projectConfig.displayName, actualProjectDir = projectName.replace(/-/g, '/');
isManuallyAdded: true, }
sessions: [] }
};
const project = {
name: projectName,
path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName,
isManuallyAdded: true,
sessions: []
};
projects.push(project); projects.push(project);
} }
@@ -463,9 +592,9 @@ async function addProjectManually(projectPath, displayName = null) {
return { return {
name: projectName, name: projectName,
path: null, path: absolutePath,
fullPath: absolutePath, fullPath: absolutePath,
displayName: displayName || await generateDisplayName(projectName), displayName: displayName || await generateDisplayName(projectName, absolutePath),
isManuallyAdded: true, isManuallyAdded: true,
sessions: [] sessions: []
}; };
@@ -483,5 +612,7 @@ module.exports = {
deleteProject, deleteProject,
addProjectManually, addProjectManually,
loadProjectConfig, loadProjectConfig,
saveProjectConfig saveProjectConfig,
extractProjectDirectory,
clearProjectDirectoryCache
}; };

View File

@@ -3,15 +3,47 @@ const { exec } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { extractProjectDirectory } = require('../projects');
const router = express.Router(); const router = express.Router();
const execAsync = promisify(exec); const execAsync = promisify(exec);
// Helper function to get the actual project path from the encoded project name // Helper function to get the actual project path from the encoded project name
function getActualProjectPath(projectName) { async function getActualProjectPath(projectName) {
// Claude stores projects with dashes instead of slashes try {
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui" return await extractProjectDirectory(projectName);
return projectName.replace(/-/g, '/'); } catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fallback to the old method
return projectName.replace(/-/g, '/');
}
}
// Helper function to validate git repository
async function validateGitRepository(projectPath) {
try {
// Check if directory exists
await fs.access(projectPath);
} catch {
throw new Error(`Project path not found: ${projectPath}`);
}
try {
// Use --show-toplevel to get the root of the git repository
const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
const normalizedGitRoot = path.resolve(gitRoot.trim());
const normalizedProjectPath = path.resolve(projectPath);
// Ensure the git root matches our project path (prevent using parent git repos)
if (normalizedGitRoot !== normalizedProjectPath) {
throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`);
}
} catch (error) {
if (error.message.includes('Project directory is not a git repository')) {
throw error;
}
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
} }
// Get git status for a project // Get git status for a project
@@ -23,24 +55,11 @@ router.get('/status', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath); console.log('Git status for project:', project, '-> path:', projectPath);
// Check if directory exists // Validate git repository
try { await validateGitRepository(projectPath);
await fs.access(projectPath);
} catch {
console.error('Project path not found:', projectPath);
return res.json({ error: 'Project not found' });
}
// Check if it's a git repository
try {
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
} catch {
console.error('Not a git repository:', projectPath);
return res.json({ error: 'Not a git repository' });
}
// Get current branch // Get current branch
const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
@@ -79,7 +98,14 @@ router.get('/status', async (req, res) => {
}); });
} catch (error) { } catch (error) {
console.error('Git status error:', error); console.error('Git status error:', error);
res.json({ error: error.message }); res.json({
error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: 'Git operation failed',
details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
? error.message
: `Failed to get git status: ${error.message}`
});
} }
}); });
@@ -92,7 +118,10 @@ router.get('/diff', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Check if file is untracked // Check if file is untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
@@ -133,7 +162,10 @@ router.post('/commit', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Validate git repository
await validateGitRepository(projectPath);
// Stage selected files // Stage selected files
for (const file of files) { for (const file of files) {
@@ -159,9 +191,12 @@ router.get('/branches', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', projectPath); console.log('Git branches for project:', project, '-> path:', projectPath);
// Validate git repository
await validateGitRepository(projectPath);
// Get all branches // Get all branches
const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
@@ -199,7 +234,7 @@ router.post('/checkout', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Checkout the branch // Checkout the branch
const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
@@ -220,7 +255,7 @@ router.post('/create-branch', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Create and checkout new branch // Create and checkout new branch
const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
@@ -241,7 +276,7 @@ router.get('/commits', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get commit log with stats // Get commit log with stats
const { stdout } = await execAsync( const { stdout } = await execAsync(
@@ -292,7 +327,7 @@ router.get('/commit-diff', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get diff for the commit // Get diff for the commit
const { stdout } = await execAsync( const { stdout } = await execAsync(
@@ -316,7 +351,7 @@ router.post('/generate-commit-message', async (req, res) => {
} }
try { try {
const projectPath = getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get diff for selected files // Get diff for selected files
let combinedDiff = ''; let combinedDiff = '';

View File

@@ -1112,15 +1112,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const isNearBottom = useCallback(() => { const isNearBottom = useCallback(() => {
if (!scrollContainerRef.current) return false; if (!scrollContainerRef.current) return false;
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
// Consider "near bottom" if within 100px of the bottom // Consider "near bottom" if within 50px of the bottom
return scrollHeight - scrollTop - clientHeight < 100; return scrollHeight - scrollTop - clientHeight < 50;
}, []); }, []);
// Handle scroll events to detect when user manually scrolls up // Handle scroll events to detect when user manually scrolls up
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
const wasNearBottom = isNearBottom(); const nearBottom = isNearBottom();
setIsUserScrolledUp(!wasNearBottom); setIsUserScrolledUp(!nearBottom);
} }
}, [isNearBottom]); }, [isNearBottom]);
@@ -1540,13 +1540,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}); });
useEffect(() => { useEffect(() => {
// Only auto-scroll to bottom when new messages arrive if: // Auto-scroll to bottom when new messages arrive
// 1. Auto-scroll is enabled in settings
// 2. User hasn't manually scrolled up
if (scrollContainerRef.current && chatMessages.length > 0) { if (scrollContainerRef.current && chatMessages.length > 0) {
if (autoScrollToBottom) { if (autoScrollToBottom) {
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
if (!isUserScrolledUp) { if (!isUserScrolledUp) {
setTimeout(() => scrollToBottom(), 0); setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
} }
} else { } else {
// When auto-scroll is disabled, preserve the visual position // When auto-scroll is disabled, preserve the visual position
@@ -1564,12 +1563,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
} }
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]); }, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
// Scroll to bottom when component mounts with existing messages // Scroll to bottom when component mounts with existing messages or when messages first load
useEffect(() => { useEffect(() => {
if (scrollContainerRef.current && chatMessages.length > 0 && autoScrollToBottom) { if (scrollContainerRef.current && chatMessages.length > 0) {
setTimeout(() => scrollToBottom(), 100); // Small delay to ensure rendering // Always scroll to bottom when messages first load (user expects to see latest)
// Also reset scroll state
setIsUserScrolledUp(false);
setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering
} }
}, [scrollToBottom, autoScrollToBottom]); }, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear
// Add scroll event listener to detect user scrolling // Add scroll event listener to detect user scrolling
useEffect(() => { useEffect(() => {
@@ -1636,8 +1638,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
can_interrupt: true can_interrupt: true
}); });
// Always scroll to bottom when user sends a message (they're actively participating) // Always scroll to bottom when user sends a message and reset scroll state
setTimeout(() => scrollToBottom(), 0); setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
// Session Protection: Mark session as active to prevent automatic project updates during conversation // Session Protection: Mark session as active to prevent automatic project updates during conversation
// This is crucial for maintaining chat state integrity. We handle two cases: // This is crucial for maintaining chat state integrity. We handle two cases:
@@ -1882,21 +1885,21 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
{/* Floating scroll to bottom button */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-10 h-10 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-10"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
</div> </div>
{/* Floating scroll to bottom button - positioned outside scrollable container */}
{isUserScrolledUp && chatMessages.length > 0 && (
<button
onClick={scrollToBottom}
className="fixed bottom-20 sm:bottom-24 right-4 sm:right-6 w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800 z-50"
title="Scroll to bottom"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
</svg>
</button>
)}
{/* Input Area - Fixed Bottom */} {/* Input Area - Fixed Bottom */}
<div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${ <div className={`p-2 sm:p-4 md:p-6 flex-shrink-0 ${
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6' isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
@@ -1977,8 +1980,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg> </svg>
</button> </button>
)} )}
{/* Mic button */} {/* Mic button - HIDDEN */}
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2"> <div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
<MicButton <MicButton
onTranscript={handleTranscript} onTranscript={handleTranscript}
className="w-10 h-10 sm:w-10 sm:h-10" className="w-10 h-10 sm:w-10 sm:h-10"

View File

@@ -62,7 +62,7 @@ function GitPanel({ selectedProject, isMobile }) {
if (data.error) { if (data.error) {
console.error('Git status error:', data.error); console.error('Git status error:', data.error);
setGitStatus(null); setGitStatus({ error: data.error, details: data.details });
} else { } else {
setGitStatus(data); setGitStatus(data);
setCurrentBranch(data.branch || 'main'); setCurrentBranch(data.branch || 'main');
@@ -506,169 +506,191 @@ function GitPanel({ selectedProject, isMobile }) {
</button> </button>
</div> </div>
{/* Tab Navigation */} {/* Git Repository Not Found Message */}
<div className="flex border-b border-gray-200 dark:border-gray-700"> {gitStatus?.error ? (
<button <div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 px-6 py-12">
onClick={() => setActiveView('changes')} <GitBranch className="w-20 h-20 mb-6 opacity-30" />
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${ <h3 className="text-xl font-medium mb-3 text-center">{gitStatus.error}</h3>
activeView === 'changes' {gitStatus.details && (
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400' <p className="text-sm text-center leading-relaxed mb-6 max-w-md">{gitStatus.details}</p>
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white' )}
}`} <div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800 max-w-md">
> <p className="text-sm text-blue-700 dark:text-blue-300 text-center">
<div className="flex items-center justify-center gap-2"> <strong>Tip:</strong> Run <code className="bg-blue-100 dark:bg-blue-900 px-2 py-1 rounded font-mono text-xs">git init</code> in your project directory to initialize git source control.
<FileText className="w-4 h-4" /> </p>
<span>Changes</span>
</div> </div>
</button> </div>
<button ) : (
onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<> <>
{/* Commit Message Input */} {/* Tab Navigation - Only show when git is available */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700"> <div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="relative"> <button
<textarea onClick={() => setActiveView('changes')}
ref={textareaRef} className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
value={commitMessage} activeView === 'changes'
onChange={(e) => setCommitMessage(e.target.value)} ? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
placeholder="Message (Ctrl+Enter to commit)" : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20" }`}
rows="3" >
onKeyDown={(e) => { <div className="flex items-center justify-center gap-2">
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { <FileText className="w-4 h-4" />
handleCommit(); <span>Changes</span>
} </div>
}} </button>
/> <button
<div className="absolute right-2 top-2 flex gap-1"> onClick={() => setActiveView('history')}
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
activeView === 'history'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<div className="flex items-center justify-center gap-2">
<History className="w-4 h-4" />
<span>History</span>
</div>
</button>
</div>
{/* Changes View */}
{activeView === 'changes' && (
<>
{/* Commit Message Input */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<textarea
ref={textareaRef}
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Message (Ctrl+Enter to commit)"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 resize-none pr-20"
rows="3"
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleCommit();
}
}}
/>
<div className="absolute right-2 top-2 flex gap-1">
<button
onClick={generateCommitMessage}
disabled={selectedFiles.size === 0 || isGeneratingMessage}
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
title="Generate commit message"
>
{isGeneratingMessage ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
</button>
<div style={{ display: 'none' }}>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected
</span>
<button
onClick={handleCommit}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting}
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
>
<Check className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span>
</button>
</div>
</div>
</>
)}
{/* File Selection Controls - Only show in changes view and when git is working */}
{activeView === 'changes' && gitStatus && !gitStatus.error && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<div className="flex gap-2">
<button <button
onClick={generateCommitMessage} onClick={() => {
disabled={selectedFiles.size === 0 || isGeneratingMessage} const allFiles = new Set([
className="p-1.5 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 disabled:opacity-50 disabled:cursor-not-allowed" ...(gitStatus?.modified || []),
title="Generate commit message" ...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
> >
{isGeneratingMessage ? ( Select All
<RefreshCw className="w-4 h-4 animate-spin" /> </button>
) : ( <span className="text-gray-300 dark:text-gray-600">|</span>
<Sparkles className="w-4 h-4" /> <button
)} onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button> </button>
<MicButton
onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default"
className="p-1.5"
/>
</div> </div>
</div> </div>
<div className="flex items-center justify-between mt-2"> )}
<span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected {/* Status Legend Toggle */}
</span> {!gitStatus?.error && (
<div className="border-b border-gray-200 dark:border-gray-700">
<button <button
onClick={handleCommit} onClick={() => setShowLegend(!showLegend)}
disabled={!commitMessage.trim() || selectedFiles.size === 0 || isCommitting} className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
className="px-3 py-1 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-1"
> >
<Check className="w-3 h-3" /> <Info className="w-3 h-3" />
<span>{isCommitting ? 'Committing...' : 'Commit'}</span> <span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button> </button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div> </div>
</div> )}
</> </>
)} )}
{/* File Selection Controls - Only show in changes view */} {/* File List - Changes View - Only show when git is available */}
{activeView === 'changes' && gitStatus && ( {activeView === 'changes' && !gitStatus?.error && (
<div className="px-4 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<span className="text-xs text-gray-600 dark:text-gray-400">
{selectedFiles.size} of {(gitStatus?.modified?.length || 0) + (gitStatus?.added?.length || 0) + (gitStatus?.deleted?.length || 0) + (gitStatus?.untracked?.length || 0)} files selected
</span>
<div className="flex gap-2">
<button
onClick={() => {
const allFiles = new Set([
...(gitStatus?.modified || []),
...(gitStatus?.added || []),
...(gitStatus?.deleted || []),
...(gitStatus?.untracked || [])
]);
setSelectedFiles(allFiles);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Select All
</button>
<span className="text-gray-300 dark:text-gray-600">|</span>
<button
onClick={() => setSelectedFiles(new Set())}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
)}
{/* Status Legend Toggle */}
<div className="border-b border-gray-200 dark:border-gray-700">
<button
onClick={() => setShowLegend(!showLegend)}
className="w-full px-4 py-2 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-750 text-xs text-gray-600 dark:text-gray-400 flex items-center justify-center gap-1"
>
<Info className="w-3 h-3" />
<span>File Status Guide</span>
{showLegend ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{showLegend && (
<div className="px-4 py-3 bg-gray-50 dark:bg-gray-800 text-xs">
<div className={`${isMobile ? 'grid grid-cols-2 gap-3 justify-items-center' : 'flex justify-center gap-6'}`}>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 rounded border border-yellow-200 dark:border-yellow-800 font-bold text-xs">
M
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Modified</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 rounded border border-green-200 dark:border-green-800 font-bold text-xs">
A
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Added</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 rounded border border-red-200 dark:border-red-800 font-bold text-xs">
D
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Deleted</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center justify-center w-5 h-5 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 font-bold text-xs">
U
</span>
<span className="text-gray-600 dark:text-gray-400 italic">Untracked</span>
</div>
</div>
</div>
)}
</div>
{/* File List - Changes View */}
{activeView === 'changes' && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
@@ -690,8 +712,8 @@ function GitPanel({ selectedProject, isMobile }) {
</div> </div>
)} )}
{/* History View */} {/* History View - Only show when git is available */}
{activeView === 'history' && ( {activeView === 'history' && !gitStatus?.error && (
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}> <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">

View File

@@ -5,13 +5,35 @@ import { transcribeWithWhisper } from '../utils/whisper';
export function MicButton({ onTranscript, className = '' }) { export function MicButton({ onTranscript, className = '' }) {
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [isSupported, setIsSupported] = useState(true);
const mediaRecorderRef = useRef(null); const mediaRecorderRef = useRef(null);
const streamRef = useRef(null); const streamRef = useRef(null);
const chunksRef = useRef([]); const chunksRef = useRef([]);
const lastTapRef = useRef(0); const lastTapRef = useRef(0);
// Version indicator to verify updates // Check microphone support on mount
useEffect(() => {
const checkSupport = () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
setIsSupported(false);
setError('Microphone not supported. Please use HTTPS or a modern browser.');
return;
}
// Additional check for secure context
if (location.protocol !== 'https:' && location.hostname !== 'localhost') {
setIsSupported(false);
setError('Microphone requires HTTPS. Please use a secure connection.');
return;
}
setIsSupported(true);
setError(null);
};
checkSupport();
}, []);
// Start recording // Start recording
const startRecording = async () => { const startRecording = async () => {
@@ -20,6 +42,11 @@ export function MicButton({ onTranscript, className = '' }) {
setError(null); setError(null);
chunksRef.current = []; chunksRef.current = [];
// Check if getUserMedia is available
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Microphone access not available. Please use HTTPS or a supported browser.');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream; streamRef.current = stream;
@@ -79,7 +106,23 @@ export function MicButton({ onTranscript, className = '' }) {
console.log('Recording started successfully'); console.log('Recording started successfully');
} catch (err) { } catch (err) {
console.error('Failed to start recording:', err); console.error('Failed to start recording:', err);
setError('Microphone access denied');
// Provide specific error messages based on error type
let errorMessage = 'Microphone access failed';
if (err.name === 'NotAllowedError') {
errorMessage = 'Microphone access denied. Please allow microphone permissions.';
} else if (err.name === 'NotFoundError') {
errorMessage = 'No microphone found. Please check your audio devices.';
} else if (err.name === 'NotSupportedError') {
errorMessage = 'Microphone not supported by this browser.';
} else if (err.name === 'NotReadableError') {
errorMessage = 'Microphone is being used by another application.';
} else if (err.message.includes('HTTPS')) {
errorMessage = err.message;
}
setError(errorMessage);
setState('idle'); setState('idle');
} }
}; };
@@ -109,6 +152,11 @@ export function MicButton({ onTranscript, className = '' }) {
e.stopPropagation(); e.stopPropagation();
} }
// Don't proceed if microphone is not supported
if (!isSupported) {
return;
}
// Debounce for mobile double-tap issue // Debounce for mobile double-tap issue
const now = Date.now(); const now = Date.now();
if (now - lastTapRef.current < 300) { if (now - lastTapRef.current < 300) {
@@ -138,6 +186,14 @@ export function MicButton({ onTranscript, className = '' }) {
// Button appearance based on state // Button appearance based on state
const getButtonAppearance = () => { const getButtonAppearance = () => {
if (!isSupported) {
return {
icon: <Mic className="w-5 h-5" />,
className: 'bg-gray-400 cursor-not-allowed',
disabled: true
};
}
switch (state) { switch (state) {
case 'recording': case 'recording':
return { return {

View File

@@ -142,8 +142,8 @@ const QuickSettingsPanel = ({
</label> </label>
</div> </div>
{/* Whisper Dictation Settings */} {/* Whisper Dictation Settings - HIDDEN */}
<div className="space-y-2"> <div className="space-y-2" style={{ display: 'none' }}>
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4> <h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Whisper Dictation</h4>
<div className="space-y-2"> <div className="space-y-2">