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:
Simos
2025-07-04 11:30:14 +02:00
parent 845d5346eb
commit 3b0a612c9c
18 changed files with 3495 additions and 360 deletions

View File

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