mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-28 04:17:32 +00:00
Update package dependencies, add Git API routes, and implement audio transcription functionality. Introduce new components for Git management, enhance chat interface with microphone support, and improve UI elements for better user experience.
This commit is contained in:
57
src/App.jsx
57
src/App.jsx
@@ -53,6 +53,10 @@ function AppContent() {
|
||||
const saved = localStorage.getItem('showRawParameters');
|
||||
return saved !== null ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [autoScrollToBottom, setAutoScrollToBottom] = useState(() => {
|
||||
const saved = localStorage.getItem('autoScrollToBottom');
|
||||
return saved !== null ? JSON.parse(saved) : true;
|
||||
});
|
||||
// Session Protection System: Track sessions with active conversations to prevent
|
||||
// automatic project updates from interrupting ongoing chats. When a user sends
|
||||
// a message, the session is marked as "active" and project updates are paused
|
||||
@@ -217,13 +221,18 @@ function AppContent() {
|
||||
// Handle URL-based session loading
|
||||
useEffect(() => {
|
||||
if (sessionId && projects.length > 0) {
|
||||
// Only switch tabs on initial load, not on every project update
|
||||
const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId;
|
||||
// Find the session across all projects
|
||||
for (const project of projects) {
|
||||
const session = project.sessions?.find(s => s.id === sessionId);
|
||||
if (session) {
|
||||
setSelectedProject(project);
|
||||
setSelectedSession(session);
|
||||
setActiveTab('chat');
|
||||
// Only switch to chat tab if we're loading a different session
|
||||
if (shouldSwitchTab) {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -245,7 +254,11 @@ function AppContent() {
|
||||
|
||||
const handleSessionSelect = (session) => {
|
||||
setSelectedSession(session);
|
||||
setActiveTab('chat');
|
||||
// Only switch to chat tab when user explicitly selects a session
|
||||
// This prevents tab switching during automatic updates
|
||||
if (activeTab !== 'git' && activeTab !== 'preview') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
@@ -482,23 +495,29 @@ function AppContent() {
|
||||
isInputFocused={isInputFocused}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick Settings Panel */}
|
||||
<QuickSettingsPanel
|
||||
isOpen={showQuickSettings}
|
||||
onToggle={setShowQuickSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
onAutoExpandChange={(value) => {
|
||||
setAutoExpandTools(value);
|
||||
localStorage.setItem('autoExpandTools', JSON.stringify(value));
|
||||
}}
|
||||
showRawParameters={showRawParameters}
|
||||
onShowRawParametersChange={(value) => {
|
||||
setShowRawParameters(value);
|
||||
localStorage.setItem('showRawParameters', JSON.stringify(value));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{/* Quick Settings Panel - Only show on chat tab */}
|
||||
{activeTab === 'chat' && (
|
||||
<QuickSettingsPanel
|
||||
isOpen={showQuickSettings}
|
||||
onToggle={setShowQuickSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
onAutoExpandChange={(value) => {
|
||||
setAutoExpandTools(value);
|
||||
localStorage.setItem('autoExpandTools', JSON.stringify(value));
|
||||
}}
|
||||
showRawParameters={showRawParameters}
|
||||
onShowRawParametersChange={(value) => {
|
||||
setShowRawParameters(value);
|
||||
localStorage.setItem('showRawParameters', JSON.stringify(value));
|
||||
}}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
onAutoScrollChange={(value) => {
|
||||
setAutoScrollToBottom(value);
|
||||
localStorage.setItem('autoScrollToBottom', JSON.stringify(value));
|
||||
}}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tools Settings Modal */}
|
||||
<ToolsSettings
|
||||
|
||||
@@ -19,16 +19,18 @@
|
||||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import TodoList from './TodoList';
|
||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
prevMessage.type === 'assistant' &&
|
||||
!prevMessage.isToolUse && !message.isToolUse;
|
||||
|
||||
const messageRef = React.useRef(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
||||
|
||||
@@ -89,8 +91,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
!
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-gray-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
C
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
||||
<ClaudeLogo className="w-full h-full" />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -195,7 +197,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div> {showRawParameters && (
|
||||
</div>
|
||||
{showRawParameters && (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
@@ -205,7 +208,6 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
@@ -225,6 +227,101 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
);
|
||||
})()}
|
||||
{message.toolInput && message.toolName !== 'Edit' && (() => {
|
||||
// Debug log to see what we're dealing with
|
||||
console.log('Tool display - name:', message.toolName, 'input type:', typeof message.toolInput);
|
||||
|
||||
// Special handling for Write tool
|
||||
if (message.toolName === 'Write') {
|
||||
console.log('Write tool detected, toolInput:', message.toolInput);
|
||||
try {
|
||||
let input;
|
||||
// Handle both JSON string and already parsed object
|
||||
if (typeof message.toolInput === 'string') {
|
||||
input = JSON.parse(message.toolInput);
|
||||
} else {
|
||||
input = message.toolInput;
|
||||
}
|
||||
|
||||
console.log('Parsed Write input:', input);
|
||||
|
||||
if (input.file_path && input.content !== undefined) {
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
📄 Creating new file:
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content
|
||||
});
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||||
>
|
||||
{input.file_path.split('/').pop()}
|
||||
</button>
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(input.file_path, {
|
||||
old_string: '',
|
||||
new_string: input.content
|
||||
})}
|
||||
className="text-xs font-mono text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 truncate underline cursor-pointer"
|
||||
>
|
||||
{input.file_path}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
New File
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono">
|
||||
{createDiff('', input.content).map((diffLine, i) => (
|
||||
<div key={i} className="flex">
|
||||
<span className={`w-8 text-center border-r ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{diffLine.type === 'removed' ? '-' : '+'}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
||||
diffLine.type === 'removed'
|
||||
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
||||
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
||||
}`}>
|
||||
{diffLine.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{showRawParameters && (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for TodoWrite tool
|
||||
if (message.toolName === 'TodoWrite') {
|
||||
try {
|
||||
@@ -251,7 +348,100 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</details>
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for Bash tool
|
||||
if (message.toolName === 'Bash') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
Running command
|
||||
</summary>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="bg-gray-900 dark:bg-gray-950 text-gray-100 rounded-lg p-3 font-mono text-sm">
|
||||
<div className="flex items-center gap-2 mb-2 text-gray-400">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-xs">Terminal</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-all text-green-400">
|
||||
$ {input.command}
|
||||
</div>
|
||||
</div>
|
||||
{input.description && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
||||
{input.description}
|
||||
</div>
|
||||
)}
|
||||
{showRawParameters && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
} catch (e) {
|
||||
// Fall back to regular display
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for Read tool
|
||||
if (message.toolName === 'Read') {
|
||||
try {
|
||||
const input = JSON.parse(message.toolInput);
|
||||
if (input.file_path) {
|
||||
// Extract filename
|
||||
const filename = input.file_path.split('/').pop();
|
||||
const pathParts = input.file_path.split('/');
|
||||
const directoryPath = pathParts.slice(0, -1).join('/');
|
||||
|
||||
// Simple heuristic to show only relevant path parts
|
||||
// Show the last 2-3 directory parts before the filename
|
||||
const relevantParts = pathParts.slice(-4, -1); // Get up to 3 directories before filename
|
||||
const relativePath = relevantParts.length > 0 ? relevantParts.join('/') + '/' : '';
|
||||
|
||||
return (
|
||||
<details className="mt-2" open={autoExpandTools}>
|
||||
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200 flex items-center gap-1">
|
||||
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className="text-gray-600 dark:text-gray-400 font-mono text-xs">{relativePath}</span>
|
||||
<span className="font-semibold text-blue-700 dark:text-blue-300 font-mono">{filename}</span>
|
||||
</summary>
|
||||
{showRawParameters && (
|
||||
<div className="mt-3">
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
||||
View raw parameters
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-blue-100 dark:bg-blue-800/30 p-2 rounded whitespace-pre-wrap break-words overflow-hidden text-blue-900 dark:text-blue-100">
|
||||
{message.toolInput}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -345,6 +535,106 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
// Fall through to regular handling
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for interactive prompts
|
||||
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
|
||||
const lines = content.split('\n');
|
||||
const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?'));
|
||||
const beforePrompt = lines.slice(0, promptIndex).join('\n');
|
||||
const promptLines = lines.slice(promptIndex);
|
||||
|
||||
// Extract the question and options
|
||||
const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || '';
|
||||
const options = [];
|
||||
|
||||
// Parse numbered options (1. Yes, 2. No, etc.)
|
||||
promptLines.forEach(line => {
|
||||
const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
|
||||
if (optionMatch) {
|
||||
options.push({
|
||||
number: optionMatch[1],
|
||||
text: optionMatch[2].trim()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Find which option was selected (usually indicated by "> 1" or similar)
|
||||
const selectedMatch = content.match(/>\s*(\d+)/);
|
||||
const selectedOption = selectedMatch ? selectedMatch[1] : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{beforePrompt && (
|
||||
<div className="bg-gray-900 dark:bg-gray-950 text-gray-100 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap break-words">{beforePrompt}</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-2">
|
||||
Interactive Prompt
|
||||
</h4>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||
{questionLine}
|
||||
</p>
|
||||
|
||||
{/* Option buttons */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.number}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
selectedOption === option.number
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700 hover:border-amber-400 dark:hover:border-amber-600 hover:shadow-sm'
|
||||
} ${
|
||||
selectedOption ? 'cursor-default' : 'cursor-not-allowed opacity-75'
|
||||
}`}
|
||||
disabled
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
selectedOption === option.number
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
{option.number}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium flex-1">
|
||||
{option.text}
|
||||
</span>
|
||||
{selectedOption === option.number && (
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedOption && (
|
||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||
✓ Claude selected option {selectedOption}
|
||||
</p>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
||||
In the CLI, you would select this option interactively using arrow keys or by typing the number.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const fileEditMatch = content.match(/The file (.+?) has been updated\./);
|
||||
if (fileEditMatch) {
|
||||
@@ -363,6 +653,43 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Write tool output for file creation
|
||||
const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/);
|
||||
if (fileCreateMatch) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium">File created successfully</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onFileOpen && onFileOpen(fileCreateMatch[1])}
|
||||
className="text-xs font-mono bg-green-100 dark:bg-green-800/30 px-2 py-1 rounded text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline cursor-pointer"
|
||||
>
|
||||
{fileCreateMatch[1]}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Special handling for Write tool - hide content if it's just the file content
|
||||
if (message.toolName === 'Write' && !message.toolResult.isError) {
|
||||
// For Write tool, the diff is already shown in the tool input section
|
||||
// So we just show a success message here
|
||||
return (
|
||||
<div className="text-green-700 dark:text-green-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="font-medium">File written successfully</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-green-600 dark:text-green-400">
|
||||
The file content is displayed in the diff view above
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (content.includes('cat -n') && content.includes('→')) {
|
||||
return (
|
||||
<details open={autoExpandTools}>
|
||||
@@ -407,17 +734,100 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : message.isInteractivePrompt ? (
|
||||
// Special handling for interactive prompts
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
|
||||
Interactive Prompt
|
||||
</h4>
|
||||
{(() => {
|
||||
const lines = message.content.split('\n').filter(line => line.trim());
|
||||
const questionLine = lines.find(line => line.includes('?')) || lines[0] || '';
|
||||
const options = [];
|
||||
|
||||
// Parse the menu options
|
||||
lines.forEach(line => {
|
||||
// Match lines like "❯ 1. Yes" or " 2. No"
|
||||
const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/);
|
||||
if (optionMatch) {
|
||||
const isSelected = line.includes('❯');
|
||||
options.push({
|
||||
number: optionMatch[1],
|
||||
text: optionMatch[2].trim(),
|
||||
isSelected
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
||||
{questionLine}
|
||||
</p>
|
||||
|
||||
{/* Option buttons */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.number}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
||||
option.isSelected
|
||||
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
||||
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
||||
} cursor-not-allowed opacity-75`}
|
||||
disabled
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
||||
option.isSelected
|
||||
? 'bg-white/20'
|
||||
: 'bg-amber-100 dark:bg-amber-800/50'
|
||||
}`}>
|
||||
{option.number}
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-medium flex-1">
|
||||
{option.text}
|
||||
</span>
|
||||
{option.isSelected && (
|
||||
<span className="text-lg">❯</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
||||
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
||||
⏳ Waiting for your response in the CLI
|
||||
</p>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
||||
Please select an option in your terminal where Claude is running.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{message.type === 'assistant' ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert prose-gray [&_code]:!bg-transparent [&_code]:!p-0">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
code: ({node, inline, className, children, ...props}) => {
|
||||
return inline ? (
|
||||
<code className="bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-1 py-0.5 rounded text-sm font-mono" {...props}>
|
||||
<strong className="text-blue-600 dark:text-blue-400 font-bold not-prose" {...props}>
|
||||
{children}
|
||||
</code>
|
||||
</strong>
|
||||
) : (
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-3 rounded-lg overflow-hidden my-2">
|
||||
<code className="text-gray-800 dark:text-gray-200 text-sm font-mono block whitespace-pre-wrap break-words" {...props}>
|
||||
@@ -454,7 +864,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 mt-1 ${isGrouped ? 'opacity-0 group-hover:opacity-100 transition-opacity' : ''}`}>
|
||||
<div className={`text-xs text-gray-500 dark:text-gray-400 mt-1 ${isGrouped ? 'opacity-0 group-hover:opacity-100' : ''}`}>
|
||||
{new Date(message.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -472,7 +882,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom }) {
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return localStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
@@ -504,6 +914,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
||||
const [canAbortSession, setCanAbortSession] = useState(false);
|
||||
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
||||
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState([]);
|
||||
const [filteredCommands, setFilteredCommands] = useState([]);
|
||||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||||
const [slashPosition, setSlashPosition] = useState(-1);
|
||||
const [claudeStatus, setClaudeStatus] = useState(null);
|
||||
|
||||
|
||||
// Memoized diff calculation to prevent recalculating on every render
|
||||
@@ -718,8 +1136,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id);
|
||||
setSessionMessages(messages);
|
||||
// convertedMessages will be automatically updated via useMemo
|
||||
// Scroll to bottom after loading session messages
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
// Scroll to bottom after loading session messages if auto-scroll is enabled
|
||||
if (autoScrollToBottom) {
|
||||
setTimeout(() => scrollToBottom(), 200);
|
||||
}
|
||||
} else {
|
||||
// Reset the flag after handling system session change
|
||||
setIsSystemSessionChange(false);
|
||||
@@ -920,7 +1340,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'claude-interactive-prompt':
|
||||
// Handle interactive prompts from CLI
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'assistant',
|
||||
content: latestMessage.data,
|
||||
timestamp: new Date(),
|
||||
isInteractivePrompt: true
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'claude-error':
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
@@ -932,6 +1361,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
case 'claude-complete':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
|
||||
|
||||
// Session Protection: Mark session as inactive to re-enable automatic project updates
|
||||
// Conversation is complete, safe to allow project updates again
|
||||
@@ -952,6 +1383,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
case 'session-aborted':
|
||||
setIsLoading(false);
|
||||
setCanAbortSession(false);
|
||||
setClaudeStatus(null);
|
||||
|
||||
// Session Protection: Mark session as inactive when aborted
|
||||
// User or system aborted the conversation, re-enable project updates
|
||||
@@ -960,13 +1392,52 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
}
|
||||
|
||||
setChatMessages(prev => [...prev, {
|
||||
type: 'error',
|
||||
content: latestMessage.success ?
|
||||
'Session aborted successfully' :
|
||||
'Failed to abort session - it may have already completed',
|
||||
type: 'assistant',
|
||||
content: 'Session interrupted by user.',
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
break;
|
||||
|
||||
case 'claude-status':
|
||||
// Handle Claude working status messages
|
||||
console.log('🔔 Received claude-status message:', latestMessage);
|
||||
const statusData = latestMessage.data;
|
||||
if (statusData) {
|
||||
// Parse the status message to extract relevant information
|
||||
let statusInfo = {
|
||||
text: 'Working...',
|
||||
tokens: 0,
|
||||
can_interrupt: true
|
||||
};
|
||||
|
||||
// Check for different status message formats
|
||||
if (statusData.message) {
|
||||
statusInfo.text = statusData.message;
|
||||
} else if (statusData.status) {
|
||||
statusInfo.text = statusData.status;
|
||||
} else if (typeof statusData === 'string') {
|
||||
statusInfo.text = statusData;
|
||||
}
|
||||
|
||||
// Extract token count
|
||||
if (statusData.tokens) {
|
||||
statusInfo.tokens = statusData.tokens;
|
||||
} else if (statusData.token_count) {
|
||||
statusInfo.tokens = statusData.token_count;
|
||||
}
|
||||
|
||||
// Check if can interrupt
|
||||
if (statusData.can_interrupt !== undefined) {
|
||||
statusInfo.can_interrupt = statusData.can_interrupt;
|
||||
}
|
||||
|
||||
console.log('📊 Setting claude status:', statusInfo);
|
||||
setClaudeStatus(statusInfo);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(statusInfo.can_interrupt);
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
}, [messages]);
|
||||
@@ -1057,21 +1528,48 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
return chatMessages.slice(-maxMessages);
|
||||
}, [chatMessages]);
|
||||
|
||||
// Capture scroll position before render when auto-scroll is disabled
|
||||
useEffect(() => {
|
||||
// Only auto-scroll to bottom when new messages arrive if user hasn't scrolled up
|
||||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||||
const container = scrollContainerRef.current;
|
||||
scrollPositionRef.current = {
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-scroll to bottom when new messages arrive if:
|
||||
// 1. Auto-scroll is enabled in settings
|
||||
// 2. User hasn't manually scrolled up
|
||||
if (scrollContainerRef.current && chatMessages.length > 0) {
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 0);
|
||||
if (autoScrollToBottom) {
|
||||
if (!isUserScrolledUp) {
|
||||
setTimeout(() => scrollToBottom(), 0);
|
||||
}
|
||||
} else {
|
||||
// When auto-scroll is disabled, preserve the visual position
|
||||
const container = scrollContainerRef.current;
|
||||
const prevHeight = scrollPositionRef.current.height;
|
||||
const prevTop = scrollPositionRef.current.top;
|
||||
const newHeight = container.scrollHeight;
|
||||
const heightDiff = newHeight - prevHeight;
|
||||
|
||||
// If content was added above the current view, adjust scroll position
|
||||
if (heightDiff > 0 && prevTop > 0) {
|
||||
container.scrollTop = prevTop + heightDiff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [chatMessages.length, isUserScrolledUp, scrollToBottom]);
|
||||
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
|
||||
|
||||
// Scroll to bottom when component mounts with existing messages
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && chatMessages.length > 0) {
|
||||
if (scrollContainerRef.current && chatMessages.length > 0 && autoScrollToBottom) {
|
||||
setTimeout(() => scrollToBottom(), 100); // Small delay to ensure rendering
|
||||
}
|
||||
}, [scrollToBottom]);
|
||||
}, [scrollToBottom, autoScrollToBottom]);
|
||||
|
||||
// Add scroll event listener to detect user scrolling
|
||||
useEffect(() => {
|
||||
@@ -1087,9 +1585,36 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
|
||||
// Check if initially expanded
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(isExpanded);
|
||||
}
|
||||
}, []); // Only run once on mount
|
||||
|
||||
const handleTranscript = useCallback((text) => {
|
||||
if (text.trim()) {
|
||||
setInput(prevInput => {
|
||||
const newInput = prevInput.trim() ? `${prevInput} ${text}` : text;
|
||||
|
||||
// Update textarea height after setting new content
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
||||
|
||||
// Check if expanded after transcript
|
||||
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
||||
const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(isExpanded);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return newInput;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
@@ -1104,6 +1629,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setChatMessages(prev => [...prev, userMessage]);
|
||||
setIsLoading(true);
|
||||
setCanAbortSession(true);
|
||||
// Set a default status when starting
|
||||
setClaudeStatus({
|
||||
text: 'Processing',
|
||||
tokens: 0,
|
||||
can_interrupt: true
|
||||
});
|
||||
|
||||
// Always scroll to bottom when user sends a message (they're actively participating)
|
||||
setTimeout(() => scrollToBottom(), 0);
|
||||
@@ -1151,6 +1682,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
setInput('');
|
||||
setIsTextareaExpanded(false);
|
||||
// Clear the saved draft since message was sent
|
||||
if (selectedProject) {
|
||||
localStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||||
@@ -1245,7 +1777,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
setCanAbortSession(false);
|
||||
};
|
||||
|
||||
// Abort functionality is not yet implemented at the backend
|
||||
const handleAbortSession = () => {
|
||||
if (currentSessionId && canAbortSession) {
|
||||
sendMessage({
|
||||
type: 'abort-session',
|
||||
sessionId: currentSessionId
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if no project is selected
|
||||
if (!selectedProject) {
|
||||
@@ -1281,11 +1820,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
</div>
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
|
||||
<p>Start a conversation with Claude</p>
|
||||
<p className="text-sm mt-2">
|
||||
Ask questions about your code, request changes, or get help with development tasks
|
||||
</p>
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
|
||||
<p className="font-bold text-lg sm:text-xl mb-3">Start a conversation with Claude</p>
|
||||
<p className="text-sm sm:text-base leading-relaxed">
|
||||
Ask questions about your code, request changes, or get help with development tasks
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1312,7 +1853,6 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -1347,7 +1887,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
{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 z-10"
|
||||
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">
|
||||
@@ -1361,8 +1901,15 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<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'
|
||||
}`}>
|
||||
{/* Claude Working Status - positioned above the input form */}
|
||||
<ClaudeStatus
|
||||
status={claudeStatus}
|
||||
isLoading={isLoading}
|
||||
onAbort={handleAbortSession}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-transparent">
|
||||
<div className={`relative bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-600 focus-within:ring-2 focus-within:ring-blue-500 dark:focus-within:ring-blue-500 focus-within:border-blue-500 transition-all duration-200 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
@@ -1376,13 +1923,67 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
setCursorPosition(e.target.selectionStart);
|
||||
|
||||
// Check if textarea is expanded (more than 2 lines worth of height)
|
||||
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
||||
const isExpanded = e.target.scrollHeight > lineHeight * 2;
|
||||
setIsTextareaExpanded(isExpanded);
|
||||
}}
|
||||
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
className="w-full px-4 sm:px-6 py-3 sm:py-4 pr-12 sm:pr-16 bg-transparent rounded-2xl focus:outline-none dark:text-white placeholder-gray-500 dark:placeholder-gray-400 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-32 overflow-y-auto text-sm sm:text-base"
|
||||
className="chat-input-placeholder w-full px-4 sm:px-6 py-3 sm:py-4 pr-28 sm:pr-40 bg-transparent rounded-2xl focus:outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 disabled:opacity-50 resize-none min-h-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
{/* Clear button - shown when there's text */}
|
||||
{input.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setInput('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
setIsTextareaExpanded(false);
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setInput('');
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
setIsTextareaExpanded(false);
|
||||
}}
|
||||
className="absolute -left-0.5 -top-3 sm:right-28 sm:left-auto sm:top-1/2 sm:-translate-y-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group z-10 shadow-sm"
|
||||
title="Clear input"
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3 sm:w-4 sm:h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Mic button */}
|
||||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2">
|
||||
<MicButton
|
||||
onTranscript={handleTranscript}
|
||||
className="w-10 h-10 sm:w-10 sm:h-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Send button */}
|
||||
<button
|
||||
type="submit"
|
||||
@@ -1395,7 +1996,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-12 h-12 sm:w-12 sm:h-12 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-full flex items-center justify-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:ring-offset-gray-800"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5 text-white transform rotate-90"
|
||||
|
||||
11
src/components/ClaudeLogo.jsx
Executable file
11
src/components/ClaudeLogo.jsx
Executable file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
const ClaudeLogo = ({className = 'w-5 h-5'}) => {
|
||||
return (
|
||||
<img src="/icons/claude-ai-icon.svg" alt="Claude" className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
export default ClaudeLogo;
|
||||
|
||||
|
||||
107
src/components/ClaudeStatus.jsx
Executable file
107
src/components/ClaudeStatus.jsx
Executable file
@@ -0,0 +1,107 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
function ClaudeStatus({ status, onAbort, isLoading }) {
|
||||
const [elapsedTime, setElapsedTime] = useState(0);
|
||||
const [animationPhase, setAnimationPhase] = useState(0);
|
||||
const [fakeTokens, setFakeTokens] = useState(0);
|
||||
|
||||
// Update elapsed time every second
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setElapsedTime(0);
|
||||
setFakeTokens(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
setElapsedTime(elapsed);
|
||||
// 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]);
|
||||
|
||||
if (!isLoading) return null;
|
||||
|
||||
// Clever action words that cycle
|
||||
const actionWords = ['Thinking', 'Processing', 'Analyzing', 'Working', 'Computing', 'Reasoning'];
|
||||
const actionIndex = Math.floor(elapsedTime / 3) % actionWords.length;
|
||||
|
||||
// Parse status data
|
||||
const statusText = status?.text || actionWords[actionIndex];
|
||||
const tokens = status?.tokens || fakeTokens;
|
||||
const canInterrupt = status?.can_interrupt !== false;
|
||||
|
||||
// Animation characters
|
||||
const spinners = ['✻', '✹', '✸', '✶'];
|
||||
const currentSpinner = spinners[animationPhase];
|
||||
|
||||
return (
|
||||
<div className="w-full mb-6 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between max-w-4xl mx-auto bg-gray-900 dark:bg-gray-950 text-white rounded-lg shadow-lg px-4 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Animated spinner */}
|
||||
<span className={cn(
|
||||
"text-xl transition-all duration-500",
|
||||
animationPhase % 2 === 0 ? "text-blue-400 scale-110" : "text-blue-300"
|
||||
)}>
|
||||
{currentSpinner}
|
||||
</span>
|
||||
|
||||
{/* Status text - first line */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{statusText}...</span>
|
||||
<span className="text-gray-400 text-sm">({elapsedTime}s)</span>
|
||||
{tokens > 0 && (
|
||||
<>
|
||||
<span className="text-gray-400">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">⚒ {tokens.toLocaleString()} tokens</span>
|
||||
<span className="text-gray-300 text-sm sm:hidden">⚒ {tokens.toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-400 hidden sm:inline">·</span>
|
||||
<span className="text-gray-300 text-sm hidden sm:inline">esc to interrupt</span>
|
||||
</div>
|
||||
{/* Second line for mobile */}
|
||||
<div className="text-xs text-gray-400 sm:hidden mt-1">
|
||||
esc to interrupt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interrupt button */}
|
||||
{canInterrupt && onAbort && (
|
||||
<button
|
||||
onClick={onAbort}
|
||||
className="ml-3 text-xs bg-red-600 hover:bg-red-700 text-white px-2.5 py-1 sm:px-3 sm:py-1.5 rounded-md transition-colors flex items-center gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ClaudeStatus;
|
||||
777
src/components/GitPanel.jsx
Executable file
777
src/components/GitPanel.jsx
Executable file
@@ -0,0 +1,777 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
const [gitDiff, setGitDiff] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [expandedFiles, setExpandedFiles] = useState(new Set());
|
||||
const [selectedFiles, setSelectedFiles] = useState(new Set());
|
||||
const [isCommitting, setIsCommitting] = useState(false);
|
||||
const [currentBranch, setCurrentBranch] = useState('');
|
||||
const [branches, setBranches] = useState([]);
|
||||
const [wrapText, setWrapText] = useState(true);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const [showBranchDropdown, setShowBranchDropdown] = useState(false);
|
||||
const [showNewBranchModal, setShowNewBranchModal] = useState(false);
|
||||
const [newBranchName, setNewBranchName] = useState('');
|
||||
const [isCreatingBranch, setIsCreatingBranch] = useState(false);
|
||||
const [activeView, setActiveView] = useState('changes'); // 'changes' or 'history'
|
||||
const [recentCommits, setRecentCommits] = useState([]);
|
||||
const [expandedCommits, setExpandedCommits] = useState(new Set());
|
||||
const [commitDiffs, setCommitDiffs] = useState({});
|
||||
const [isGeneratingMessage, setIsGeneratingMessage] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
if (activeView === 'history') {
|
||||
fetchRecentCommits();
|
||||
}
|
||||
}
|
||||
}, [selectedProject, activeView]);
|
||||
|
||||
// Handle click outside dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setShowBranchDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const fetchGitStatus = async () => {
|
||||
if (!selectedProject) return;
|
||||
|
||||
console.log('Fetching git status for project:', selectedProject.name, 'path:', selectedProject.path);
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/git/status?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
console.log('Git status response:', data);
|
||||
|
||||
if (data.error) {
|
||||
console.error('Git status error:', data.error);
|
||||
setGitStatus(null);
|
||||
} else {
|
||||
setGitStatus(data);
|
||||
setCurrentBranch(data.branch || 'main');
|
||||
|
||||
// Auto-select all changed files
|
||||
const allFiles = new Set([
|
||||
...(data.modified || []),
|
||||
...(data.added || []),
|
||||
...(data.deleted || []),
|
||||
...(data.untracked || [])
|
||||
]);
|
||||
setSelectedFiles(allFiles);
|
||||
|
||||
// Fetch diffs for changed files
|
||||
for (const file of data.modified || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
for (const file of data.added || []) {
|
||||
fetchFileDiff(file);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching git status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBranches = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/branches?project=${encodeURIComponent(selectedProject.name)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.branches) {
|
||||
setBranches(data.branches);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching branches:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const switchBranch = async (branchName) => {
|
||||
try {
|
||||
const response = await fetch('/api/git/checkout', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
branch: branchName
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCurrentBranch(branchName);
|
||||
setShowBranchDropdown(false);
|
||||
fetchGitStatus(); // Refresh status after branch switch
|
||||
} else {
|
||||
console.error('Failed to switch branch:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error switching branch:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createBranch = async () => {
|
||||
if (!newBranchName.trim()) return;
|
||||
|
||||
setIsCreatingBranch(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/create-branch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
branch: newBranchName.trim()
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setCurrentBranch(newBranchName.trim());
|
||||
setShowNewBranchModal(false);
|
||||
setShowBranchDropdown(false);
|
||||
setNewBranchName('');
|
||||
fetchBranches(); // Refresh branch list
|
||||
fetchGitStatus(); // Refresh status
|
||||
} else {
|
||||
console.error('Failed to create branch:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating branch:', error);
|
||||
} finally {
|
||||
setIsCreatingBranch(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFileDiff = async (filePath) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/diff?project=${encodeURIComponent(selectedProject.name)}&file=${encodeURIComponent(filePath)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
setGitDiff(prev => ({
|
||||
...prev,
|
||||
[filePath]: data.diff
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file diff:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecentCommits = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commits?project=${encodeURIComponent(selectedProject.name)}&limit=10`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.commits) {
|
||||
setRecentCommits(data.commits);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching commits:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCommitDiff = async (commitHash) => {
|
||||
try {
|
||||
const response = await fetch(`/api/git/commit-diff?project=${encodeURIComponent(selectedProject.name)}&commit=${commitHash}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error && data.diff) {
|
||||
setCommitDiffs(prev => ({
|
||||
...prev,
|
||||
[commitHash]: data.diff
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching commit diff:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const generateCommitMessage = async () => {
|
||||
setIsGeneratingMessage(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/generate-commit-message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
files: Array.from(selectedFiles)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.message) {
|
||||
setCommitMessage(data.message);
|
||||
} else {
|
||||
console.error('Failed to generate commit message:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating commit message:', error);
|
||||
} finally {
|
||||
setIsGeneratingMessage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFileExpanded = (filePath) => {
|
||||
setExpandedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(filePath)) {
|
||||
newSet.delete(filePath);
|
||||
} else {
|
||||
newSet.add(filePath);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCommitExpanded = (commitHash) => {
|
||||
setExpandedCommits(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(commitHash)) {
|
||||
newSet.delete(commitHash);
|
||||
} else {
|
||||
newSet.add(commitHash);
|
||||
// Fetch diff for this commit if not already fetched
|
||||
if (!commitDiffs[commitHash]) {
|
||||
fetchCommitDiff(commitHash);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFileSelected = (filePath) => {
|
||||
setSelectedFiles(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(filePath)) {
|
||||
newSet.delete(filePath);
|
||||
} else {
|
||||
newSet.add(filePath);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (!commitMessage.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
setIsCommitting(true);
|
||||
try {
|
||||
const response = await fetch('/api/git/commit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project: selectedProject.name,
|
||||
message: commitMessage,
|
||||
files: Array.from(selectedFiles)
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Reset state after successful commit
|
||||
setCommitMessage('');
|
||||
setSelectedFiles(new Set());
|
||||
fetchGitStatus();
|
||||
} else {
|
||||
console.error('Commit failed:', data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error committing changes:', error);
|
||||
} finally {
|
||||
setIsCommitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
case 'M': return 'Modified';
|
||||
case 'A': return 'Added';
|
||||
case 'D': return 'Deleted';
|
||||
case 'U': return 'Untracked';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const renderCommitItem = (commit) => {
|
||||
const isExpanded = expandedCommits.has(commit.hash);
|
||||
const diff = commitDiffs[commit.hash];
|
||||
|
||||
return (
|
||||
<div key={commit.hash} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div
|
||||
className="flex items-start p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer"
|
||||
onClick={() => toggleCommitExpanded(commit.hash)}
|
||||
>
|
||||
<div className="mr-2 mt-1 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{commit.message}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{commit.author} • {commit.date}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-gray-400 dark:text-gray-500 flex-shrink-0">
|
||||
{commit.hash.substring(0, 7)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && diff && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||
{commit.stats}
|
||||
</div>
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileItem = (filePath, status) => {
|
||||
const isExpanded = expandedFiles.has(filePath);
|
||||
const isSelected = selectedFiles.has(filePath);
|
||||
const diff = gitDiff[filePath];
|
||||
|
||||
return (
|
||||
<div key={filePath} className="border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div className="flex items-center px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => toggleFileSelected(filePath)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mr-2 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center flex-1 cursor-pointer"
|
||||
onClick={() => toggleFileExpanded(filePath)}
|
||||
>
|
||||
<div className="mr-2 p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
|
||||
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
</div>
|
||||
<span className="flex-1 text-sm truncate">{filePath}</span>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-bold border ${
|
||||
status === 'M' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800' :
|
||||
status === 'A' ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300 border-green-200 dark:border-green-800' :
|
||||
status === 'D' ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300 border-red-200 dark:border-red-800' :
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
title={getStatusLabel(status)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && diff && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900">
|
||||
{isMobile && (
|
||||
<div className="flex justify-end p-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setWrapText(!wrapText);
|
||||
}}
|
||||
className="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
title={wrapText ? "Switch to horizontal scroll" : "Switch to text wrap"}
|
||||
>
|
||||
{wrapText ? '↔️ Scroll' : '↩️ Wrap'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!selectedProject) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<p>Select a project to view source control</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowBranchDropdown(!showBranchDropdown)}
|
||||
className="flex items-center space-x-2 px-3 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 text-gray-600 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium">{currentBranch}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-gray-500 transition-transform ${showBranchDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Branch Dropdown */}
|
||||
{showBranchDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-64 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-50">
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{branches.map(branch => (
|
||||
<button
|
||||
key={branch}
|
||||
onClick={() => switchBranch(branch)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
|
||||
branch === currentBranch ? 'bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{branch === currentBranch && <Check className="w-3 h-3 text-green-600 dark:text-green-400" />}
|
||||
<span className={branch === currentBranch ? 'font-medium' : ''}>{branch}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewBranchModal(true);
|
||||
setShowBranchDropdown(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center space-x-2"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Create new branch</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchGitStatus();
|
||||
fetchBranches();
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveView('changes')}
|
||||
className={`flex-1 px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeView === 'changes'
|
||||
? '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">
|
||||
<FileText className="w-4 h-4" />
|
||||
<span>Changes</span>
|
||||
</div>
|
||||
</button>
|
||||
<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 */}
|
||||
<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>
|
||||
<MicButton
|
||||
onTranscript={(transcript) => setCommitMessage(transcript)}
|
||||
mode="default"
|
||||
className="p-1.5"
|
||||
/>
|
||||
</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 */}
|
||||
{activeView === 'changes' && gitStatus && (
|
||||
<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' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : !gitStatus || (!gitStatus.modified?.length && !gitStatus.added?.length && !gitStatus.deleted?.length && !gitStatus.untracked?.length) ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<GitCommit className="w-12 h-12 mb-2 opacity-50" />
|
||||
<p className="text-sm">No changes detected</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isMobile ? 'pb-4' : ''}>
|
||||
{gitStatus.modified?.map(file => renderFileItem(file, 'M'))}
|
||||
{gitStatus.added?.map(file => renderFileItem(file, 'A'))}
|
||||
{gitStatus.deleted?.map(file => renderFileItem(file, 'D'))}
|
||||
{gitStatus.untracked?.map(file => renderFileItem(file, 'U'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History View */}
|
||||
{activeView === 'history' && (
|
||||
<div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20' : ''}`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : recentCommits.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||
<History className="w-12 h-12 mb-2 opacity-50" />
|
||||
<p className="text-sm">No commits found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={isMobile ? 'pb-4' : ''}>
|
||||
{recentCommits.map(commit => renderCommitItem(commit))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Branch Modal */}
|
||||
{showNewBranchModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowNewBranchModal(false)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full">
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Create New Branch</h3>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Branch Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isCreatingBranch) {
|
||||
createBranch();
|
||||
}
|
||||
}}
|
||||
placeholder="feature/new-feature"
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
This will create a new branch from the current branch ({currentBranch})
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowNewBranchModal(false);
|
||||
setNewBranchName('');
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={createBranch}
|
||||
disabled={!newBranchName.trim() || isCreatingBranch}
|
||||
className="px-4 py-2 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-2"
|
||||
>
|
||||
{isCreatingBranch ? (
|
||||
<>
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
<span>Creating...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Create Branch</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GitPanel;
|
||||
@@ -11,11 +11,12 @@
|
||||
* No session protection logic is implemented here - it's purely a props bridge.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import Shell from './Shell';
|
||||
import GitPanel from './GitPanel';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
@@ -37,7 +38,8 @@ function MainContent({
|
||||
onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround)
|
||||
onShowSettings, // Show tools settings panel
|
||||
autoExpandTools, // Auto-expand tool accordions
|
||||
showRawParameters // Show raw parameters in tool accordions
|
||||
showRawParameters, // Show raw parameters in tool accordions
|
||||
autoScrollToBottom // Auto-scroll to bottom when new messages arrive
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
|
||||
@@ -73,8 +75,15 @@ function MainContent({
|
||||
)}
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4">
|
||||
<div className="w-16 h-16 animate-spin rounded-full border-4 border-gray-300 dark:border-gray-600 border-t-blue-600" />
|
||||
<div className="w-12 h-12 mx-auto mb-4">
|
||||
<div
|
||||
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500"
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
WebkitAnimation: 'spin 1s linear infinite',
|
||||
MozAnimation: 'spin 1s linear infinite'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Loading Claude Code UI</h2>
|
||||
<p>Setting up your workspace...</p>
|
||||
@@ -135,7 +144,7 @@ function MainContent({
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 transition-transform duration-75"
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -164,7 +173,7 @@ function MainContent({
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Project Files
|
||||
{activeTab === 'files' ? 'Project Files' : activeTab === 'git' ? 'Source Control' : 'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
@@ -179,7 +188,7 @@ function MainContent({
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
@@ -222,6 +231,36 @@ function MainContent({
|
||||
<span className="hidden sm:inline">Files</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Source Control</span>
|
||||
</span>
|
||||
</button>
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Preview</span>
|
||||
</span>
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +284,7 @@ function MainContent({
|
||||
onShowSettings={onShowSettings}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'files' ? 'block' : 'hidden'}`}>
|
||||
@@ -258,6 +297,35 @@ function MainContent({
|
||||
isActive={activeTab === 'shell'}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
|
||||
{/* <LivePreviewPanel
|
||||
selectedProject={selectedProject}
|
||||
serverStatus={serverStatus}
|
||||
serverUrl={serverUrl}
|
||||
availableScripts={availableScripts}
|
||||
onStartServer={(script) => {
|
||||
sendMessage({
|
||||
type: 'server:start',
|
||||
projectPath: selectedProject?.fullPath,
|
||||
script: script
|
||||
});
|
||||
}}
|
||||
onStopServer={() => {
|
||||
sendMessage({
|
||||
type: 'server:stop',
|
||||
projectPath: selectedProject?.fullPath
|
||||
});
|
||||
}}
|
||||
onScriptSelect={setCurrentScript}
|
||||
currentScript={currentScript}
|
||||
isMobile={isMobile}
|
||||
serverLogs={serverLogs}
|
||||
onClearLogs={() => setServerLogs([])}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Editor Modal */}
|
||||
|
||||
217
src/components/MicButton.jsx
Executable file
217
src/components/MicButton.jsx
Executable file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Mic, Loader2, Brain } from 'lucide-react';
|
||||
import { transcribeWithWhisper } from '../utils/whisper';
|
||||
|
||||
export function MicButton({ onTranscript, className = '' }) {
|
||||
const [state, setState] = useState('idle'); // idle, recording, transcribing, processing
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
const lastTapRef = useRef(0);
|
||||
|
||||
// Version indicator to verify updates
|
||||
console.log('MicButton v2.0 loaded');
|
||||
|
||||
// Start recording
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
console.log('Starting recording...');
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : 'audio/mp4';
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = async () => {
|
||||
console.log('Recording stopped, creating blob...');
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
|
||||
// Clean up stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Start transcribing
|
||||
setState('transcribing');
|
||||
|
||||
// Check if we're in an enhancement mode
|
||||
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
|
||||
const isEnhancementMode = whisperMode === 'prompt' || whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect';
|
||||
|
||||
// Set up a timer to switch to processing state for enhancement modes
|
||||
let processingTimer;
|
||||
if (isEnhancementMode) {
|
||||
processingTimer = setTimeout(() => {
|
||||
setState('processing');
|
||||
}, 2000); // Switch to processing after 2 seconds
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await transcribeWithWhisper(blob);
|
||||
if (text && onTranscript) {
|
||||
onTranscript(text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Transcription error:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
if (processingTimer) {
|
||||
clearTimeout(processingTimer);
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
recorder.start();
|
||||
setState('recording');
|
||||
console.log('Recording started successfully');
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
setError('Microphone access denied');
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Stop recording
|
||||
const stopRecording = () => {
|
||||
console.log('Stopping recording...');
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
// Don't set state here - let the onstop handler do it
|
||||
} else {
|
||||
// If recorder isn't in recording state, force cleanup
|
||||
console.log('Recorder not in recording state, forcing cleanup');
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setState('idle');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle button click
|
||||
const handleClick = (e) => {
|
||||
// Prevent double firing on mobile
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Debounce for mobile double-tap issue
|
||||
const now = Date.now();
|
||||
if (now - lastTapRef.current < 300) {
|
||||
console.log('Ignoring rapid tap');
|
||||
return;
|
||||
}
|
||||
lastTapRef.current = now;
|
||||
|
||||
console.log('Button clicked, current state:', state);
|
||||
|
||||
if (state === 'idle') {
|
||||
startRecording();
|
||||
} else if (state === 'recording') {
|
||||
stopRecording();
|
||||
}
|
||||
// Do nothing if transcribing or processing
|
||||
};
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Button appearance based on state
|
||||
const getButtonAppearance = () => {
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5 text-white" />,
|
||||
className: 'bg-red-500 hover:bg-red-600 animate-pulse',
|
||||
disabled: false
|
||||
};
|
||||
case 'transcribing':
|
||||
return {
|
||||
icon: <Loader2 className="w-5 h-5 animate-spin" />,
|
||||
className: 'bg-blue-500 hover:bg-blue-600',
|
||||
disabled: true
|
||||
};
|
||||
case 'processing':
|
||||
return {
|
||||
icon: <Brain className="w-5 h-5 animate-pulse" />,
|
||||
className: 'bg-purple-500 hover:bg-purple-600',
|
||||
disabled: true
|
||||
};
|
||||
default: // idle
|
||||
return {
|
||||
icon: <Mic className="w-5 h-5" />,
|
||||
className: 'bg-gray-700 hover:bg-gray-600',
|
||||
disabled: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, className: buttonClass, disabled } = getButtonAppearance();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
backgroundColor: state === 'recording' ? '#ef4444' :
|
||||
state === 'transcribing' ? '#3b82f6' :
|
||||
state === 'processing' ? '#a855f7' :
|
||||
'#374151'
|
||||
}}
|
||||
className={`
|
||||
flex items-center justify-center
|
||||
w-12 h-12 rounded-full
|
||||
text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500
|
||||
dark:ring-offset-gray-800
|
||||
touch-action-manipulation
|
||||
${disabled ? 'cursor-not-allowed opacity-75' : 'cursor-pointer'}
|
||||
${state === 'recording' ? 'animate-pulse' : ''}
|
||||
hover:opacity-90
|
||||
${className}
|
||||
`}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
{error && (
|
||||
<div className="absolute top-full mt-2 left-1/2 transform -translate-x-1/2
|
||||
bg-red-500 text-white text-xs px-2 py-1 rounded whitespace-nowrap z-10
|
||||
animate-fade-in">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'recording' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-red-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
|
||||
{state === 'processing' && (
|
||||
<div className="absolute -inset-1 rounded-full border-2 border-purple-500 animate-ping pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Folder, Terminal } from 'lucide-react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe } from 'lucide-react';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
// Detect dark mode
|
||||
@@ -19,6 +19,11 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
id: 'files',
|
||||
icon: Folder,
|
||||
onClick: () => setActiveTab('files')
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
onClick: () => setActiveTab('git')
|
||||
}
|
||||
];
|
||||
|
||||
@@ -52,7 +57,7 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
e.preventDefault();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`flex items-center justify-center p-2 rounded-lg transition-colors duration-75 min-h-[40px] min-w-[40px] relative touch-manipulation ${
|
||||
className={`flex items-center justify-center p-2 rounded-lg min-h-[40px] min-w-[40px] relative touch-manipulation ${
|
||||
isActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
|
||||
@@ -4,14 +4,14 @@ import {
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Zap,
|
||||
Layout,
|
||||
Terminal,
|
||||
Code2,
|
||||
Settings2,
|
||||
Moon,
|
||||
Sun
|
||||
Sun,
|
||||
ArrowDown,
|
||||
Mic,
|
||||
Brain,
|
||||
Sparkles,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import DarkModeToggle from './DarkModeToggle';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
@@ -23,9 +23,14 @@ const QuickSettingsPanel = ({
|
||||
onAutoExpandChange,
|
||||
showRawParameters,
|
||||
onShowRawParametersChange,
|
||||
autoScrollToBottom,
|
||||
onAutoScrollChange,
|
||||
isMobile
|
||||
}) => {
|
||||
const [localIsOpen, setLocalIsOpen] = useState(isOpen);
|
||||
const [whisperMode, setWhisperMode] = useState(() => {
|
||||
return localStorage.getItem('whisperMode') || 'default';
|
||||
});
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +49,7 @@ const QuickSettingsPanel = ({
|
||||
<div
|
||||
className={`fixed ${isMobile ? 'bottom-44' : 'top-1/2 -translate-y-1/2'} ${
|
||||
localIsOpen ? 'right-64' : 'right-0'
|
||||
} z-40 transition-all duration-300 ease-in-out`}
|
||||
} z-50 transition-all duration-150 ease-out`}
|
||||
>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
@@ -61,9 +66,9 @@ const QuickSettingsPanel = ({
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-300 ease-in-out z-30 ${
|
||||
className={`fixed top-0 right-0 h-full w-64 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 shadow-xl transform transition-transform duration-150 ease-out z-40 ${
|
||||
localIsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
} ${isMobile ? 'h-screen' : ''}`}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header */}
|
||||
@@ -75,12 +80,12 @@ const QuickSettingsPanel = ({
|
||||
</div>
|
||||
|
||||
{/* Settings Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6 bg-white dark:bg-gray-800">
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden p-4 space-y-6 bg-white dark:bg-gray-900 ${isMobile ? 'pb-20' : ''}`}>
|
||||
{/* Appearance Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Appearance</h4>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
{isDarkMode ? <Moon className="h-4 w-4 text-gray-600 dark:text-gray-400" /> : <Sun className="h-4 w-4 text-gray-600 dark:text-gray-400" />}
|
||||
Dark Mode
|
||||
@@ -93,7 +98,7 @@ const QuickSettingsPanel = ({
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Tool Display</h4>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Maximize2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-expand tools
|
||||
@@ -102,11 +107,11 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={autoExpandTools}
|
||||
onChange={(e) => onAutoExpandChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 accent-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<Eye className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Show raw parameters
|
||||
@@ -115,60 +120,114 @@ const QuickSettingsPanel = ({
|
||||
type="checkbox"
|
||||
checked={showRawParameters}
|
||||
onChange={(e) => onShowRawParametersChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 accent-blue-600"
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button disabled className="w-full flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-left text-gray-500 dark:text-gray-400 transition-colors opacity-60 cursor-not-allowed">
|
||||
<Code2 className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Syntax highlighting
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Performance Settings */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Performance</h4>
|
||||
|
||||
<button disabled className="w-full flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-left text-gray-500 dark:text-gray-400 transition-colors opacity-60 cursor-not-allowed">
|
||||
<Zap className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Stream optimizations
|
||||
</button>
|
||||
|
||||
<button disabled className="w-full flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-left text-gray-500 dark:text-gray-400 transition-colors opacity-60 cursor-not-allowed">
|
||||
<Layout className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Compact mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Options */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">View Options</h4>
|
||||
|
||||
<button disabled className="w-full flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-left text-gray-500 dark:text-gray-400 transition-colors opacity-60 cursor-not-allowed">
|
||||
<Terminal className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Terminal theme
|
||||
</button>
|
||||
|
||||
<button disabled className="w-full flex items-center gap-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 text-sm text-left text-gray-500 dark:text-gray-400 transition-colors opacity-60 cursor-not-allowed">
|
||||
<EyeOff className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Hide timestamps
|
||||
</button>
|
||||
<label className="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<span className="flex items-center gap-2 text-sm text-gray-900 dark:text-white">
|
||||
<ArrowDown className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Auto-scroll to bottom
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoScrollToBottom}
|
||||
onChange={(e) => onAutoScrollChange(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
<button disabled className="w-full py-2 px-4 bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded-md transition-colors text-sm cursor-not-allowed opacity-60">
|
||||
Advanced Settings
|
||||
</button>
|
||||
{/* Whisper Dictation Settings */}
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="default"
|
||||
checked={whisperMode === 'default'}
|
||||
onChange={() => {
|
||||
setWhisperMode('default');
|
||||
localStorage.setItem('whisperMode', 'default');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Mic className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Default Mode
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct transcription of your speech
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="prompt"
|
||||
checked={whisperMode === 'prompt'}
|
||||
onChange={() => {
|
||||
setWhisperMode('prompt');
|
||||
localStorage.setItem('whisperMode', 'prompt');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<Sparkles className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Prompt Enhancement
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Transform rough ideas into clear, detailed AI prompts
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-start p-3 rounded-lg bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer transition-colors border border-transparent hover:border-gray-300 dark:hover:border-gray-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="whisperMode"
|
||||
value="vibe"
|
||||
checked={whisperMode === 'vibe' || whisperMode === 'instructions' || whisperMode === 'architect'}
|
||||
onChange={() => {
|
||||
setWhisperMode('vibe');
|
||||
localStorage.setItem('whisperMode', 'vibe');
|
||||
window.dispatchEvent(new Event('whisperModeChanged'));
|
||||
}}
|
||||
className="mt-0.5 h-4 w-4 border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-500 focus:ring-blue-500 dark:focus:ring-blue-400 dark:bg-gray-800 dark:checked:bg-blue-600"
|
||||
/>
|
||||
<div className="ml-3 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
<FileText className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||
Vibe Mode
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Format ideas as clear agent instructions with details
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && localIsOpen && (
|
||||
{/* Backdrop */}
|
||||
{localIsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-20"
|
||||
className="fixed inset-0 bg-background/80 backdrop-blur-sm z-30 transition-opacity duration-150 ease-out"
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -493,11 +493,11 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
|
||||
{/* Connect button when not connected */}
|
||||
{isInitialized && !isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-center">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<button
|
||||
onClick={connectToShell}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center space-x-2 text-base font-medium"
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center space-x-2 text-base font-medium w-full sm:w-auto"
|
||||
title="Connect to shell"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -505,7 +505,7 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
</svg>
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3">
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{selectedSession ?
|
||||
`Resume session: ${selectedSession.summary.slice(0, 50)}...` :
|
||||
'Start a new Claude session'
|
||||
@@ -517,13 +517,13 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
|
||||
{/* Connecting state */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center space-x-3 text-yellow-400">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-90 p-4">
|
||||
<div className="text-center max-w-sm w-full">
|
||||
<div className="flex items-center justify-center space-x-3 text-yellow-400">
|
||||
<div className="w-6 h-6 animate-spin rounded-full border-2 border-yellow-400 border-t-transparent"></div>
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3">
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
Starting Claude CLI in {selectedProject.displayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Input } from './ui/input';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw } from 'lucide-react';
|
||||
import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime) => {
|
||||
@@ -56,6 +57,9 @@ function Sidebar({
|
||||
const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
|
||||
const [currentTime, setCurrentTime] = useState(new Date());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [editingSession, setEditingSession] = useState(null);
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
|
||||
// Touch handler to prevent double-tap issues on iPad
|
||||
const handleTouchClick = (callback) => {
|
||||
@@ -601,7 +605,7 @@ function Sidebar({
|
||||
<>
|
||||
{getAllSessions(project).length === 0 && (
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 transition-all duration-150 border border-red-200 dark:border-red-800"
|
||||
className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteProject(project.name);
|
||||
@@ -612,7 +616,7 @@ function Sidebar({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 transition-all duration-150 border border-primary/20 dark:border-primary/30"
|
||||
className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditing(project);
|
||||
@@ -639,7 +643,7 @@ function Sidebar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50 transition-colors duration-200",
|
||||
"hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50",
|
||||
isSelected && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -781,14 +785,27 @@ function Sidebar({
|
||||
<p className="text-xs text-muted-foreground">No sessions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
getAllSessions(project).map((session) => (
|
||||
getAllSessions(project).map((session) => {
|
||||
// Calculate if session is active (within last 10 minutes)
|
||||
const sessionDate = new Date(session.lastActivity);
|
||||
const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
|
||||
const isActive = diffInMinutes < 10;
|
||||
|
||||
return (
|
||||
<div key={session.id} className="group relative">
|
||||
{/* Active session indicator dot */}
|
||||
{isActive && (
|
||||
<div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
{/* Mobile Session Item */}
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"p-2 mx-3 my-0.5 rounded-md bg-card border border-border/30 active:scale-[0.98] transition-all duration-150 relative",
|
||||
selectedSession?.id === session.id && "bg-primary/5 border-primary/20"
|
||||
"p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative",
|
||||
selectedSession?.id === session.id ? "bg-primary/5 border-primary/20" :
|
||||
isActive ? "border-green-500/30 bg-green-50/5 dark:bg-green-900/5" : "border-border/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
@@ -871,22 +888,99 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
{/* Desktop delete button */}
|
||||
<button
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center touch:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
}}
|
||||
title="Delete session (Delete)"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
{/* Desktop hover buttons */}
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
|
||||
{editingSession === session.id ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSessionName}
|
||||
onChange={(e) => setEditingSessionName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
updateSessionSummary(project.name, session.id, editingSessionName);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
className="w-6 h-6 bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateSessionSummary(project.name, session.id, editingSessionName);
|
||||
}}
|
||||
title="Save"
|
||||
>
|
||||
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(null);
|
||||
setEditingSessionName('');
|
||||
}}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Generate summary button */}
|
||||
{/* <button
|
||||
className="w-6 h-6 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
generateSessionSummary(project.name, session.id);
|
||||
}}
|
||||
title="Generate AI summary for this session"
|
||||
disabled={generatingSummary[`${project.name}-${session.id}`]}
|
||||
>
|
||||
{generatingSummary[`${project.name}-${session.id}`] ? (
|
||||
<div className="w-3 h-3 animate-spin rounded-full border border-blue-600 dark:border-blue-400 border-t-transparent" />
|
||||
) : (
|
||||
<Sparkles className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</button> */}
|
||||
{/* Edit button */}
|
||||
<button
|
||||
className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingSession(session.id);
|
||||
setEditingSessionName(session.summary || 'New Session');
|
||||
}}
|
||||
title="Manually edit session name"
|
||||
>
|
||||
<Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
{/* Delete button */}
|
||||
<button
|
||||
className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(project.name, session.id);
|
||||
}}
|
||||
title="Delete this session permanently"
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
|
||||
{/* Show More Sessions Button */}
|
||||
{getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
|
||||
<Button
|
||||
|
||||
109
src/hooks/useAudioRecorder.js
Executable file
109
src/hooks/useAudioRecorder.js
Executable file
@@ -0,0 +1,109 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export function useAudioRecorder() {
|
||||
const [isRecording, setRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const chunksRef = useRef([]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setAudioBlob(null);
|
||||
chunksRef.current = [];
|
||||
|
||||
// Request microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
sampleRate: 16000,
|
||||
}
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
// Determine supported MIME type
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/webm')
|
||||
? 'audio/webm'
|
||||
: 'audio/mp4';
|
||||
|
||||
// Create media recorder
|
||||
const recorder = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = recorder;
|
||||
|
||||
// Set up event handlers
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) {
|
||||
chunksRef.current.push(e.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
// Create blob from chunks
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
setAudioBlob(blob);
|
||||
|
||||
// Clean up stream
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
console.error('MediaRecorder error:', event);
|
||||
setError('Recording failed');
|
||||
setRecording(false);
|
||||
};
|
||||
|
||||
// Start recording
|
||||
recorder.start();
|
||||
setRecording(true);
|
||||
console.log('Recording started');
|
||||
} catch (err) {
|
||||
console.error('Failed to start recording:', err);
|
||||
setError(err.message || 'Failed to start recording');
|
||||
setRecording(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
console.log('Stop called, recorder state:', mediaRecorderRef.current?.state);
|
||||
|
||||
try {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
console.log('Recording stopped');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error stopping recorder:', err);
|
||||
}
|
||||
|
||||
// Always update state
|
||||
setRecording(false);
|
||||
|
||||
// Clean up stream if still active
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAudioBlob(null);
|
||||
setError(null);
|
||||
chunksRef.current = [];
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isRecording,
|
||||
audioBlob,
|
||||
error,
|
||||
start,
|
||||
stop,
|
||||
reset
|
||||
};
|
||||
}
|
||||
485
src/index.css
485
src/index.css
@@ -2,6 +2,25 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Global spinner animation - defined early to ensure it loads */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
@@ -53,6 +72,7 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
box-sizing: border-box;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -67,9 +87,106 @@
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Global transition defaults */
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
[role="button"],
|
||||
.transition-all {
|
||||
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Color transitions for theme switching */
|
||||
* {
|
||||
transition: background-color 200ms ease-in-out,
|
||||
border-color 200ms ease-in-out,
|
||||
color 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Transform transitions */
|
||||
.transition-transform {
|
||||
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Opacity transitions */
|
||||
.transition-opacity {
|
||||
transition: opacity 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Scale transitions for interactions */
|
||||
.transition-scale {
|
||||
transition: transform 100ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Shadow transitions */
|
||||
.transition-shadow {
|
||||
transition: box-shadow 200ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Respect reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Smooth hover transitions for interactive elements */
|
||||
button:hover,
|
||||
a:hover,
|
||||
[role="button"]:hover {
|
||||
transition-duration: 100ms;
|
||||
}
|
||||
|
||||
/* Active state transitions */
|
||||
button:active,
|
||||
a:active,
|
||||
[role="button"]:active {
|
||||
transition-duration: 50ms;
|
||||
}
|
||||
|
||||
/* Focus transitions */
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible {
|
||||
transition: outline-offset 150ms ease-out, box-shadow 150ms ease-out;
|
||||
}
|
||||
|
||||
/* Sidebar transitions */
|
||||
.sidebar-transition {
|
||||
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 300ms ease-in-out;
|
||||
}
|
||||
|
||||
/* Modal and dropdown transitions */
|
||||
.modal-transition {
|
||||
transition: opacity 200ms ease-in-out,
|
||||
transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Chat message transitions */
|
||||
.message-transition {
|
||||
transition: opacity 300ms ease-in-out,
|
||||
transform 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Height transitions for expanding elements */
|
||||
.height-transition {
|
||||
transition: height 200ms ease-in-out,
|
||||
max-height 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground)) transparent;
|
||||
@@ -77,6 +194,7 @@
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
@@ -91,6 +209,222 @@
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.8);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar styles */
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.3);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
/* Global scrollbar styles for main content areas */
|
||||
.dark::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-track {
|
||||
background: rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(107, 114, 128, 0.5);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dark::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(107, 114, 128, 0.7);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar styles */
|
||||
.dark {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(107, 114, 128, 0.5) rgba(31, 41, 55, 0.5);
|
||||
}
|
||||
|
||||
/* Ensure checkbox styling is preserved */
|
||||
input[type="checkbox"] {
|
||||
@apply accent-blue-600;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:focus {
|
||||
opacity: 1;
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Fix checkbox appearance in dark mode */
|
||||
.dark input[type="checkbox"] {
|
||||
background-color: rgb(31 41 55); /* gray-800 */
|
||||
border-color: rgb(75 85 99); /* gray-600 */
|
||||
color: rgb(37 99 235); /* blue-600 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark input[type="checkbox"]:checked {
|
||||
background-color: rgb(37 99 235); /* blue-600 */
|
||||
border-color: rgb(37 99 235); /* blue-600 */
|
||||
}
|
||||
|
||||
.dark input[type="checkbox"]:focus {
|
||||
--tw-ring-color: rgb(59 130 246); /* blue-500 */
|
||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
||||
}
|
||||
|
||||
/* Fix radio button appearance in dark mode */
|
||||
.dark input[type="radio"] {
|
||||
background-color: rgb(31 41 55); /* gray-800 */
|
||||
border-color: rgb(75 85 99); /* gray-600 */
|
||||
color: rgb(37 99 235); /* blue-600 */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.dark input[type="radio"]:checked {
|
||||
background-color: rgb(37 99 235); /* blue-600 */
|
||||
border-color: rgb(37 99 235); /* blue-600 */
|
||||
}
|
||||
|
||||
.dark input[type="radio"]:focus {
|
||||
--tw-ring-color: rgb(59 130 246); /* blue-500 */
|
||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
||||
}
|
||||
|
||||
/* Ensure textarea text is always visible in dark mode */
|
||||
textarea {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.dark textarea {
|
||||
color: rgb(243 244 246) !important; /* gray-100 */
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
caret-color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Fix for focus state in dark mode */
|
||||
.dark textarea:focus {
|
||||
color: rgb(243 244 246) !important;
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Fix for iOS/Safari dark mode textarea issues */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
.dark textarea {
|
||||
background-color: transparent !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
.dark textarea:focus {
|
||||
background-color: transparent !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure parent container doesn't override textarea styles */
|
||||
.dark .bg-gray-800 textarea {
|
||||
color: rgb(243 244 246) !important;
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Fix focus-within container issues in dark mode */
|
||||
.dark .focus-within\:ring-2:focus-within {
|
||||
background-color: rgb(31 41 55) !important; /* gray-800 */
|
||||
}
|
||||
|
||||
/* Ensure textarea remains transparent with visible text */
|
||||
.dark textarea.bg-transparent {
|
||||
background-color: transparent !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
-webkit-text-fill-color: rgb(243 244 246) !important;
|
||||
}
|
||||
|
||||
/* Fix placeholder text color to be properly gray */
|
||||
textarea::placeholder {
|
||||
color: rgb(156 163 175) !important; /* gray-400 */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark textarea::placeholder {
|
||||
color: rgb(75 85 99) !important; /* gray-600 - darker gray */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* More specific selector for chat input textarea */
|
||||
.dark .bg-gray-800 textarea::placeholder,
|
||||
.dark textarea.bg-transparent::placeholder {
|
||||
color: rgb(75 85 99) !important; /* gray-600 - darker gray */
|
||||
opacity: 1 !important;
|
||||
-webkit-text-fill-color: rgb(75 85 99) !important;
|
||||
}
|
||||
|
||||
/* Custom class for chat input placeholder */
|
||||
.chat-input-placeholder::placeholder {
|
||||
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;
|
||||
-webkit-text-fill-color: rgb(75 85 99) !important;
|
||||
}
|
||||
|
||||
/* WebKit specific placeholder styles */
|
||||
textarea::-webkit-input-placeholder {
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark textarea::-webkit-input-placeholder {
|
||||
color: rgb(75 85 99) !important; /* gray-600 - darker gray */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Mozilla specific placeholder styles */
|
||||
textarea::-moz-placeholder {
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark textarea::-moz-placeholder {
|
||||
color: rgb(75 85 99) !important; /* gray-600 - darker gray */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* IE/Edge specific placeholder styles */
|
||||
textarea:-ms-input-placeholder {
|
||||
color: rgb(156 163 175) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.dark textarea:-ms-input-placeholder {
|
||||
color: rgb(75 85 99) !important; /* gray-600 - darker gray */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile optimizations and components */
|
||||
@@ -102,6 +436,12 @@
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Preserve checkbox visibility */
|
||||
input[type="checkbox"] {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"],
|
||||
.clickable,
|
||||
@@ -132,6 +472,12 @@
|
||||
@apply w-full sm:max-w-[95%];
|
||||
}
|
||||
|
||||
/* Session name truncation on mobile */
|
||||
.session-name-mobile {
|
||||
@apply truncate;
|
||||
max-width: calc(100vw - 120px); /* Account for sidebar padding and buttons */
|
||||
}
|
||||
|
||||
/* Enable text selection on mobile for terminal */
|
||||
.xterm,
|
||||
.xterm .xterm-viewport {
|
||||
@@ -182,6 +528,12 @@
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Preserve checkbox visibility on touch devices */
|
||||
input[type="checkbox"] {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Only disable hover states for interactive elements, not containers */
|
||||
button:hover,
|
||||
[role="button"]:hover,
|
||||
@@ -251,4 +603,137 @@
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide markdown backticks in prose content */
|
||||
.prose code::before,
|
||||
.prose code::after {
|
||||
content: "" !important;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Custom spinner animation for mobile compatibility */
|
||||
@layer utilities {
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Force hardware acceleration for smoother animation on mobile */
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Improved textarea styling */
|
||||
.chat-input-placeholder {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-input-placeholder::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
|
||||
.dark .chat-input-placeholder {
|
||||
scrollbar-color: rgba(107, 114, 128, 0.3) transparent;
|
||||
}
|
||||
|
||||
.dark .chat-input-placeholder::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(107, 114, 128, 0.3);
|
||||
}
|
||||
|
||||
.dark .chat-input-placeholder::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(107, 114, 128, 0.5);
|
||||
}
|
||||
|
||||
/* Enhanced box shadow when textarea expands */
|
||||
.chat-input-expanded {
|
||||
box-shadow: 0 -5px 15px -3px rgba(0, 0, 0, 0.1), 0 -4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dark .chat-input-expanded {
|
||||
box-shadow: 0 -5px 15px -3px rgba(0, 0, 0, 0.3), 0 -4px 6px -2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Fix focus ring offset color in dark mode */
|
||||
.dark [class*="ring-offset"] {
|
||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
||||
}
|
||||
|
||||
/* Ensure buttons don't show white backgrounds in dark mode */
|
||||
.dark button:focus {
|
||||
--tw-ring-offset-color: rgb(31 41 55); /* gray-800 */
|
||||
}
|
||||
|
||||
/* Fix mobile select dropdown styling */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
select {
|
||||
font-size: 16px !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Improve select appearance in dark mode */
|
||||
.dark select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239CA3AF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236B7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.5rem center;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* Fix select option text in mobile */
|
||||
select option {
|
||||
font-size: 16px !important;
|
||||
padding: 8px !important;
|
||||
background-color: var(--background) !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.dark select option {
|
||||
background-color: rgb(31 41 55) !important;
|
||||
color: rgb(243 244 246) !important;
|
||||
}
|
||||
}
|
||||
38
src/utils/whisper.js
Executable file
38
src/utils/whisper.js
Executable file
@@ -0,0 +1,38 @@
|
||||
export async function transcribeWithWhisper(audioBlob, onStatusChange) {
|
||||
const formData = new FormData();
|
||||
const fileName = `recording_${Date.now()}.webm`;
|
||||
const file = new File([audioBlob], fileName, { type: audioBlob.type });
|
||||
|
||||
formData.append('audio', file);
|
||||
|
||||
const whisperMode = window.localStorage.getItem('whisperMode') || 'default';
|
||||
formData.append('mode', whisperMode);
|
||||
|
||||
try {
|
||||
// Start with transcribing state
|
||||
if (onStatusChange) {
|
||||
onStatusChange('transcribing');
|
||||
}
|
||||
|
||||
const response = await fetch('/api/transcribe', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error ||
|
||||
`Transcription error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.text || '';
|
||||
} catch (error) {
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
throw new Error('Cannot connect to server. Please ensure the backend is running.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user