mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-30 21:37:35 +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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user