9 Commits

11 changed files with 440 additions and 146 deletions

117
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "claude-code-ui", "name": "claude-code-ui",
"version": "1.0.0", "version": "1.1.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-code-ui", "name": "claude-code-ui",
"version": "1.0.0", "version": "1.1.1",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-code": "^1.0.24", "@anthropic-ai/claude-code": "^1.0.24",
@@ -28,7 +28,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"node-fetch": "^3.3.2", "node-fetch": "^2.7.0",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -2618,15 +2618,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
}, },
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/date-fns": { "node_modules/date-fns": {
"version": "2.30.0", "version": "2.30.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
@@ -2962,29 +2953,6 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3041,18 +3009,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/forwarded": { "node_modules/forwarded": {
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -4286,42 +4242,23 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "3.3.2", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": { "dependencies": {
"data-uri-to-buffer": "^4.0.0", "whatwg-url": "^5.0.0"
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
}, },
"engines": { "engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0" "node": "4.x || >=6.0.0"
}, },
"funding": { "peerDependencies": {
"type": "opencollective", "encoding": "^0.1.0"
"url": "https://opencollective.com/node-fetch" },
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
} }
}, },
"node_modules/node-pty": { "node_modules/node-pty": {
@@ -5795,6 +5732,11 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/tree-kill": { "node_modules/tree-kill": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -6094,13 +6036,18 @@
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
}, },
"node_modules/web-streams-polyfill": { "node_modules/webidl-conversions": {
"version": "3.3.3", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
"license": "MIT", },
"engines": { "node_modules/whatwg-url": {
"node": ">= 8" "version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
} }
}, },
"node_modules/which": { "node_modules/which": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "claude-code-ui", "name": "claude-code-ui",
"version": "1.0.0", "version": "1.1.1",
"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": {
@@ -40,7 +40,7 @@
"express": "^4.18.2", "express": "^4.18.2",
"lucide-react": "^0.515.0", "lucide-react": "^0.515.0",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"node-fetch": "^3.3.2", "node-fetch": "^2.7.0",
"node-pty": "^1.0.0", "node-pty": "^1.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@@ -691,7 +691,6 @@ app.post('/api/transcribe', async (req, res) => {
formData.append('language', 'en'); formData.append('language', 'en');
// Make request to OpenAI // Make request to OpenAI
const fetch = require('node-fetch');
const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
method: 'POST', method: 'POST',
headers: { headers: {

View File

@@ -21,9 +21,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 +54,91 @@ async function generateDisplayName(projectName) {
return projectPath; return projectPath;
} }
// Extract the actual project directory from JSONL sessions
async function extractProjectDirectory(projectName) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
const cwdCounts = new Map();
let latestTimestamp = 0;
let latestCwd = null;
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
return projectName.replace(/-/g, '/');
}
// 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
return projectName.replace(/-/g, '/');
}
if (cwdCounts.size === 1) {
// Only one cwd, use it
return Array.from(cwdCounts.keys())[0];
}
// 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) {
return latestCwd;
}
// Otherwise use the most frequently used cwd
for (const [cwd, count] of cwdCounts.entries()) {
if (count === maxCount) {
return cwd;
}
}
// Fallback (shouldn't reach here)
return latestCwd || projectName.replace(/-/g, '/');
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
// Fall back to decoded project name
return projectName.replace(/-/g, '/');
}
}
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 +154,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,13 +193,23 @@ 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;
if (!actualProjectDir) {
try {
actualProjectDir = await extractProjectDirectory(projectName);
} catch (error) {
// Fall back to decoded project name
actualProjectDir = projectName.replace(/-/g, '/');
}
}
const project = { const project = {
name: projectName, name: projectName,
path: null, // No physical path yet path: actualProjectDir,
displayName: projectConfig.displayName || await generateDisplayName(projectName), displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir),
fullPath: fullPath, fullPath: actualProjectDir,
isCustomName: !!projectConfig.displayName, isCustomName: !!projectConfig.displayName,
isManuallyAdded: true, isManuallyAdded: true,
sessions: [] sessions: []
@@ -463,9 +561,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 +581,6 @@ module.exports = {
deleteProject, deleteProject,
addProjectManually, addProjectManually,
loadProjectConfig, loadProjectConfig,
saveProjectConfig saveProjectConfig,
extractProjectDirectory
}; };

