Merge branch 'main' into fix/session-streamed-to-another-chat

This commit is contained in:
viper151
2026-01-25 23:15:23 +01:00
committed by GitHub
43 changed files with 4041 additions and 666 deletions

View File

@@ -30,11 +30,13 @@ import CursorLogo from './CursorLogo.jsx';
import CodexLogo from './CodexLogo.jsx';
import NextTaskBanner from './NextTaskBanner.jsx';
import { useTasksSettings } from '../contexts/TasksSettingsContext';
import { useTranslation } from 'react-i18next';
import ClaudeStatus from './ClaudeStatus';
import TokenUsagePie from './TokenUsagePie';
import { MicButton } from './MicButton.jsx';
import { api, authenticatedFetch } from '../utils/api';
import ThinkingModeSelector, { thinkingModes } from './ThinkingModeSelector.jsx';
import Fuse from 'fuse.js';
import CommandMenu from './CommandMenu';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants';
@@ -95,6 +97,10 @@ function unescapeWithMathProtection(text) {
return processedText;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// Small wrapper to keep markdown behavior consistent in one place
const Markdown = ({ children, className }) => {
const content = normalizeInlineCodeFences(String(children ?? ''));
@@ -336,27 +342,27 @@ function grantClaudeToolPermission(entry) {
}
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = {
code: ({ node, inline, className, children, ...props }) => {
const [copied, setCopied] = React.useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line
const CodeBlock = ({ node, inline, className, children, ...props }) => {
const { t } = useTranslation('chat');
const [copied, setCopied] = React.useState(false);
const raw = Array.isArray(children) ? children.join('') : String(children ?? '');
const looksMultiline = /[\r\n]/.test(raw);
const inlineDetected = inline || (node && node.type === 'inlineCode');
const shouldInline = inlineDetected || !looksMultiline; // fallback to inline if single-line
// Inline code rendering
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
className || ''
}`}
{...props}
>
{children}
</code>
);
}
// Inline code rendering
if (shouldInline) {
return (
<code
className={`font-mono text-[0.9em] px-1.5 py-0.5 rounded-md bg-gray-100 text-gray-900 border border-gray-200 dark:bg-gray-800/60 dark:text-gray-100 dark:border-gray-700 whitespace-pre-wrap break-words ${
className || ''
}`}
{...props}
>
{children}
</code>
);
}
// Extract language from className (format: language-xxx)
const match = /language-(\w+)/.exec(className || '');
@@ -411,15 +417,15 @@ const markdownComponents = {
type="button"
onClick={handleCopy}
className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 focus:opacity-100 active:opacity-100 transition-opacity text-xs px-2 py-1 rounded-md bg-gray-700/80 hover:bg-gray-700 text-white border border-gray-600"
title={copied ? 'Copied' : 'Copy code'}
aria-label={copied ? 'Copied' : 'Copy code'}
title={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
aria-label={copied ? t('codeBlock.copied') : t('codeBlock.copyCode')}
>
{copied ? (
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<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>
Copied
{t('codeBlock.copied')}
</span>
) : (
<span className="flex items-center gap-1">
@@ -427,7 +433,7 @@ const markdownComponents = {
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"></path>
</svg>
Copy
{t('codeBlock.copy')}
</span>
)}
</button>
@@ -452,7 +458,11 @@ const markdownComponents = {
</SyntaxHighlighter>
</div>
);
},
};
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
const markdownComponents = {
code: CodeBlock,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
{children}
@@ -485,6 +495,7 @@ const markdownComponents = {
// Memoized message component to prevent unnecessary re-renders
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }) => {
const { t } = useTranslation('chat');
const isGrouped = prevMessage && prevMessage.type === message.type &&
((prevMessage.type === 'assistant') ||
(prevMessage.type === 'user') ||
@@ -587,7 +598,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
</div>
)}
<div className="text-sm font-medium text-gray-900 dark:text-white">
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? 'Codex' : 'Claude')}
{message.type === 'error' ? t('messageTypes.error') : message.type === 'tool' ? t('messageTypes.tool') : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? t('messageTypes.cursor') : (localStorage.getItem('selected-provider') || 'claude') === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude'))}
</div>
</div>
)}
@@ -615,8 +626,8 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
const input = JSON.parse(message.toolInput);
return (
<span className="font-mono truncate flex-1 min-w-0">
{input.pattern && <span>pattern: <span className="text-blue-600 dark:text-blue-400">{input.pattern}</span></span>}
{input.path && <span className="ml-2">in: {input.path}</span>}
{input.pattern && <span>{t('search.pattern')} <span className="text-blue-600 dark:text-blue-400">{input.pattern}</span></span>}
{input.path && <span className="ml-2">{t('search.in')} {input.path}</span>}
</span>
);
} catch (e) {
@@ -629,7 +640,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
href={`#tool-result-${message.toolId}`}
className="flex-shrink-0 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium transition-colors flex items-center gap-1"
>
<span>results</span>
<span>{t('tools.searchResults')}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
@@ -673,7 +684,7 @@ const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFile
onShowSettings();
}}
className="p-2 rounded-lg hover:bg-white/60 dark:hover:bg-gray-800/60 transition-all duration-200 group/btn backdrop-blur-sm"
title="Tool Settings"
title={t('tools.settings')}
>
<svg className="w-4 h-4 text-gray-600 dark:text-gray-400 group-hover/btn:text-blue-600 dark:group-hover/btn:text-blue-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
@@ -1851,6 +1862,7 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const { t } = useTranslation('chat');
const [input, setInput] = useState(() => {
if (typeof window !== 'undefined' && selectedProject) {
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
@@ -1886,6 +1898,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const inputContainerRef = useRef(null);
const inputHighlightRef = useRef(null);
const scrollContainerRef = useRef(null);
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
const isLoadingMoreRef = useRef(false);
@@ -1901,6 +1914,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [debouncedInput, setDebouncedInput] = useState('');
const [showFileDropdown, setShowFileDropdown] = useState(false);
const [fileList, setFileList] = useState([]);
const [fileMentions, setFileMentions] = useState([]);
const [filteredFiles, setFilteredFiles] = useState([]);
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
const [cursorPosition, setCursorPosition] = useState(0);
@@ -1918,6 +1932,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const [slashPosition, setSlashPosition] = useState(-1);
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
const [claudeStatus, setClaudeStatus] = useState(null);
const [thinkingMode, setThinkingMode] = useState('none');
const [provider, setProvider] = useState(() => {
return localStorage.getItem('selected-provider') || 'claude';
});
@@ -4093,6 +4108,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
return result;
};
// Handle @ symbol detection and file filtering
useEffect(() => {
const textBeforeCursor = input.slice(0, cursorPosition);
@@ -4123,6 +4139,43 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}, [input, cursorPosition, fileList]);
const activeFileMentions = useMemo(() => {
if (!input || fileMentions.length === 0) return [];
return fileMentions.filter(path => input.includes(path));
}, [fileMentions, input]);
const sortedFileMentions = useMemo(() => {
if (activeFileMentions.length === 0) return [];
const unique = Array.from(new Set(activeFileMentions));
return unique.sort((a, b) => b.length - a.length);
}, [activeFileMentions]);
const fileMentionRegex = useMemo(() => {
if (sortedFileMentions.length === 0) return null;
const pattern = sortedFileMentions.map(escapeRegExp).join('|');
return new RegExp(`(${pattern})`, 'g');
}, [sortedFileMentions]);
const fileMentionSet = useMemo(() => new Set(sortedFileMentions), [sortedFileMentions]);
const renderInputWithMentions = useCallback((text) => {
if (!text) return '';
if (!fileMentionRegex) return text;
const parts = text.split(fileMentionRegex);
return parts.map((part, index) => (
fileMentionSet.has(part) ? (
<span
key={`mention-${index}`}
className="bg-blue-200/70 -ml-0.5 dark:bg-blue-300/40 px-0.5 rounded-md box-decoration-clone text-transparent"
>
{part}
</span>
) : (
<span key={`text-${index}`}>{part}</span>
)
));
}, [fileMentionRegex, fileMentionSet]);
// Debounced input handling
useEffect(() => {
const timer = setTimeout(() => {
@@ -4357,6 +4410,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
e.preventDefault();
if (!input.trim() || isLoading || !selectedProject) return;
// Apply thinking mode prefix if selected
let messageContent = input;
const selectedThinkingMode = thinkingModes.find(mode => mode.id === thinkingMode);
if (selectedThinkingMode && selectedThinkingMode.prefix) {
messageContent = `${selectedThinkingMode.prefix}: ${input}`;
}
// Upload images first if any
let uploadedImages = [];
if (attachedImages.length > 0) {
@@ -4450,7 +4510,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Cursor command (always use cursor-command; include resume/sessionId when replying)
sendMessage({
type: 'cursor-command',
command: input,
command: messageContent,
sessionId: effectiveSessionId,
options: {
// Prefer fullPath (actual cwd for project), fallback to path
@@ -4467,7 +4527,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Codex command
sendMessage({
type: 'codex-command',
command: input,
command: messageContent,
sessionId: effectiveSessionId,
options: {
cwd: selectedProject.fullPath || selectedProject.path,
@@ -4482,7 +4542,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Send Claude command (existing code)
sendMessage({
type: 'claude-command',
command: input,
command: messageContent,
options: {
projectPath: selectedProject.path,
cwd: selectedProject.fullPath,
@@ -4501,6 +4561,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
setUploadingImages(new Map());
setImageErrors(new Map());
setIsTextareaExpanded(false);
setThinkingMode('none'); // Reset thinking mode after sending
// Reset textarea height
if (textareaRef.current) {
@@ -4511,7 +4572,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
if (selectedProject) {
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
}
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, claudeModel, codexModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom, thinkingMode]);
const handleGrantToolPermission = useCallback((suggestion) => {
if (!suggestion || provider !== 'claude') {
@@ -4702,8 +4763,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
const spaceIndex = textAfterAtQuery.indexOf(' ');
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
const newInput = textBeforeAt + file.path + ' ' + textAfterQuery;
const newCursorPos = textBeforeAt.length + file.path.length + 1;
// Immediately ensure focus is maintained
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
@@ -4713,6 +4774,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
// Update input and cursor position
setInput(newInput);
setCursorPosition(newCursorPos);
setFileMentions(prev => (prev.includes(file.path) ? prev : [...prev, file.path]));
// Hide dropdown
setShowFileDropdown(false);
@@ -4806,6 +4868,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
};
const syncInputOverlayScroll = useCallback((target) => {
if (!inputHighlightRef.current || !target) return;
inputHighlightRef.current.scrollTop = target.scrollTop;
inputHighlightRef.current.scrollLeft = target.scrollLeft;
}, []);
const handleTextareaClick = (e) => {
setCursorPosition(e.target.selectionStart);
};
@@ -4877,16 +4945,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p>Loading session messages...</p>
<p>{t('session.loading.sessionMessages')}</p>
</div>
</div>
) : chatMessages.length === 0 ? (
<div className="flex items-center justify-center h-full">
{!selectedSession && !currentSessionId && (
<div className="text-center px-6 sm:px-4 py-8">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">{t('providerSelection.title')}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Select a provider to start a new conversation
{t('providerSelection.description')}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
@@ -4908,7 +4976,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<ClaudeLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.anthropic')}</p>
</div>
</div>
{provider === 'claude' && (
@@ -4940,7 +5008,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CursorLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.cursorEditor')}</p>
</div>
</div>
{provider === 'cursor' && (
@@ -4972,7 +5040,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<CodexLogo className="w-10 h-10" />
<div>
<p className="font-semibold text-gray-900 dark:text-white">Codex</p>
<p className="text-xs text-gray-500 dark:text-gray-400">by OpenAI</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('providerSelection.providerInfo.openai')}</p>
</div>
</div>
{provider === 'codex' && (
@@ -4990,7 +5058,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Model Selection - Always reserve space to prevent jumping */}
<div className={`mb-6 transition-opacity duration-200 ${provider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Model
{t('providerSelection.selectModel')}
</label>
{provider === 'claude' ? (
<select
@@ -5040,12 +5108,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<p className="text-sm text-gray-500 dark:text-gray-400">
{provider === 'claude'
? `Ready to use Claude with ${claudeModel}. Start typing your message below.`
? t('providerSelection.readyPrompt.claude', { model: claudeModel })
: provider === 'cursor'
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
? t('providerSelection.readyPrompt.cursor', { model: cursorModel })
: provider === 'codex'
? `Ready to use Codex with ${codexModel}. Start typing your message below.`
: 'Select a provider above to begin'
? t('providerSelection.readyPrompt.codex', { model: codexModel })
: t('providerSelection.readyPrompt.default')
}
</p>
@@ -5062,9 +5130,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
)}
{selectedSession && (
<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">Continue your conversation</p>
<p className="font-bold text-lg sm:text-xl mb-3">{t('session.continue.title')}</p>
<p className="text-sm sm:text-base leading-relaxed">
Ask questions about your code, request changes, or get help with development tasks
{t('session.continue.description')}
</p>
{/* Show NextTaskBanner for existing sessions too, only if TaskMaster is installed */}
@@ -5086,7 +5154,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
<div className="flex items-center justify-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
<p className="text-sm">Loading older messages...</p>
<p className="text-sm">{t('session.loading.olderMessages')}</p>
</div>
</div>
)}
@@ -5096,8 +5164,8 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
{totalMessages > 0 && (
<span>
Showing {sessionMessages.length} of {totalMessages} messages
<span className="text-xs">Scroll up to load more</span>
{t('session.messages.showingOf', { shown: sessionMessages.length, total: totalMessages })}
<span className="text-xs">{t('session.messages.scrollToLoad')}</span>
</span>
)}
</div>
@@ -5106,12 +5174,12 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
{/* Legacy message count indicator (for non-paginated view) */}
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
Showing last {visibleMessageCount} messages ({chatMessages.length} total)
<button
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })}
<button
className="ml-1 text-blue-600 hover:text-blue-700 underline"
onClick={loadEarlierMessages}
>
Load earlier messages
{t('session.messages.loadEarlier')}
</button>
</div>
)}
@@ -5296,7 +5364,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-600 hover:bg-orange-100 dark:hover:bg-orange-900/30'
: 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-600 hover:bg-blue-100 dark:hover:bg-blue-900/30'
}`}
title="Click to change permission mode (or press Tab in input)"
title={t('input.clickToChangeMode')}
>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
@@ -5309,13 +5377,24 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
: 'bg-blue-500'
}`} />
<span>
{permissionMode === 'default' && 'Default Mode'}
{permissionMode === 'acceptEdits' && 'Accept Edits'}
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
{permissionMode === 'plan' && 'Plan Mode'}
{permissionMode === 'default' && t('codex.modes.default')}
{permissionMode === 'acceptEdits' && t('codex.modes.acceptEdits')}
{permissionMode === 'bypassPermissions' && t('codex.modes.bypassPermissions')}
{permissionMode === 'plan' && t('codex.modes.plan')}
</span>
</div>
</button>
{/* Thinking Mode Selector */}
{
provider === 'claude' && (
<ThinkingModeSelector
selectedMode={thinkingMode}
onModeChange={setThinkingMode}
className=""
/>
)}
{/* Token usage pie chart - positioned next to mode indicator */}
<TokenUsagePie
used={tokenBudget?.used || 0}
@@ -5341,7 +5420,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
}
}}
className="relative w-8 h-8 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 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"
title="Show all commands"
title={t('input.showAllCommands')}
>
<svg
className="w-5 h-5"
@@ -5506,6 +5585,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
<div {...getRootProps()} 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 overflow-hidden ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
<input {...getInputProps()} />
<div
ref={inputHighlightRef}
aria-hidden="true"
className="absolute inset-0 pointer-events-none overflow-hidden rounded-2xl"
>
<div className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 text-transparent text-sm sm:text-base leading-[21px] sm:leading-6 whitespace-pre-wrap break-words">
{renderInputWithMentions(input)}
</div>
</div>
<div className="relative z-10">
<textarea
ref={textareaRef}
value={input}
@@ -5513,6 +5602,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
onClick={handleTextareaClick}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onScroll={(e) => syncInputOverlayScroll(e.target)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onInput={(e) => {
@@ -5520,13 +5610,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
setCursorPosition(e.target.selectionStart);
syncInputOverlayScroll(e.target);
// 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={`Type / for commands, @ for files, or ask ${provider === 'cursor' ? 'Cursor' : 'Claude'} anything...`}
placeholder={t('input.placeholder', { provider: provider === 'cursor' ? t('messageTypes.cursor') : provider === 'codex' ? t('messageTypes.codex') : t('messageTypes.claude') })}
disabled={isLoading}
className="chat-input-placeholder block w-full pl-12 pr-20 sm:pr-40 py-1.5 sm:py-4 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-[50px] sm:min-h-[80px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base leading-[21px] sm:leading-6 transition-all duration-200"
style={{ height: '50px' }}
@@ -5536,7 +5627,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
type="button"
onClick={open}
className="absolute left-2 top-1/2 transform -translate-y-1/2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Attach images"
title={t('input.attachImages')}
>
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
@@ -5585,8 +5676,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
input.trim() ? 'opacity-0' : 'opacity-100'
}`}>
{sendByCtrlEnter
? "Ctrl+Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"
: "Enter to send • Shift+Enter for new line • Tab to change modes • / for slash commands"}
? t('input.hintText.ctrlEnter')
: t('input.hintText.enter')}
</div>
</div>
</div>
</form>