View File

@@ -28,6 +28,7 @@ import QuickSettingsPanel from './components/QuickSettingsPanel';
import { useWebSocket } from './utils/websocket'; import { useWebSocket } from './utils/websocket';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { useVersionCheck } from './hooks/useVersionCheck';
// Main App component with routing // Main App component with routing
@@ -35,6 +36,9 @@ function AppContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const { sessionId } = useParams(); const { sessionId } = useParams();
const { updateAvailable, latestVersion, currentVersion } = useVersionCheck('siteboon', 'claudecodeui');
const [showVersionModal, setShowVersionModal] = useState(false);
const [projects, setProjects] = useState([]); const [projects, setProjects] = useState([]);
const [selectedProject, setSelectedProject] = useState(null); const [selectedProject, setSelectedProject] = useState(null);
const [selectedSession, setSelectedSession] = useState(null); const [selectedSession, setSelectedSession] = useState(null);
@@ -399,6 +403,92 @@ function AppContent() {
} }
}; };
// Version Upgrade Modal Component
const VersionUpgradeModal = () => {
if (!showVersionModal) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
onClick={() => setShowVersionModal(false)}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Update Available</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">A new version is ready</p>
</div>
</div>
<button
onClick={() => setShowVersionModal(false)}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Version Info */}
<div className="space-y-3">
<div className="flex justify-between items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Current Version</span>
<span className="text-sm text-gray-900 dark:text-white font-mono">{currentVersion}</span>
</div>
<div className="flex justify-between items-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Latest Version</span>
<span className="text-sm text-blue-900 dark:text-blue-100 font-mono">{latestVersion}</span>
</div>
</div>
{/* Upgrade Instructions */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-gray-900 dark:text-white">How to upgrade:</h3>
<div className="bg-gray-100 dark:bg-gray-800 rounded-lg p-3 border">
<code className="text-sm text-gray-800 dark:text-gray-200 font-mono">
git checkout main && git pull && npm install
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
Run this command in your Claude Code UI directory to update to the latest version.
</p>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<button
onClick={() => setShowVersionModal(false)}
className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
>
Later
</button>
<button
onClick={() => {
// Copy command to clipboard
navigator.clipboard.writeText('git checkout main && git pull && npm install');
setShowVersionModal(false);
}}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
>
Copy Command
</button>
</div>
</div>
</div>
);
};
return ( return (
<div className="fixed inset-0 flex bg-background"> <div className="fixed inset-0 flex bg-background">
{/* Fixed Desktop Sidebar */} {/* Fixed Desktop Sidebar */}
@@ -417,6 +507,10 @@ function AppContent() {
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)} onShowSettings={() => setShowToolsSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
/> />
</div> </div>
</div> </div>
@@ -458,6 +552,10 @@ function AppContent() {
isLoading={isLoadingProjects} isLoading={isLoadingProjects}
onRefresh={handleSidebarRefresh} onRefresh={handleSidebarRefresh}
onShowSettings={() => setShowToolsSettings(true)} onShowSettings={() => setShowToolsSettings(true)}
updateAvailable={updateAvailable}
latestVersion={latestVersion}
currentVersion={currentVersion}
onShowVersionModal={() => setShowVersionModal(true)}
/> />
</div> </div>
</div> </div>
@@ -524,6 +622,9 @@ function AppContent() {
isOpen={showToolsSettings} isOpen={showToolsSettings}
onClose={() => setShowToolsSettings(false)} onClose={() => setShowToolsSettings(false)}
/> />
{/* Version Upgrade Modal */}
<VersionUpgradeModal />
</div> </div>
); );
} }

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,12 +1885,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div>
{/* Floating scroll to bottom button */} {/* Floating scroll to bottom button - positioned outside scrollable container */}
{isUserScrolledUp && chatMessages.length > 0 && ( {isUserScrolledUp && chatMessages.length > 0 && (
<button <button
onClick={scrollToBottom} 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" 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" title="Scroll to bottom"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -1895,7 +1899,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
</svg> </svg>
</button> </button>
)} )}
</div>
{/* 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 ${
@@ -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

@@ -568,6 +568,7 @@ function GitPanel({ selectedProject, isMobile }) {
<Sparkles className="w-4 h-4" /> <Sparkles className="w-4 h-4" />
)} )}
</button> </button>
<div style={{ display: 'none' }}>
<MicButton <MicButton
onTranscript={(transcript) => setCommitMessage(transcript)} onTranscript={(transcript) => setCommitMessage(transcript)}
mode="default" mode="default"
@@ -575,6 +576,7 @@ function GitPanel({ selectedProject, isMobile }) {
/> />
</div> </div>
</div> </div>
</div>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
{selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected {selectedFiles.size} file{selectedFiles.size !== 1 ? 's' : ''} selected

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">

View File

@@ -44,7 +44,11 @@ function Sidebar({
onProjectDelete, onProjectDelete,
isLoading, isLoading,
onRefresh, onRefresh,
onShowSettings onShowSettings,
updateAvailable,
latestVersion,
currentVersion,
onShowVersionModal
}) { }) {
const [expandedProjects, setExpandedProjects] = useState(new Set()); const [expandedProjects, setExpandedProjects] = useState(new Set());
const [editingProject, setEditingProject] = useState(null); const [editingProject, setEditingProject] = useState(null);
@@ -1036,6 +1040,50 @@ function Sidebar({
</div> </div>
</ScrollArea> </ScrollArea>
{/* Version Update Notification */}
{updateAvailable && (
<div className="md:p-2 border-t border-border/50 flex-shrink-0">
{/* Desktop Version Notification */}
<div className="hidden md:block">
<Button
variant="ghost"
className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2"
onClick={onShowVersionModal}
>
<div className="relative">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
</div>
</Button>
</div>
{/* Mobile Version Notification */}
<div className="md:hidden p-3 pb-2">
<button
className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
onClick={onShowVersionModal}
>
<div className="relative">
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
</svg>
<div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
</div>
<div className="min-w-0 flex-1 text-left">
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
<div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
</div>
</button>
</div>
</div>
)}
{/* Settings Section */} {/* Settings Section */}
<div className="md:p-2 md:border-t md:border-border flex-shrink-0"> <div className="md:p-2 md:border-t md:border-border flex-shrink-0">
{/* Mobile Settings */} {/* Mobile Settings */}

View File

@@ -0,0 +1,39 @@
// hooks/useVersionCheck.js
import { useState, useEffect } from 'react';
import { version } from '../../package.json';
export const useVersionCheck = (owner, repo) => {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [latestVersion, setLatestVersion] = useState(null);
useEffect(() => {
const checkVersion = async () => {
try {
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
const data = await response.json();
// Handle the case where there might not be any releases
if (data.tag_name) {
const latest = data.tag_name.replace(/^v/, '');
setLatestVersion(latest);
setUpdateAvailable(version !== latest);
} else {
// No releases found, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
}
} catch (error) {
console.error('Version check failed:', error);
// On error, don't show update notification
setUpdateAvailable(false);
setLatestVersion(null);
}
};
checkVersion();
const interval = setInterval(checkVersion, 5 * 60 * 1000); // Check every 5 minutes
return () => clearInterval(interval);
}, [owner, repo]);
return { updateAvailable, latestVersion, currentVersion: version };
};