mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 08:39:37 +00:00
4462 lines
195 KiB
JavaScript
4462 lines
195 KiB
JavaScript
/*
|
||
* ChatInterface.jsx - Chat Component with Session Protection Integration
|
||
*
|
||
* SESSION PROTECTION INTEGRATION:
|
||
* ===============================
|
||
*
|
||
* This component integrates with the Session Protection System to prevent project updates
|
||
* from interrupting active conversations:
|
||
*
|
||
* Key Integration Points:
|
||
* 1. handleSubmit() - Marks session as active when user sends message (including temp ID for new sessions)
|
||
* 2. session-created handler - Replaces temporary session ID with real WebSocket session ID
|
||
* 3. claude-complete handler - Marks session as inactive when conversation finishes
|
||
* 4. session-aborted handler - Marks session as inactive when conversation is aborted
|
||
*
|
||
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
|
||
*/
|
||
|
||
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
||
import ReactMarkdown from 'react-markdown';
|
||
import remarkGfm from 'remark-gfm';
|
||
import { useDropzone } from 'react-dropzone';
|
||
import TodoList from './TodoList';
|
||
import ClaudeLogo from './ClaudeLogo.jsx';
|
||
import CursorLogo from './CursorLogo.jsx';
|
||
import NextTaskBanner from './NextTaskBanner.jsx';
|
||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||
|
||
import ClaudeStatus from './ClaudeStatus';
|
||
import TokenUsagePie from './TokenUsagePie';
|
||
import { MicButton } from './MicButton.jsx';
|
||
import { api, authenticatedFetch } from '../utils/api';
|
||
import Fuse from 'fuse.js';
|
||
import CommandMenu from './CommandMenu';
|
||
|
||
|
||
// Helper function to decode HTML entities in text
|
||
function decodeHtmlEntities(text) {
|
||
if (!text) return text;
|
||
return text
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/&/g, '&');
|
||
}
|
||
|
||
// Normalize markdown text where providers mistakenly wrap short inline code with single-line triple fences.
|
||
// Only convert fences that do NOT contain any newline to avoid touching real code blocks.
|
||
function normalizeInlineCodeFences(text) {
|
||
if (!text || typeof text !== 'string') return text;
|
||
try {
|
||
// ```code``` -> `code`
|
||
return text.replace(/```\s*([^\n\r]+?)\s*```/g, '`$1`');
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
// Small wrapper to keep markdown behavior consistent in one place
|
||
const Markdown = ({ children, className }) => {
|
||
const content = normalizeInlineCodeFences(String(children ?? ''));
|
||
return (
|
||
<div className={className}>
|
||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||
{content}
|
||
</ReactMarkdown>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Format "Claude AI usage limit reached|<epoch>" into a local time string
|
||
function formatUsageLimitText(text) {
|
||
try {
|
||
if (typeof text !== 'string') return text;
|
||
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
|
||
let timestampMs = parseInt(ts, 10);
|
||
if (!Number.isFinite(timestampMs)) return match;
|
||
if (timestampMs < 1e12) timestampMs *= 1000; // seconds → ms
|
||
const reset = new Date(timestampMs);
|
||
|
||
// Time HH:mm in local time
|
||
const timeStr = new Intl.DateTimeFormat(undefined, {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false
|
||
}).format(reset);
|
||
|
||
// Human-readable timezone: GMT±HH[:MM] (City)
|
||
const offsetMinutesLocal = -reset.getTimezoneOffset();
|
||
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
|
||
const abs = Math.abs(offsetMinutesLocal);
|
||
const offH = Math.floor(abs / 60);
|
||
const offM = abs % 60;
|
||
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
|
||
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||
const cityRaw = tzId.split('/').pop() || '';
|
||
const city = cityRaw
|
||
.replace(/_/g, ' ')
|
||
.toLowerCase()
|
||
.replace(/\b\w/g, c => c.toUpperCase());
|
||
const tzHuman = city ? `${gmt} (${city})` : gmt;
|
||
|
||
// Readable date like "8 Jun 2025"
|
||
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
|
||
|
||
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
|
||
});
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
// Safe localStorage utility to handle quota exceeded errors
|
||
const safeLocalStorage = {
|
||
setItem: (key, value) => {
|
||
try {
|
||
// For chat messages, implement compression and size limits
|
||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
// Limit to last 50 messages to prevent storage bloat
|
||
if (Array.isArray(parsed) && parsed.length > 50) {
|
||
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
|
||
const truncated = parsed.slice(-50);
|
||
value = JSON.stringify(truncated);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn('Could not parse chat messages for truncation:', parseError);
|
||
}
|
||
}
|
||
|
||
localStorage.setItem(key, value);
|
||
} catch (error) {
|
||
if (error.name === 'QuotaExceededError') {
|
||
console.warn('localStorage quota exceeded, clearing old data');
|
||
// Clear old chat messages to free up space
|
||
const keys = Object.keys(localStorage);
|
||
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
|
||
|
||
// Remove oldest chat data first, keeping only the 3 most recent projects
|
||
if (chatKeys.length > 3) {
|
||
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
|
||
localStorage.removeItem(k);
|
||
console.log(`Removed old chat data: ${k}`);
|
||
});
|
||
}
|
||
|
||
// If still failing, clear draft inputs too
|
||
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
|
||
draftKeys.forEach(k => {
|
||
localStorage.removeItem(k);
|
||
});
|
||
|
||
// Try again with reduced data
|
||
try {
|
||
localStorage.setItem(key, value);
|
||
} catch (retryError) {
|
||
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
||
// Last resort: Try to save just the last 10 messages
|
||
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
if (Array.isArray(parsed) && parsed.length > 10) {
|
||
const minimal = parsed.slice(-10);
|
||
localStorage.setItem(key, JSON.stringify(minimal));
|
||
console.warn('Saved only last 10 messages due to quota constraints');
|
||
}
|
||
} catch (finalError) {
|
||
console.error('Final save attempt failed:', finalError);
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
console.error('localStorage error:', error);
|
||
}
|
||
}
|
||
},
|
||
getItem: (key) => {
|
||
try {
|
||
return localStorage.getItem(key);
|
||
} catch (error) {
|
||
console.error('localStorage getItem error:', error);
|
||
return null;
|
||
}
|
||
},
|
||
removeItem: (key) => {
|
||
try {
|
||
localStorage.removeItem(key);
|
||
} catch (error) {
|
||
console.error('localStorage removeItem error:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Common markdown components to ensure consistent rendering (tables, inline code, links, etc.)
|
||
const markdownComponents = {
|
||
code: ({ node, inline, className, children, ...props }) => {
|
||
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
|
||
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>
|
||
);
|
||
}
|
||
const [copied, setCopied] = React.useState(false);
|
||
const textToCopy = raw;
|
||
|
||
const handleCopy = () => {
|
||
const doSet = () => {
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 1500);
|
||
};
|
||
try {
|
||
if (navigator && navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(textToCopy).then(doSet).catch(() => {
|
||
// Fallback
|
||
const ta = document.createElement('textarea');
|
||
ta.value = textToCopy;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); } catch {}
|
||
document.body.removeChild(ta);
|
||
doSet();
|
||
});
|
||
} else {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = textToCopy;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try { document.execCommand('copy'); } catch {}
|
||
document.body.removeChild(ta);
|
||
doSet();
|
||
}
|
||
} catch {}
|
||
};
|
||
|
||
return (
|
||
<div className="relative group my-2">
|
||
<button
|
||
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'}
|
||
>
|
||
{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
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-1">
|
||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<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
|
||
</span>
|
||
)}
|
||
</button>
|
||
<pre className="bg-gray-900 dark:bg-gray-900 border border-gray-700/40 rounded-lg p-3 overflow-x-auto">
|
||
<code className={`text-gray-100 dark:text-gray-100 text-sm font-mono ${className || ''}`} {...props}>
|
||
{children}
|
||
</code>
|
||
</pre>
|
||
</div>
|
||
);
|
||
},
|
||
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}
|
||
</blockquote>
|
||
),
|
||
a: ({ href, children }) => (
|
||
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
||
{children}
|
||
</a>
|
||
),
|
||
p: ({ children }) => <div className="mb-2 last:mb-0">{children}</div>,
|
||
// GFM tables
|
||
table: ({ children }) => (
|
||
<div className="overflow-x-auto my-2">
|
||
<table className="min-w-full border-collapse border border-gray-200 dark:border-gray-700">
|
||
{children}
|
||
</table>
|
||
</div>
|
||
),
|
||
thead: ({ children }) => (
|
||
<thead className="bg-gray-50 dark:bg-gray-800">{children}</thead>
|
||
),
|
||
th: ({ children }) => (
|
||
<th className="px-3 py-2 text-left text-sm font-semibold border border-gray-200 dark:border-gray-700">{children}</th>
|
||
),
|
||
td: ({ children }) => (
|
||
<td className="px-3 py-2 align-top text-sm border border-gray-200 dark:border-gray-700">{children}</td>
|
||
)
|
||
};
|
||
|
||
// Memoized message component to prevent unnecessary re-renders
|
||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters, showThinking }) => {
|
||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||
((prevMessage.type === 'assistant') ||
|
||
(prevMessage.type === 'user') ||
|
||
(prevMessage.type === 'tool') ||
|
||
(prevMessage.type === 'error'));
|
||
const messageRef = React.useRef(null);
|
||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||
React.useEffect(() => {
|
||
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
||
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting && !isExpanded) {
|
||
setIsExpanded(true);
|
||
// Find all details elements and open them
|
||
const details = messageRef.current.querySelectorAll('details');
|
||
details.forEach(detail => {
|
||
detail.open = true;
|
||
});
|
||
}
|
||
});
|
||
},
|
||
{ threshold: 0.1 }
|
||
);
|
||
|
||
observer.observe(messageRef.current);
|
||
|
||
return () => {
|
||
if (messageRef.current) {
|
||
observer.unobserve(messageRef.current);
|
||
}
|
||
};
|
||
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
||
|
||
return (
|
||
<div
|
||
ref={messageRef}
|
||
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
||
>
|
||
{message.type === 'user' ? (
|
||
/* User message bubble on the right */
|
||
<div className="flex items-end space-x-0 sm:space-x-3 w-full sm:w-auto sm:max-w-[85%] md:max-w-md lg:max-w-lg xl:max-w-xl">
|
||
<div className="bg-blue-600 text-white rounded-2xl rounded-br-md px-3 sm:px-4 py-2 shadow-sm flex-1 sm:flex-initial">
|
||
<div className="text-sm whitespace-pre-wrap break-words">
|
||
{message.content}
|
||
</div>
|
||
{message.images && message.images.length > 0 && (
|
||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||
{message.images.map((img, idx) => (
|
||
<img
|
||
key={idx}
|
||
src={img.data}
|
||
alt={img.name}
|
||
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={() => window.open(img.data, '_blank')}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="text-xs text-blue-100 mt-1 text-right">
|
||
{new Date(message.timestamp).toLocaleTimeString()}
|
||
</div>
|
||
</div>
|
||
{!isGrouped && (
|
||
<div className="hidden sm:flex w-8 h-8 bg-blue-600 rounded-full items-center justify-center text-white text-sm flex-shrink-0">
|
||
U
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* Claude/Error/Tool messages on the left */
|
||
<div className="w-full">
|
||
{!isGrouped && (
|
||
<div className="flex items-center space-x-3 mb-2">
|
||
{message.type === 'error' ? (
|
||
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
||
!
|
||
</div>
|
||
) : message.type === 'tool' ? (
|
||
<div className="w-8 h-8 bg-gray-600 dark:bg-gray-700 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
||
🔧
|
||
</div>
|
||
) : (
|
||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
||
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||
<CursorLogo className="w-full h-full" />
|
||
) : (
|
||
<ClaudeLogo className="w-full h-full" />
|
||
)}
|
||
</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' : 'Claude')}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="w-full">
|
||
|
||
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-2 sm:p-3 mb-2">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-5 h-5 bg-blue-600 rounded flex items-center justify-center">
|
||
<svg className="w-3 h-3 text-white" 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" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
</div>
|
||
<span className="font-medium text-blue-900 dark:text-blue-100">
|
||
Using {message.toolName}
|
||
</span>
|
||
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono">
|
||
{message.toolId}
|
||
</span>
|
||
</div>
|
||
{onShowSettings && (
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onShowSettings();
|
||
}}
|
||
className="p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
||
title="Tool Settings"
|
||
>
|
||
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
{message.toolInput && message.toolName === 'Edit' && (() => {
|
||
try {
|
||
const input = JSON.parse(message.toolInput);
|
||
if (input.file_path && input.old_string && input.new_string) {
|
||
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>
|
||
📝 View edit diff for
|
||
<button
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onFileOpen && onFileOpen(input.file_path, {
|
||
old_string: input.old_string,
|
||
new_string: input.new_string
|
||
});
|
||
}}
|
||
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: input.old_string,
|
||
new_string: input.new_string
|
||
})}
|
||
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">
|
||
Diff
|
||
</span>
|
||
</div>
|
||
<div className="text-xs font-mono">
|
||
{createDiff(input.old_string, input.new_string).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 raw display if parsing fails
|
||
}
|
||
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">
|
||
View input 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>
|
||
);
|
||
})()}
|
||
{message.toolInput && message.toolName !== 'Edit' && (() => {
|
||
// Debug log to see what we're dealing with
|
||
|
||
// Special handling for Write tool
|
||
if (message.toolName === 'Write') {
|
||
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;
|
||
}
|
||
|
||
|
||
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 {
|
||
const input = JSON.parse(message.toolInput);
|
||
if (input.todos && Array.isArray(input.todos)) {
|
||
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>
|
||
Updating Todo List
|
||
</summary>
|
||
<div className="mt-3">
|
||
<TodoList todos={input.todos} />
|
||
{showRawParameters && (
|
||
<details className="mt-3" 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 overflow-x-auto text-blue-900 dark:text-blue-100">
|
||
{message.toolInput}
|
||
</pre>
|
||
</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) {
|
||
const filename = input.file_path.split('/').pop();
|
||
|
||
return (
|
||
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
||
Read{' '}
|
||
<button
|
||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||
>
|
||
{filename}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// Fall back to regular display
|
||
}
|
||
}
|
||
|
||
// Special handling for exit_plan_mode tool
|
||
if (message.toolName === 'exit_plan_mode') {
|
||
try {
|
||
const input = JSON.parse(message.toolInput);
|
||
if (input.plan) {
|
||
// Replace escaped newlines with actual newlines
|
||
const planContent = input.plan.replace(/\\n/g, '\n');
|
||
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>
|
||
📋 View implementation plan
|
||
</summary>
|
||
<Markdown className="mt-3 prose prose-sm max-w-none dark:prose-invert">
|
||
{planContent}
|
||
</Markdown>
|
||
</details>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// Fall back to regular display
|
||
}
|
||
}
|
||
|
||
// Regular tool input display for other tools
|
||
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>
|
||
View input 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>
|
||
);
|
||
})()}
|
||
|
||
{/* Tool Result Section */}
|
||
{message.toolResult && (
|
||
<div className="mt-3 border-t border-blue-200 dark:border-blue-700 pt-3">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<div className={`w-4 h-4 rounded flex items-center justify-center ${
|
||
message.toolResult.isError
|
||
? 'bg-red-500'
|
||
: 'bg-green-500'
|
||
}`}>
|
||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
{message.toolResult.isError ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
) : (
|
||
<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>
|
||
</div>
|
||
<span className={`text-sm font-medium ${
|
||
message.toolResult.isError
|
||
? 'text-red-700 dark:text-red-300'
|
||
: 'text-green-700 dark:text-green-300'
|
||
}`}>
|
||
{message.toolResult.isError ? 'Tool Error' : 'Tool Result'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className={`text-sm ${
|
||
message.toolResult.isError
|
||
? 'text-red-800 dark:text-red-200'
|
||
: 'text-green-800 dark:text-green-200'
|
||
}`}>
|
||
{(() => {
|
||
const content = String(message.toolResult.content || '');
|
||
|
||
// Special handling for TodoWrite/TodoRead results
|
||
if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') &&
|
||
(content.includes('Todos have been modified successfully') ||
|
||
content.includes('Todo list') ||
|
||
(content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) {
|
||
try {
|
||
// Try to parse if it looks like todo JSON data
|
||
let todos = null;
|
||
if (content.startsWith('[')) {
|
||
todos = JSON.parse(content);
|
||
} else if (content.includes('Todos have been modified successfully')) {
|
||
// For TodoWrite success messages, we don't have the data in the result
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="font-medium">Todo list has been updated successfully</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (todos && Array.isArray(todos)) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="font-medium">Current Todo List</span>
|
||
</div>
|
||
<TodoList todos={todos} isResult={true} />
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// Fall through to regular handling
|
||
}
|
||
}
|
||
|
||
// Special handling for exit_plan_mode tool results
|
||
if (message.toolName === 'exit_plan_mode') {
|
||
try {
|
||
// The content should be JSON with a "plan" field
|
||
const parsed = JSON.parse(content);
|
||
if (parsed.plan) {
|
||
// Replace escaped newlines with actual newlines
|
||
const planContent = parsed.plan.replace(/\\n/g, '\n');
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<span className="font-medium">Implementation Plan</span>
|
||
</div>
|
||
<Markdown className="prose prose-sm max-w-none dark:prose-invert">
|
||
{planContent}
|
||
</Markdown>
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
// 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) {
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="font-medium">File updated successfully</span>
|
||
</div>
|
||
<button
|
||
onClick={() => onFileOpen && onFileOpen(fileEditMatch[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"
|
||
>
|
||
{fileEditMatch[1]}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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}>
|
||
<summary className="text-sm text-green-700 dark:text-green-300 cursor-pointer hover:text-green-800 dark:hover:text-green-200 mb-2 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>
|
||
View file content
|
||
</summary>
|
||
<div className="mt-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||
<div className="text-xs font-mono p-3 whitespace-pre-wrap break-words overflow-hidden">
|
||
{content}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
if (content.length > 300) {
|
||
return (
|
||
<details open={autoExpandTools}>
|
||
<summary className="text-sm text-green-700 dark:text-green-300 cursor-pointer hover:text-green-800 dark:hover:text-green-200 mb-2 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>
|
||
View full output ({content.length} chars)
|
||
</summary>
|
||
<Markdown className="mt-2 prose prose-sm max-w-none prose-green dark:prose-invert">
|
||
{content}
|
||
</Markdown>
|
||
</details>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Markdown className="prose prose-sm max-w-none prose-green dark:prose-invert">
|
||
{content}
|
||
</Markdown>
|
||
);
|
||
})()}
|
||
</div>
|
||
</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>
|
||
) : message.isToolUse && message.toolName === 'Read' ? (
|
||
// Simple Read tool indicator
|
||
(() => {
|
||
try {
|
||
const input = JSON.parse(message.toolInput);
|
||
if (input.file_path) {
|
||
const filename = input.file_path.split('/').pop();
|
||
return (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||
📖 Read{' '}
|
||
<button
|
||
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
||
>
|
||
{filename}
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
return (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||
📖 Read file
|
||
</div>
|
||
);
|
||
}
|
||
})()
|
||
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
|
||
// Simple TodoWrite tool indicator with tasks
|
||
(() => {
|
||
try {
|
||
const input = JSON.parse(message.toolInput);
|
||
if (input.todos && Array.isArray(input.todos)) {
|
||
return (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2">
|
||
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
||
📝 Update todo list
|
||
</div>
|
||
<TodoList todos={input.todos} />
|
||
</div>
|
||
);
|
||
}
|
||
} catch (e) {
|
||
return (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||
📝 Update todo list
|
||
</div>
|
||
);
|
||
}
|
||
})()
|
||
) : message.isToolUse && message.toolName === 'TodoRead' ? (
|
||
// Simple TodoRead tool indicator
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-2 border-blue-300 dark:border-blue-600 pl-3 py-1 mb-2 text-sm text-blue-700 dark:text-blue-300">
|
||
📋 Read todo list
|
||
</div>
|
||
) : (
|
||
<div className="text-sm text-gray-700 dark:text-gray-300">
|
||
{/* Thinking accordion for reasoning */}
|
||
{showThinking && message.reasoning && (
|
||
<details className="mb-3">
|
||
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
|
||
💭 Thinking...
|
||
</summary>
|
||
<div className="mt-2 pl-4 border-l-2 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400 text-sm">
|
||
<div className="whitespace-pre-wrap">
|
||
{message.reasoning}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{(() => {
|
||
const content = formatUsageLimitText(String(message.content || ''));
|
||
|
||
// Detect if content is pure JSON (starts with { or [)
|
||
const trimmedContent = content.trim();
|
||
if ((trimmedContent.startsWith('{') || trimmedContent.startsWith('[')) &&
|
||
(trimmedContent.endsWith('}') || trimmedContent.endsWith(']'))) {
|
||
try {
|
||
const parsed = JSON.parse(trimmedContent);
|
||
const formatted = JSON.stringify(parsed, null, 2);
|
||
|
||
return (
|
||
<div className="my-2">
|
||
<div className="flex items-center gap-2 mb-2 text-sm text-gray-600 dark: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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||
</svg>
|
||
<span className="font-medium">JSON Response</span>
|
||
</div>
|
||
<div className="bg-gray-800 dark:bg-gray-900 border border-gray-600/30 dark:border-gray-700 rounded-lg overflow-hidden">
|
||
<pre className="p-4 overflow-x-auto">
|
||
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre">
|
||
{formatted}
|
||
</code>
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
);
|
||
} catch (e) {
|
||
// Not valid JSON, fall through to normal rendering
|
||
}
|
||
}
|
||
|
||
// Normal rendering for non-JSON content
|
||
return message.type === 'assistant' ? (
|
||
<Markdown className="prose prose-sm max-w-none dark:prose-invert prose-gray">
|
||
{content}
|
||
</Markdown>
|
||
) : (
|
||
<div className="whitespace-pre-wrap">
|
||
{content}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
|
||
<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>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
// ImageAttachment component for displaying image previews
|
||
const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||
const [preview, setPreview] = useState(null);
|
||
|
||
useEffect(() => {
|
||
const url = URL.createObjectURL(file);
|
||
setPreview(url);
|
||
return () => URL.revokeObjectURL(url);
|
||
}, [file]);
|
||
|
||
return (
|
||
<div className="relative group">
|
||
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
||
{uploadProgress !== undefined && uploadProgress < 100 && (
|
||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||
<div className="text-white text-xs">{uploadProgress}%</div>
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</div>
|
||
)}
|
||
<button
|
||
onClick={onRemove}
|
||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
||
>
|
||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ChatInterface: Main chat component with Session Protection System integration
|
||
//
|
||
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
||
// - onSessionActive: Called when user sends message to mark session as protected
|
||
// - onSessionInactive: Called when conversation completes/aborts to re-enable updates
|
||
// - 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, onSessionProcessing, onSessionNotProcessing, processingSessions, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter, externalMessageUpdate, onTaskClick, onShowAllTasks }) {
|
||
const { tasksEnabled } = useTasksSettings();
|
||
const [input, setInput] = useState(() => {
|
||
if (typeof window !== 'undefined' && selectedProject) {
|
||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||
}
|
||
return '';
|
||
});
|
||
const [chatMessages, setChatMessages] = useState(() => {
|
||
if (typeof window !== 'undefined' && selectedProject) {
|
||
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
||
return saved ? JSON.parse(saved) : [];
|
||
}
|
||
return [];
|
||
});
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null);
|
||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||
const [sessionMessages, setSessionMessages] = useState([]);
|
||
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
||
const [messagesOffset, setMessagesOffset] = useState(0);
|
||
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
||
const [totalMessages, setTotalMessages] = useState(0);
|
||
const MESSAGES_PER_PAGE = 20;
|
||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||
const [permissionMode, setPermissionMode] = useState('default');
|
||
const [attachedImages, setAttachedImages] = useState([]);
|
||
const [uploadingImages, setUploadingImages] = useState(new Map());
|
||
const [imageErrors, setImageErrors] = useState(new Map());
|
||
const messagesEndRef = useRef(null);
|
||
const textareaRef = useRef(null);
|
||
const inputContainerRef = useRef(null);
|
||
const scrollContainerRef = useRef(null);
|
||
const isLoadingSessionRef = useRef(false); // Track session loading to prevent multiple scrolls
|
||
// Streaming throttle buffers
|
||
const streamBufferRef = useRef('');
|
||
const streamTimerRef = useRef(null);
|
||
const commandQueryTimerRef = useRef(null);
|
||
const [debouncedInput, setDebouncedInput] = useState('');
|
||
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
||
const [fileList, setFileList] = useState([]);
|
||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
||
const [cursorPosition, setCursorPosition] = useState(0);
|
||
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 [commandQuery, setCommandQuery] = useState('');
|
||
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
||
const [tokenBudget, setTokenBudget] = useState(null);
|
||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
||
const [slashPosition, setSlashPosition] = useState(-1);
|
||
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
||
const [claudeStatus, setClaudeStatus] = useState(null);
|
||
const [provider, setProvider] = useState(() => {
|
||
return localStorage.getItem('selected-provider') || 'claude';
|
||
});
|
||
const [cursorModel, setCursorModel] = useState(() => {
|
||
return localStorage.getItem('cursor-model') || 'gpt-5';
|
||
});
|
||
// Load permission mode for the current session
|
||
useEffect(() => {
|
||
if (selectedSession?.id) {
|
||
const savedMode = localStorage.getItem(`permissionMode-${selectedSession.id}`);
|
||
if (savedMode) {
|
||
setPermissionMode(savedMode);
|
||
} else {
|
||
setPermissionMode('default');
|
||
}
|
||
}
|
||
}, [selectedSession?.id]);
|
||
|
||
// When selecting a session from Sidebar, auto-switch provider to match session's origin
|
||
useEffect(() => {
|
||
if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) {
|
||
setProvider(selectedSession.__provider);
|
||
localStorage.setItem('selected-provider', selectedSession.__provider);
|
||
}
|
||
}, [selectedSession]);
|
||
|
||
// Load Cursor default model from config
|
||
useEffect(() => {
|
||
if (provider === 'cursor') {
|
||
fetch('/api/cursor/config', {
|
||
headers: {
|
||
'Authorization': `Bearer ${localStorage.getItem('auth-token')}`
|
||
}
|
||
})
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
if (data.success && data.config?.model?.modelId) {
|
||
// Map Cursor model IDs to our simplified names
|
||
const modelMap = {
|
||
'gpt-5': 'gpt-5',
|
||
'claude-4-sonnet': 'sonnet-4',
|
||
'sonnet-4': 'sonnet-4',
|
||
'claude-4-opus': 'opus-4.1',
|
||
'opus-4.1': 'opus-4.1'
|
||
};
|
||
const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId;
|
||
if (!localStorage.getItem('cursor-model')) {
|
||
setCursorModel(mappedModel);
|
||
}
|
||
}
|
||
})
|
||
.catch(err => console.error('Error loading Cursor config:', err));
|
||
}
|
||
}, [provider]);
|
||
|
||
// Fetch slash commands on mount and when project changes
|
||
useEffect(() => {
|
||
const fetchCommands = async () => {
|
||
if (!selectedProject) return;
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/commands/list', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
projectPath: selectedProject.path
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to fetch commands');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Combine built-in and custom commands
|
||
const allCommands = [
|
||
...(data.builtIn || []).map(cmd => ({ ...cmd, type: 'built-in' })),
|
||
...(data.custom || []).map(cmd => ({ ...cmd, type: 'custom' }))
|
||
];
|
||
|
||
setSlashCommands(allCommands);
|
||
|
||
// Load command history from localStorage
|
||
const historyKey = `command_history_${selectedProject.name}`;
|
||
const history = safeLocalStorage.getItem(historyKey);
|
||
if (history) {
|
||
try {
|
||
const parsedHistory = JSON.parse(history);
|
||
// Sort commands by usage frequency
|
||
const sortedCommands = allCommands.sort((a, b) => {
|
||
const aCount = parsedHistory[a.name] || 0;
|
||
const bCount = parsedHistory[b.name] || 0;
|
||
return bCount - aCount;
|
||
});
|
||
setSlashCommands(sortedCommands);
|
||
} catch (e) {
|
||
console.error('Error parsing command history:', e);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching slash commands:', error);
|
||
setSlashCommands([]);
|
||
}
|
||
};
|
||
|
||
fetchCommands();
|
||
}, [selectedProject]);
|
||
|
||
// Create Fuse instance for fuzzy search
|
||
const fuse = useMemo(() => {
|
||
if (!slashCommands.length) return null;
|
||
|
||
return new Fuse(slashCommands, {
|
||
keys: [
|
||
{ name: 'name', weight: 2 },
|
||
{ name: 'description', weight: 1 }
|
||
],
|
||
threshold: 0.4,
|
||
includeScore: true,
|
||
minMatchCharLength: 1
|
||
});
|
||
}, [slashCommands]);
|
||
|
||
// Filter commands based on query
|
||
useEffect(() => {
|
||
if (!commandQuery) {
|
||
setFilteredCommands(slashCommands);
|
||
return;
|
||
}
|
||
|
||
if (!fuse) {
|
||
setFilteredCommands([]);
|
||
return;
|
||
}
|
||
|
||
const results = fuse.search(commandQuery);
|
||
setFilteredCommands(results.map(result => result.item));
|
||
}, [commandQuery, slashCommands, fuse]);
|
||
|
||
// Calculate frequently used commands
|
||
const frequentCommands = useMemo(() => {
|
||
if (!selectedProject || slashCommands.length === 0) return [];
|
||
|
||
const historyKey = `command_history_${selectedProject.name}`;
|
||
const history = safeLocalStorage.getItem(historyKey);
|
||
|
||
if (!history) return [];
|
||
|
||
try {
|
||
const parsedHistory = JSON.parse(history);
|
||
|
||
// Sort commands by usage count
|
||
const commandsWithUsage = slashCommands
|
||
.map(cmd => ({
|
||
...cmd,
|
||
usageCount: parsedHistory[cmd.name] || 0
|
||
}))
|
||
.filter(cmd => cmd.usageCount > 0)
|
||
.sort((a, b) => b.usageCount - a.usageCount)
|
||
.slice(0, 5); // Top 5 most used
|
||
|
||
return commandsWithUsage;
|
||
} catch (e) {
|
||
console.error('Error parsing command history:', e);
|
||
return [];
|
||
}
|
||
}, [selectedProject, slashCommands]);
|
||
|
||
// Command selection callback with history tracking
|
||
const handleCommandSelect = useCallback((command, index, isHover) => {
|
||
if (!command || !selectedProject) return;
|
||
|
||
// If hovering, just update the selected index
|
||
if (isHover) {
|
||
setSelectedCommandIndex(index);
|
||
return;
|
||
}
|
||
|
||
// Update command history
|
||
const historyKey = `command_history_${selectedProject.name}`;
|
||
const history = safeLocalStorage.getItem(historyKey);
|
||
let parsedHistory = {};
|
||
|
||
try {
|
||
parsedHistory = history ? JSON.parse(history) : {};
|
||
} catch (e) {
|
||
console.error('Error parsing command history:', e);
|
||
}
|
||
|
||
parsedHistory[command.name] = (parsedHistory[command.name] || 0) + 1;
|
||
safeLocalStorage.setItem(historyKey, JSON.stringify(parsedHistory));
|
||
|
||
// Execute the command
|
||
executeCommand(command);
|
||
}, [selectedProject]);
|
||
|
||
// Execute a command
|
||
const handleBuiltInCommand = useCallback((result) => {
|
||
const { action, data } = result;
|
||
|
||
switch (action) {
|
||
case 'clear':
|
||
// Clear conversation history
|
||
setChatMessages([]);
|
||
setSessionMessages([]);
|
||
break;
|
||
|
||
case 'help':
|
||
// Show help content
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: data.content,
|
||
timestamp: Date.now()
|
||
}]);
|
||
break;
|
||
|
||
case 'model':
|
||
// Show model information
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `**Current Model**: ${data.current.model}\n\n**Available Models**:\n\nClaude: ${data.available.claude.join(', ')}\n\nCursor: ${data.available.cursor.join(', ')}`,
|
||
timestamp: Date.now()
|
||
}]);
|
||
break;
|
||
|
||
case 'cost': {
|
||
const costMessage = `**Token Usage**: ${data.tokenUsage.used.toLocaleString()} / ${data.tokenUsage.total.toLocaleString()} (${data.tokenUsage.percentage}%)\n\n**Estimated Cost**:\n- Input: $${data.cost.input}\n- Output: $${data.cost.output}\n- **Total**: $${data.cost.total}\n\n**Model**: ${data.model}`;
|
||
setChatMessages(prev => [...prev, { role: 'assistant', content: costMessage, timestamp: Date.now() }]);
|
||
break;
|
||
}
|
||
|
||
case 'status': {
|
||
const statusMessage = `**System Status**\n\n- Version: ${data.version}\n- Uptime: ${data.uptime}\n- Model: ${data.model}\n- Provider: ${data.provider}\n- Node.js: ${data.nodeVersion}\n- Platform: ${data.platform}`;
|
||
setChatMessages(prev => [...prev, { role: 'assistant', content: statusMessage, timestamp: Date.now() }]);
|
||
break;
|
||
}
|
||
case 'memory':
|
||
// Show memory file info
|
||
if (data.error) {
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `⚠️ ${data.message}`,
|
||
timestamp: Date.now()
|
||
}]);
|
||
} else {
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `📝 ${data.message}\n\nPath: \`${data.path}\``,
|
||
timestamp: Date.now()
|
||
}]);
|
||
// Optionally open file in editor
|
||
if (data.exists && onFileOpen) {
|
||
onFileOpen(data.path);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'config':
|
||
// Open settings
|
||
if (onShowSettings) {
|
||
onShowSettings();
|
||
}
|
||
break;
|
||
|
||
case 'rewind':
|
||
// Rewind conversation
|
||
if (data.error) {
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `⚠️ ${data.message}`,
|
||
timestamp: Date.now()
|
||
}]);
|
||
} else {
|
||
// Remove last N messages
|
||
setChatMessages(prev => prev.slice(0, -data.steps * 2)); // Remove user + assistant pairs
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `⏪ ${data.message}`,
|
||
timestamp: Date.now()
|
||
}]);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
console.warn('Unknown built-in command action:', action);
|
||
}
|
||
}, [onFileOpen, onShowSettings]);
|
||
|
||
// Ref to store handleSubmit so we can call it from handleCustomCommand
|
||
const handleSubmitRef = useRef(null);
|
||
|
||
// Handle custom command execution
|
||
const handleCustomCommand = useCallback(async (result, args) => {
|
||
const { content, hasBashCommands, hasFileIncludes } = result;
|
||
|
||
// Show confirmation for bash commands
|
||
if (hasBashCommands) {
|
||
const confirmed = window.confirm(
|
||
'This command contains bash commands that will be executed. Do you want to proceed?'
|
||
);
|
||
if (!confirmed) {
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: '❌ Command execution cancelled',
|
||
timestamp: Date.now()
|
||
}]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Set the input to the command content
|
||
setInput(content);
|
||
|
||
// Wait for state to update, then directly call handleSubmit
|
||
setTimeout(() => {
|
||
if (handleSubmitRef.current) {
|
||
// Create a fake event to pass to handleSubmit
|
||
const fakeEvent = { preventDefault: () => {} };
|
||
handleSubmitRef.current(fakeEvent);
|
||
}
|
||
}, 50);
|
||
}, []);
|
||
const executeCommand = useCallback(async (command) => {
|
||
if (!command || !selectedProject) return;
|
||
|
||
try {
|
||
// Parse command and arguments from current input
|
||
const commandMatch = input.match(new RegExp(`${command.name}\\s*(.*)`));
|
||
const args = commandMatch && commandMatch[1]
|
||
? commandMatch[1].trim().split(/\s+/)
|
||
: [];
|
||
|
||
// Prepare context for command execution
|
||
const context = {
|
||
projectPath: selectedProject.path,
|
||
projectName: selectedProject.name,
|
||
sessionId: currentSessionId,
|
||
provider,
|
||
model: provider === 'cursor' ? cursorModel : 'claude-sonnet-4.5',
|
||
tokenUsage: tokenBudget
|
||
};
|
||
|
||
// Call the execute endpoint
|
||
const response = await authenticatedFetch('/api/commands/execute', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
commandName: command.name,
|
||
commandPath: command.path,
|
||
args,
|
||
context
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to execute command');
|
||
}
|
||
|
||
const result = await response.json();
|
||
|
||
// Handle built-in commands
|
||
if (result.type === 'builtin') {
|
||
handleBuiltInCommand(result);
|
||
} else if (result.type === 'custom') {
|
||
// Handle custom commands - inject as system message
|
||
await handleCustomCommand(result, args);
|
||
}
|
||
|
||
// Clear the input after successful execution
|
||
setInput('');
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
setSelectedCommandIndex(-1);
|
||
|
||
} catch (error) {
|
||
console.error('Error executing command:', error);
|
||
// Show error message to user
|
||
setChatMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `Error executing command: ${error.message}`,
|
||
timestamp: Date.now()
|
||
}]);
|
||
}
|
||
}, [input, selectedProject, currentSessionId, provider, cursorModel, tokenBudget]);
|
||
|
||
// Handle built-in command actions
|
||
|
||
|
||
// Memoized diff calculation to prevent recalculating on every render
|
||
const createDiff = useMemo(() => {
|
||
const cache = new Map();
|
||
return (oldStr, newStr) => {
|
||
const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`;
|
||
if (cache.has(key)) {
|
||
return cache.get(key);
|
||
}
|
||
|
||
const result = calculateDiff(oldStr, newStr);
|
||
cache.set(key, result);
|
||
if (cache.size > 100) {
|
||
const firstKey = cache.keys().next().value;
|
||
cache.delete(firstKey);
|
||
}
|
||
return result;
|
||
};
|
||
}, []);
|
||
|
||
// Load session messages from API with pagination
|
||
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => {
|
||
if (!projectName || !sessionId) return [];
|
||
|
||
const isInitialLoad = !loadMore;
|
||
if (isInitialLoad) {
|
||
setIsLoadingSessionMessages(true);
|
||
} else {
|
||
setIsLoadingMoreMessages(true);
|
||
}
|
||
|
||
try {
|
||
const currentOffset = loadMore ? messagesOffset : 0;
|
||
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset);
|
||
if (!response.ok) {
|
||
throw new Error('Failed to load session messages');
|
||
}
|
||
const data = await response.json();
|
||
|
||
// Handle paginated response
|
||
if (data.hasMore !== undefined) {
|
||
setHasMoreMessages(data.hasMore);
|
||
setTotalMessages(data.total);
|
||
setMessagesOffset(currentOffset + (data.messages?.length || 0));
|
||
return data.messages || [];
|
||
} else {
|
||
// Backward compatibility for non-paginated response
|
||
const messages = data.messages || [];
|
||
setHasMoreMessages(false);
|
||
setTotalMessages(messages.length);
|
||
return messages;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading session messages:', error);
|
||
return [];
|
||
} finally {
|
||
if (isInitialLoad) {
|
||
setIsLoadingSessionMessages(false);
|
||
} else {
|
||
setIsLoadingMoreMessages(false);
|
||
}
|
||
}
|
||
}, [messagesOffset]);
|
||
|
||
// Load Cursor session messages from SQLite via backend
|
||
const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => {
|
||
if (!projectPath || !sessionId) return [];
|
||
setIsLoadingSessionMessages(true);
|
||
try {
|
||
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
|
||
const res = await authenticatedFetch(url);
|
||
if (!res.ok) return [];
|
||
const data = await res.json();
|
||
const blobs = data?.session?.messages || [];
|
||
const converted = [];
|
||
const toolUseMap = {}; // Map to store tool uses by ID for linking results
|
||
|
||
// First pass: process all messages maintaining order
|
||
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) {
|
||
const blob = blobs[blobIdx];
|
||
const content = blob.content;
|
||
let text = '';
|
||
let role = 'assistant';
|
||
let reasoningText = null; // Move to outer scope
|
||
try {
|
||
// Handle different Cursor message formats
|
||
if (content?.role && content?.content) {
|
||
// Direct format: {"role":"user","content":[{"type":"text","text":"..."}]}
|
||
// Skip system messages
|
||
if (content.role === 'system') {
|
||
continue;
|
||
}
|
||
|
||
// Handle tool messages
|
||
if (content.role === 'tool') {
|
||
// Tool result format - find the matching tool use message and update it
|
||
if (Array.isArray(content.content)) {
|
||
for (const item of content.content) {
|
||
if (item?.type === 'tool-result') {
|
||
// Map ApplyPatch to Edit for consistency
|
||
let toolName = item.toolName || 'Unknown Tool';
|
||
if (toolName === 'ApplyPatch') {
|
||
toolName = 'Edit';
|
||
}
|
||
const toolCallId = item.toolCallId || content.id;
|
||
const result = item.result || '';
|
||
|
||
// Store the tool result to be linked later
|
||
if (toolUseMap[toolCallId]) {
|
||
toolUseMap[toolCallId].toolResult = {
|
||
content: result,
|
||
isError: false
|
||
};
|
||
} else {
|
||
// No matching tool use found, create a standalone result message
|
||
converted.push({
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid,
|
||
isToolUse: true,
|
||
toolName: toolName,
|
||
toolId: toolCallId,
|
||
toolInput: null,
|
||
toolResult: {
|
||
content: result,
|
||
isError: false
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
continue; // Don't add tool messages as regular messages
|
||
} else {
|
||
// User or assistant messages
|
||
role = content.role === 'user' ? 'user' : 'assistant';
|
||
|
||
if (Array.isArray(content.content)) {
|
||
// Extract text, reasoning, and tool calls from content array
|
||
const textParts = [];
|
||
|
||
for (const part of content.content) {
|
||
if (part?.type === 'text' && part?.text) {
|
||
textParts.push(decodeHtmlEntities(part.text));
|
||
} else if (part?.type === 'reasoning' && part?.text) {
|
||
// Handle reasoning type - will be displayed in a collapsible section
|
||
reasoningText = decodeHtmlEntities(part.text);
|
||
} else if (part?.type === 'tool-call') {
|
||
// First, add any text/reasoning we've collected so far as a message
|
||
if (textParts.length > 0 || reasoningText) {
|
||
converted.push({
|
||
type: role,
|
||
content: textParts.join('\n'),
|
||
reasoning: reasoningText,
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid
|
||
});
|
||
textParts.length = 0;
|
||
reasoningText = null;
|
||
}
|
||
|
||
// Tool call in assistant message - format like Claude Code
|
||
// Map ApplyPatch to Edit for consistency with Claude Code
|
||
let toolName = part.toolName || 'Unknown Tool';
|
||
if (toolName === 'ApplyPatch') {
|
||
toolName = 'Edit';
|
||
}
|
||
const toolId = part.toolCallId || `tool_${blobIdx}`;
|
||
|
||
// Create a tool use message with Claude Code format
|
||
// Map Cursor args format to Claude Code format
|
||
let toolInput = part.args;
|
||
|
||
if (toolName === 'Edit' && part.args) {
|
||
// ApplyPatch uses 'patch' format, convert to Edit format
|
||
if (part.args.patch) {
|
||
// Parse the patch to extract old and new content
|
||
const patchLines = part.args.patch.split('\n');
|
||
let oldLines = [];
|
||
let newLines = [];
|
||
let inPatch = false;
|
||
|
||
for (const line of patchLines) {
|
||
if (line.startsWith('@@')) {
|
||
inPatch = true;
|
||
} else if (inPatch) {
|
||
if (line.startsWith('-')) {
|
||
oldLines.push(line.substring(1));
|
||
} else if (line.startsWith('+')) {
|
||
newLines.push(line.substring(1));
|
||
} else if (line.startsWith(' ')) {
|
||
// Context line - add to both
|
||
oldLines.push(line.substring(1));
|
||
newLines.push(line.substring(1));
|
||
}
|
||
}
|
||
}
|
||
|
||
const filePath = part.args.file_path;
|
||
const absolutePath = filePath && !filePath.startsWith('/')
|
||
? `${projectPath}/${filePath}`
|
||
: filePath;
|
||
toolInput = {
|
||
file_path: absolutePath,
|
||
old_string: oldLines.join('\n') || part.args.patch,
|
||
new_string: newLines.join('\n') || part.args.patch
|
||
};
|
||
} else {
|
||
// Direct edit format
|
||
toolInput = part.args;
|
||
}
|
||
} else if (toolName === 'Read' && part.args) {
|
||
// Map 'path' to 'file_path'
|
||
// Convert relative path to absolute if needed
|
||
const filePath = part.args.path || part.args.file_path;
|
||
const absolutePath = filePath && !filePath.startsWith('/')
|
||
? `${projectPath}/${filePath}`
|
||
: filePath;
|
||
toolInput = {
|
||
file_path: absolutePath
|
||
};
|
||
} else if (toolName === 'Write' && part.args) {
|
||
// Map fields for Write tool
|
||
const filePath = part.args.path || part.args.file_path;
|
||
const absolutePath = filePath && !filePath.startsWith('/')
|
||
? `${projectPath}/${filePath}`
|
||
: filePath;
|
||
toolInput = {
|
||
file_path: absolutePath,
|
||
content: part.args.contents || part.args.content
|
||
};
|
||
}
|
||
|
||
const toolMessage = {
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid,
|
||
isToolUse: true,
|
||
toolName: toolName,
|
||
toolId: toolId,
|
||
toolInput: toolInput ? JSON.stringify(toolInput) : null,
|
||
toolResult: null // Will be filled when we get the tool result
|
||
};
|
||
converted.push(toolMessage);
|
||
toolUseMap[toolId] = toolMessage; // Store for linking results
|
||
} else if (part?.type === 'tool_use') {
|
||
// Old format support
|
||
if (textParts.length > 0 || reasoningText) {
|
||
converted.push({
|
||
type: role,
|
||
content: textParts.join('\n'),
|
||
reasoning: reasoningText,
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid
|
||
});
|
||
textParts.length = 0;
|
||
reasoningText = null;
|
||
}
|
||
|
||
const toolName = part.name || 'Unknown Tool';
|
||
const toolId = part.id || `tool_${blobIdx}`;
|
||
|
||
const toolMessage = {
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid,
|
||
isToolUse: true,
|
||
toolName: toolName,
|
||
toolId: toolId,
|
||
toolInput: part.input ? JSON.stringify(part.input) : null,
|
||
toolResult: null
|
||
};
|
||
converted.push(toolMessage);
|
||
toolUseMap[toolId] = toolMessage;
|
||
} else if (typeof part === 'string') {
|
||
textParts.push(part);
|
||
}
|
||
}
|
||
|
||
// Add any remaining text/reasoning
|
||
if (textParts.length > 0) {
|
||
text = textParts.join('\n');
|
||
if (reasoningText && !text) {
|
||
// Just reasoning, no text
|
||
converted.push({
|
||
type: role,
|
||
content: '',
|
||
reasoning: reasoningText,
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid
|
||
});
|
||
text = ''; // Clear to avoid duplicate
|
||
}
|
||
} else {
|
||
text = '';
|
||
}
|
||
} else if (typeof content.content === 'string') {
|
||
text = content.content;
|
||
}
|
||
}
|
||
} else if (content?.message?.role && content?.message?.content) {
|
||
// Nested message format
|
||
if (content.message.role === 'system') {
|
||
continue;
|
||
}
|
||
role = content.message.role === 'user' ? 'user' : 'assistant';
|
||
if (Array.isArray(content.message.content)) {
|
||
text = content.message.content
|
||
.map(p => (typeof p === 'string' ? p : (p?.text || '')))
|
||
.filter(Boolean)
|
||
.join('\n');
|
||
} else if (typeof content.message.content === 'string') {
|
||
text = content.message.content;
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('Error parsing blob content:', e);
|
||
}
|
||
if (text && text.trim()) {
|
||
const message = {
|
||
type: role,
|
||
content: text,
|
||
timestamp: new Date(Date.now() + blobIdx * 1000),
|
||
blobId: blob.id,
|
||
sequence: blob.sequence,
|
||
rowid: blob.rowid
|
||
};
|
||
|
||
// Add reasoning if we have it
|
||
if (reasoningText) {
|
||
message.reasoning = reasoningText;
|
||
}
|
||
|
||
converted.push(message);
|
||
}
|
||
}
|
||
|
||
// Sort messages by sequence/rowid to maintain chronological order
|
||
converted.sort((a, b) => {
|
||
// First sort by sequence if available (clean 1,2,3... numbering)
|
||
if (a.sequence !== undefined && b.sequence !== undefined) {
|
||
return a.sequence - b.sequence;
|
||
}
|
||
// Then try rowid (original SQLite row IDs)
|
||
if (a.rowid !== undefined && b.rowid !== undefined) {
|
||
return a.rowid - b.rowid;
|
||
}
|
||
// Fallback to timestamp
|
||
return new Date(a.timestamp) - new Date(b.timestamp);
|
||
});
|
||
|
||
return converted;
|
||
} catch (e) {
|
||
console.error('Error loading Cursor session messages:', e);
|
||
return [];
|
||
} finally {
|
||
setIsLoadingSessionMessages(false);
|
||
}
|
||
}, []);
|
||
|
||
// Actual diff calculation function
|
||
const calculateDiff = (oldStr, newStr) => {
|
||
const oldLines = oldStr.split('\n');
|
||
const newLines = newStr.split('\n');
|
||
|
||
// Simple diff algorithm - find common lines and differences
|
||
const diffLines = [];
|
||
let oldIndex = 0;
|
||
let newIndex = 0;
|
||
|
||
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
||
const oldLine = oldLines[oldIndex];
|
||
const newLine = newLines[newIndex];
|
||
|
||
if (oldIndex >= oldLines.length) {
|
||
// Only new lines remaining
|
||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||
newIndex++;
|
||
} else if (newIndex >= newLines.length) {
|
||
// Only old lines remaining
|
||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||
oldIndex++;
|
||
} else if (oldLine === newLine) {
|
||
// Lines are the same - skip in diff view (or show as context)
|
||
oldIndex++;
|
||
newIndex++;
|
||
} else {
|
||
// Lines are different
|
||
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
||
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
||
oldIndex++;
|
||
newIndex++;
|
||
}
|
||
}
|
||
|
||
return diffLines;
|
||
};
|
||
|
||
const convertSessionMessages = (rawMessages) => {
|
||
const converted = [];
|
||
const toolResults = new Map(); // Map tool_use_id to tool result
|
||
|
||
// First pass: collect all tool results
|
||
for (const msg of rawMessages) {
|
||
if (msg.message?.role === 'user' && Array.isArray(msg.message?.content)) {
|
||
for (const part of msg.message.content) {
|
||
if (part.type === 'tool_result') {
|
||
toolResults.set(part.tool_use_id, {
|
||
content: part.content,
|
||
isError: part.is_error,
|
||
timestamp: new Date(msg.timestamp || Date.now())
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Second pass: process messages and attach tool results to tool uses
|
||
for (const msg of rawMessages) {
|
||
// Handle user messages
|
||
if (msg.message?.role === 'user' && msg.message?.content) {
|
||
let content = '';
|
||
let messageType = 'user';
|
||
|
||
if (Array.isArray(msg.message.content)) {
|
||
// Handle array content, but skip tool results (they're attached to tool uses)
|
||
const textParts = [];
|
||
|
||
for (const part of msg.message.content) {
|
||
if (part.type === 'text') {
|
||
textParts.push(decodeHtmlEntities(part.text));
|
||
}
|
||
// Skip tool_result parts - they're handled in the first pass
|
||
}
|
||
|
||
content = textParts.join('\n');
|
||
} else if (typeof msg.message.content === 'string') {
|
||
content = decodeHtmlEntities(msg.message.content);
|
||
} else {
|
||
content = decodeHtmlEntities(String(msg.message.content));
|
||
}
|
||
|
||
// Skip command messages, system messages, and empty content
|
||
const shouldSkip = !content ||
|
||
content.startsWith('<command-name>') ||
|
||
content.startsWith('<command-message>') ||
|
||
content.startsWith('<command-args>') ||
|
||
content.startsWith('<local-command-stdout>') ||
|
||
content.startsWith('<system-reminder>') ||
|
||
content.startsWith('Caveat:') ||
|
||
content.startsWith('This session is being continued from a previous') ||
|
||
content.startsWith('[Request interrupted');
|
||
|
||
if (!shouldSkip) {
|
||
// Unescape double-escaped newlines and other escape sequences
|
||
content = content.replace(/\\n/g, '\n')
|
||
.replace(/\\t/g, '\t')
|
||
.replace(/\\r/g, '\r');
|
||
converted.push({
|
||
type: messageType,
|
||
content: content,
|
||
timestamp: msg.timestamp || new Date().toISOString()
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle assistant messages
|
||
else if (msg.message?.role === 'assistant' && msg.message?.content) {
|
||
if (Array.isArray(msg.message.content)) {
|
||
for (const part of msg.message.content) {
|
||
if (part.type === 'text') {
|
||
// Unescape double-escaped newlines and other escape sequences
|
||
let text = part.text;
|
||
if (typeof text === 'string') {
|
||
text = text.replace(/\\n/g, '\n')
|
||
.replace(/\\t/g, '\t')
|
||
.replace(/\\r/g, '\r');
|
||
}
|
||
converted.push({
|
||
type: 'assistant',
|
||
content: text,
|
||
timestamp: msg.timestamp || new Date().toISOString()
|
||
});
|
||
} else if (part.type === 'tool_use') {
|
||
// Get the corresponding tool result
|
||
const toolResult = toolResults.get(part.id);
|
||
|
||
converted.push({
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: msg.timestamp || new Date().toISOString(),
|
||
isToolUse: true,
|
||
toolName: part.name,
|
||
toolInput: JSON.stringify(part.input),
|
||
toolResult: toolResult ? (typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content)) : null,
|
||
toolError: toolResult?.isError || false,
|
||
toolResultTimestamp: toolResult?.timestamp || new Date()
|
||
});
|
||
}
|
||
}
|
||
} else if (typeof msg.message.content === 'string') {
|
||
// Unescape double-escaped newlines and other escape sequences
|
||
let text = msg.message.content;
|
||
text = text.replace(/\\n/g, '\n')
|
||
.replace(/\\t/g, '\t')
|
||
.replace(/\\r/g, '\r');
|
||
converted.push({
|
||
type: 'assistant',
|
||
content: text,
|
||
timestamp: msg.timestamp || new Date().toISOString()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
return converted;
|
||
};
|
||
|
||
// Memoize expensive convertSessionMessages operation
|
||
const convertedMessages = useMemo(() => {
|
||
return convertSessionMessages(sessionMessages);
|
||
}, [sessionMessages]);
|
||
|
||
// Note: Token budgets are not saved to JSONL files, only sent via WebSocket
|
||
// So we don't try to extract them from loaded sessionMessages
|
||
|
||
// Define scroll functions early to avoid hoisting issues in useEffect dependencies
|
||
const scrollToBottom = useCallback(() => {
|
||
if (scrollContainerRef.current) {
|
||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||
setIsUserScrolledUp(false);
|
||
}
|
||
}, []);
|
||
|
||
// Check if user is near the bottom of the scroll container
|
||
const isNearBottom = useCallback(() => {
|
||
if (!scrollContainerRef.current) return false;
|
||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||
// Consider "near bottom" if within 50px of the bottom
|
||
return scrollHeight - scrollTop - clientHeight < 50;
|
||
}, []);
|
||
|
||
// Handle scroll events to detect when user manually scrolls up and load more messages
|
||
const handleScroll = useCallback(async () => {
|
||
if (scrollContainerRef.current) {
|
||
const container = scrollContainerRef.current;
|
||
const nearBottom = isNearBottom();
|
||
setIsUserScrolledUp(!nearBottom);
|
||
|
||
// Check if we should load more messages (scrolled near top)
|
||
const scrolledNearTop = container.scrollTop < 100;
|
||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||
|
||
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
|
||
// Save current scroll position
|
||
const previousScrollHeight = container.scrollHeight;
|
||
const previousScrollTop = container.scrollTop;
|
||
|
||
// Load more messages
|
||
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true);
|
||
|
||
if (moreMessages.length > 0) {
|
||
// Prepend new messages to the existing ones
|
||
setSessionMessages(prev => [...moreMessages, ...prev]);
|
||
|
||
// Restore scroll position after DOM update
|
||
setTimeout(() => {
|
||
if (scrollContainerRef.current) {
|
||
const newScrollHeight = scrollContainerRef.current.scrollHeight;
|
||
const scrollDiff = newScrollHeight - previousScrollHeight;
|
||
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
|
||
}
|
||
}, 0);
|
||
}
|
||
}
|
||
}
|
||
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
||
|
||
useEffect(() => {
|
||
// Load session messages when session changes
|
||
const loadMessages = async () => {
|
||
if (selectedSession && selectedProject) {
|
||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||
|
||
// Mark that we're loading a session to prevent multiple scroll triggers
|
||
isLoadingSessionRef.current = true;
|
||
|
||
// Only reset state if the session ID actually changed (not initial load)
|
||
const sessionChanged = currentSessionId !== null && currentSessionId !== selectedSession.id;
|
||
|
||
if (sessionChanged) {
|
||
// Reset pagination state when switching sessions
|
||
setMessagesOffset(0);
|
||
setHasMoreMessages(false);
|
||
setTotalMessages(0);
|
||
// Reset token budget when switching sessions
|
||
// It will update when user sends a message and receives new budget from WebSocket
|
||
setTokenBudget(null);
|
||
// Reset loading state when switching sessions (unless the new session is processing)
|
||
// The restore effect will set it back to true if needed
|
||
setIsLoading(false);
|
||
|
||
// Check if the session is currently processing on the backend
|
||
if (ws && sendMessage) {
|
||
sendMessage({
|
||
type: 'check-session-status',
|
||
sessionId: selectedSession.id,
|
||
provider
|
||
});
|
||
}
|
||
} else if (currentSessionId === null) {
|
||
// Initial load - reset pagination but not token budget
|
||
setMessagesOffset(0);
|
||
setHasMoreMessages(false);
|
||
setTotalMessages(0);
|
||
|
||
// Check if the session is currently processing on the backend
|
||
if (ws && sendMessage) {
|
||
sendMessage({
|
||
type: 'check-session-status',
|
||
sessionId: selectedSession.id,
|
||
provider
|
||
});
|
||
}
|
||
}
|
||
|
||
if (provider === 'cursor') {
|
||
// For Cursor, set the session ID for resuming
|
||
setCurrentSessionId(selectedSession.id);
|
||
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
||
|
||
// Only load messages from SQLite if this is NOT a system-initiated session change
|
||
// For system-initiated changes, preserve existing messages
|
||
if (!isSystemSessionChange) {
|
||
// Load historical messages for Cursor session from SQLite
|
||
const projectPath = selectedProject.fullPath || selectedProject.path;
|
||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||
setSessionMessages([]);
|
||
setChatMessages(converted);
|
||
} else {
|
||
// Reset the flag after handling system session change
|
||
setIsSystemSessionChange(false);
|
||
}
|
||
} else {
|
||
// For Claude, load messages normally with pagination
|
||
setCurrentSessionId(selectedSession.id);
|
||
|
||
// Only load messages from API if this is a user-initiated session change
|
||
// For system-initiated changes, preserve existing messages and rely on WebSocket
|
||
if (!isSystemSessionChange) {
|
||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
|
||
setSessionMessages(messages);
|
||
// convertedMessages will be automatically updated via useMemo
|
||
// Scroll will be handled by the main scroll useEffect after messages are rendered
|
||
} else {
|
||
// Reset the flag after handling system session change
|
||
setIsSystemSessionChange(false);
|
||
}
|
||
}
|
||
} else {
|
||
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
|
||
// During system session changes or while loading, preserve the chat messages
|
||
if (!isSystemSessionChange && !isLoading) {
|
||
setChatMessages([]);
|
||
setSessionMessages([]);
|
||
}
|
||
setCurrentSessionId(null);
|
||
sessionStorage.removeItem('cursorSessionId');
|
||
setMessagesOffset(0);
|
||
setHasMoreMessages(false);
|
||
setTotalMessages(0);
|
||
}
|
||
|
||
// Mark loading as complete after messages are set
|
||
// Use setTimeout to ensure state updates and DOM rendering are complete
|
||
setTimeout(() => {
|
||
isLoadingSessionRef.current = false;
|
||
}, 250);
|
||
};
|
||
|
||
loadMessages();
|
||
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
||
|
||
// External Message Update Handler: Reload messages when external CLI modifies current session
|
||
// This triggers when App.jsx detects a JSONL file change for the currently-viewed session
|
||
// Only reloads if the session is NOT active (respecting Session Protection System)
|
||
useEffect(() => {
|
||
if (externalMessageUpdate > 0 && selectedSession && selectedProject) {
|
||
console.log('🔄 Reloading messages due to external CLI update');
|
||
|
||
const reloadExternalMessages = async () => {
|
||
try {
|
||
const provider = localStorage.getItem('selected-provider') || 'claude';
|
||
|
||
if (provider === 'cursor') {
|
||
// Reload Cursor messages from SQLite
|
||
const projectPath = selectedProject.fullPath || selectedProject.path;
|
||
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
||
setSessionMessages([]);
|
||
setChatMessages(converted);
|
||
} else {
|
||
// Reload Claude messages from API/JSONL
|
||
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
|
||
setSessionMessages(messages);
|
||
// convertedMessages will be automatically updated via useMemo
|
||
|
||
// Smart scroll behavior: only auto-scroll if user is near bottom
|
||
if (isNearBottom && autoScrollToBottom) {
|
||
setTimeout(() => scrollToBottom(), 200);
|
||
}
|
||
// If user scrolled up, preserve their position (they're reading history)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error reloading messages from external update:', error);
|
||
}
|
||
};
|
||
|
||
reloadExternalMessages();
|
||
}
|
||
}, [externalMessageUpdate, selectedSession, selectedProject, loadCursorSessionMessages, loadSessionMessages, isNearBottom, autoScrollToBottom, scrollToBottom]);
|
||
|
||
// Update chatMessages when convertedMessages changes
|
||
useEffect(() => {
|
||
if (sessionMessages.length > 0) {
|
||
setChatMessages(convertedMessages);
|
||
}
|
||
}, [convertedMessages, sessionMessages]);
|
||
|
||
// Notify parent when input focus changes
|
||
useEffect(() => {
|
||
if (onInputFocusChange) {
|
||
onInputFocusChange(isInputFocused);
|
||
}
|
||
}, [isInputFocused, onInputFocusChange]);
|
||
|
||
// Persist input draft to localStorage
|
||
useEffect(() => {
|
||
if (selectedProject && input !== '') {
|
||
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
||
} else if (selectedProject && input === '') {
|
||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||
}
|
||
}, [input, selectedProject]);
|
||
|
||
// Persist chat messages to localStorage
|
||
useEffect(() => {
|
||
if (selectedProject && chatMessages.length > 0) {
|
||
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
||
}
|
||
}, [chatMessages, selectedProject]);
|
||
|
||
// Load saved state when project changes (but don't interfere with session loading)
|
||
useEffect(() => {
|
||
if (selectedProject) {
|
||
// Always load saved input draft for the project
|
||
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||
if (savedInput !== input) {
|
||
setInput(savedInput);
|
||
}
|
||
}
|
||
}, [selectedProject?.name]);
|
||
|
||
// Track processing state: notify parent when isLoading becomes true
|
||
// Note: onSessionNotProcessing is called directly in completion message handlers
|
||
useEffect(() => {
|
||
if (currentSessionId && isLoading && onSessionProcessing) {
|
||
onSessionProcessing(currentSessionId);
|
||
}
|
||
}, [isLoading, currentSessionId, onSessionProcessing]);
|
||
|
||
// Restore processing state when switching to a processing session
|
||
useEffect(() => {
|
||
if (currentSessionId && processingSessions) {
|
||
const shouldBeProcessing = processingSessions.has(currentSessionId);
|
||
if (shouldBeProcessing && !isLoading) {
|
||
setIsLoading(true);
|
||
setCanAbortSession(true); // Assume processing sessions can be aborted
|
||
}
|
||
}
|
||
}, [currentSessionId, processingSessions]);
|
||
|
||
useEffect(() => {
|
||
// Handle WebSocket messages
|
||
if (messages.length > 0) {
|
||
const latestMessage = messages[messages.length - 1];
|
||
console.log('🔵 WebSocket message received:', latestMessage.type, latestMessage);
|
||
|
||
// Filter messages by session ID to prevent cross-session interference
|
||
// Skip filtering for global messages that apply to all sessions
|
||
const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'claude-complete'];
|
||
const isGlobalMessage = globalMessageTypes.includes(latestMessage.type);
|
||
|
||
// For new sessions (currentSessionId is null), allow messages through
|
||
if (!isGlobalMessage && latestMessage.sessionId && currentSessionId && latestMessage.sessionId !== currentSessionId) {
|
||
// Message is for a different session, ignore it
|
||
console.log('⏭️ Skipping message for different session:', latestMessage.sessionId, 'current:', currentSessionId);
|
||
return;
|
||
}
|
||
|
||
switch (latestMessage.type) {
|
||
case 'session-created':
|
||
// New session created by Claude CLI - we receive the real session ID here
|
||
// Store it temporarily until conversation completes (prevents premature session association)
|
||
if (latestMessage.sessionId && !currentSessionId) {
|
||
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
||
|
||
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
|
||
// This maintains protection continuity - no gap between temp ID and real ID
|
||
// The temporary session is removed and real session is marked as active
|
||
if (onReplaceTemporarySession) {
|
||
onReplaceTemporarySession(latestMessage.sessionId);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'token-budget':
|
||
// Token budget now fetched via API after message completion instead of WebSocket
|
||
// This case is kept for compatibility but does nothing
|
||
break;
|
||
|
||
case 'claude-response':
|
||
const messageData = latestMessage.data.message || latestMessage.data;
|
||
|
||
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
||
if (messageData && typeof messageData === 'object' && messageData.type) {
|
||
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
|
||
// Decode HTML entities and buffer deltas
|
||
const decodedText = decodeHtmlEntities(messageData.delta.text);
|
||
streamBufferRef.current += decodedText;
|
||
if (!streamTimerRef.current) {
|
||
streamTimerRef.current = setTimeout(() => {
|
||
const chunk = streamBufferRef.current;
|
||
streamBufferRef.current = '';
|
||
streamTimerRef.current = null;
|
||
if (!chunk) return;
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||
last.content = (last.content || '') + chunk;
|
||
} else {
|
||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||
}
|
||
return updated;
|
||
});
|
||
}, 100);
|
||
}
|
||
return;
|
||
}
|
||
if (messageData.type === 'content_block_stop') {
|
||
// Flush any buffered text and mark streaming message complete
|
||
if (streamTimerRef.current) {
|
||
clearTimeout(streamTimerRef.current);
|
||
streamTimerRef.current = null;
|
||
}
|
||
const chunk = streamBufferRef.current;
|
||
streamBufferRef.current = '';
|
||
if (chunk) {
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||
last.content = (last.content || '') + chunk;
|
||
} else {
|
||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||
}
|
||
return updated;
|
||
});
|
||
}
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && last.isStreaming) {
|
||
last.isStreaming = false;
|
||
}
|
||
return updated;
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Handle Claude CLI session duplication bug workaround:
|
||
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
||
// We detect this by checking for system/init messages with session_id that differs
|
||
// from our current session. When found, we need to switch the user to the new session.
|
||
// This works exactly like new session detection - preserve messages during navigation.
|
||
if (latestMessage.data.type === 'system' &&
|
||
latestMessage.data.subtype === 'init' &&
|
||
latestMessage.data.session_id &&
|
||
currentSessionId &&
|
||
latestMessage.data.session_id !== currentSessionId) {
|
||
|
||
console.log('🔄 Claude CLI session duplication detected:', {
|
||
originalSession: currentSessionId,
|
||
newSession: latestMessage.data.session_id
|
||
});
|
||
|
||
// Mark this as a system-initiated session change to preserve messages
|
||
// This works exactly like new session init - messages stay visible during navigation
|
||
setIsSystemSessionChange(true);
|
||
|
||
// Switch to the new session using React Router navigation
|
||
// This triggers the session loading logic in App.jsx without a page reload
|
||
if (onNavigateToSession) {
|
||
onNavigateToSession(latestMessage.data.session_id);
|
||
}
|
||
return; // Don't process the message further, let the navigation handle it
|
||
}
|
||
|
||
// Handle system/init for new sessions (when currentSessionId is null)
|
||
if (latestMessage.data.type === 'system' &&
|
||
latestMessage.data.subtype === 'init' &&
|
||
latestMessage.data.session_id &&
|
||
!currentSessionId) {
|
||
|
||
console.log('🔄 New session init detected:', {
|
||
newSession: latestMessage.data.session_id
|
||
});
|
||
|
||
// Mark this as a system-initiated session change to preserve messages
|
||
setIsSystemSessionChange(true);
|
||
|
||
// Switch to the new session
|
||
if (onNavigateToSession) {
|
||
onNavigateToSession(latestMessage.data.session_id);
|
||
}
|
||
return; // Don't process the message further, let the navigation handle it
|
||
}
|
||
|
||
// For system/init messages that match current session, just ignore them
|
||
if (latestMessage.data.type === 'system' &&
|
||
latestMessage.data.subtype === 'init' &&
|
||
latestMessage.data.session_id &&
|
||
currentSessionId &&
|
||
latestMessage.data.session_id === currentSessionId) {
|
||
console.log('🔄 System init message for current session, ignoring');
|
||
return; // Don't process the message further
|
||
}
|
||
|
||
// Handle different types of content in the response
|
||
if (Array.isArray(messageData.content)) {
|
||
for (const part of messageData.content) {
|
||
if (part.type === 'tool_use') {
|
||
// Add tool use message
|
||
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: '',
|
||
timestamp: new Date(),
|
||
isToolUse: true,
|
||
toolName: part.name,
|
||
toolInput: toolInput,
|
||
toolId: part.id,
|
||
toolResult: null // Will be updated when result comes in
|
||
}]);
|
||
} else if (part.type === 'text' && part.text?.trim()) {
|
||
// Decode HTML entities and normalize usage limit message to local time
|
||
let content = decodeHtmlEntities(part.text);
|
||
content = formatUsageLimitText(content);
|
||
|
||
// Add regular text message
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: content,
|
||
timestamp: new Date()
|
||
}]);
|
||
}
|
||
}
|
||
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
||
// Decode HTML entities and normalize usage limit message to local time
|
||
let content = decodeHtmlEntities(messageData.content);
|
||
content = formatUsageLimitText(content);
|
||
|
||
// Add regular text message
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: content,
|
||
timestamp: new Date()
|
||
}]);
|
||
}
|
||
|
||
// Handle tool results from user messages (these come separately)
|
||
if (messageData.role === 'user' && Array.isArray(messageData.content)) {
|
||
for (const part of messageData.content) {
|
||
if (part.type === 'tool_result') {
|
||
// Find the corresponding tool use and update it with the result
|
||
setChatMessages(prev => prev.map(msg => {
|
||
if (msg.isToolUse && msg.toolId === part.tool_use_id) {
|
||
return {
|
||
...msg,
|
||
toolResult: {
|
||
content: part.content,
|
||
isError: part.is_error,
|
||
timestamp: new Date()
|
||
}
|
||
};
|
||
}
|
||
return msg;
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'claude-output':
|
||
{
|
||
const cleaned = String(latestMessage.data || '');
|
||
if (cleaned.trim()) {
|
||
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
||
if (!streamTimerRef.current) {
|
||
streamTimerRef.current = setTimeout(() => {
|
||
const chunk = streamBufferRef.current;
|
||
streamBufferRef.current = '';
|
||
streamTimerRef.current = null;
|
||
if (!chunk) return;
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
||
} else {
|
||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||
}
|
||
return updated;
|
||
});
|
||
}, 100);
|
||
}
|
||
}
|
||
}
|
||
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',
|
||
content: `Error: ${latestMessage.error}`,
|
||
timestamp: new Date()
|
||
}]);
|
||
break;
|
||
|
||
case 'cursor-system':
|
||
// Handle Cursor system/init messages similar to Claude
|
||
try {
|
||
const cdata = latestMessage.data;
|
||
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
|
||
// If we already have a session and this differs, switch (duplication/redirect)
|
||
if (currentSessionId && cdata.session_id !== currentSessionId) {
|
||
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
|
||
setIsSystemSessionChange(true);
|
||
if (onNavigateToSession) {
|
||
onNavigateToSession(cdata.session_id);
|
||
}
|
||
return;
|
||
}
|
||
// If we don't yet have a session, adopt this one
|
||
if (!currentSessionId) {
|
||
console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id });
|
||
setIsSystemSessionChange(true);
|
||
if (onNavigateToSession) {
|
||
onNavigateToSession(cdata.session_id);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
// For other cursor-system messages, avoid dumping raw objects to chat
|
||
} catch (e) {
|
||
console.warn('Error handling cursor-system message:', e);
|
||
}
|
||
break;
|
||
|
||
case 'cursor-user':
|
||
// Handle Cursor user messages (usually echoes)
|
||
// Don't add user messages as they're already shown from input
|
||
break;
|
||
|
||
case 'cursor-tool-use':
|
||
// Handle Cursor tool use messages
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`,
|
||
timestamp: new Date(),
|
||
isToolUse: true,
|
||
toolName: latestMessage.tool,
|
||
toolInput: latestMessage.input
|
||
}]);
|
||
break;
|
||
|
||
case 'cursor-error':
|
||
// Show Cursor errors as error messages in chat
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'error',
|
||
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
|
||
timestamp: new Date()
|
||
}]);
|
||
break;
|
||
|
||
case 'cursor-result':
|
||
// Get session ID from message or fall back to current session
|
||
const cursorCompletedSessionId = latestMessage.sessionId || currentSessionId;
|
||
|
||
// Only update UI state if this is the current session
|
||
if (cursorCompletedSessionId === currentSessionId) {
|
||
setIsLoading(false);
|
||
setCanAbortSession(false);
|
||
setClaudeStatus(null);
|
||
}
|
||
|
||
// Always mark the completed session as inactive and not processing
|
||
if (cursorCompletedSessionId) {
|
||
if (onSessionInactive) {
|
||
onSessionInactive(cursorCompletedSessionId);
|
||
}
|
||
if (onSessionNotProcessing) {
|
||
onSessionNotProcessing(cursorCompletedSessionId);
|
||
}
|
||
}
|
||
|
||
// Only process result for current session
|
||
if (cursorCompletedSessionId === currentSessionId) {
|
||
try {
|
||
const r = latestMessage.data || {};
|
||
const textResult = typeof r.result === 'string' ? r.result : '';
|
||
// Flush buffered deltas before finalizing
|
||
if (streamTimerRef.current) {
|
||
clearTimeout(streamTimerRef.current);
|
||
streamTimerRef.current = null;
|
||
}
|
||
const pendingChunk = streamBufferRef.current;
|
||
streamBufferRef.current = '';
|
||
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
// Try to consolidate into the last streaming assistant message
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||
// Replace streaming content with the final content so deltas don't remain
|
||
const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || '');
|
||
last.content = finalContent;
|
||
last.isStreaming = false;
|
||
} else if (textResult && textResult.trim()) {
|
||
updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false });
|
||
}
|
||
return updated;
|
||
});
|
||
} catch (e) {
|
||
console.warn('Error handling cursor-result message:', e);
|
||
}
|
||
}
|
||
|
||
// Store session ID for future use and trigger refresh (for new sessions)
|
||
const pendingCursorSessionId = sessionStorage.getItem('pendingSessionId');
|
||
if (cursorCompletedSessionId && !currentSessionId && cursorCompletedSessionId === pendingCursorSessionId) {
|
||
setCurrentSessionId(cursorCompletedSessionId);
|
||
sessionStorage.removeItem('pendingSessionId');
|
||
|
||
// Trigger a project refresh to update the sidebar with the new session
|
||
if (window.refreshProjects) {
|
||
setTimeout(() => window.refreshProjects(), 500);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'cursor-output':
|
||
// Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads
|
||
try {
|
||
const raw = String(latestMessage.data ?? '');
|
||
const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
|
||
if (cleaned) {
|
||
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
||
if (!streamTimerRef.current) {
|
||
streamTimerRef.current = setTimeout(() => {
|
||
const chunk = streamBufferRef.current;
|
||
streamBufferRef.current = '';
|
||
streamTimerRef.current = null;
|
||
if (!chunk) return;
|
||
setChatMessages(prev => {
|
||
const updated = [...prev];
|
||
const last = updated[updated.length - 1];
|
||
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
||
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
||
} else {
|
||
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
||
}
|
||
return updated;
|
||
});
|
||
}, 100);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('Error handling cursor-output message:', e);
|
||
}
|
||
break;
|
||
|
||
case 'claude-complete':
|
||
// Get session ID from message or fall back to current session
|
||
const completedSessionId = latestMessage.sessionId || currentSessionId || sessionStorage.getItem('pendingSessionId');
|
||
|
||
console.log('🎯 claude-complete received:', {
|
||
completedSessionId,
|
||
currentSessionId,
|
||
match: completedSessionId === currentSessionId,
|
||
isNew: !currentSessionId
|
||
});
|
||
|
||
// Update UI state if this is the current session OR if we don't have a session ID yet (new session)
|
||
if (completedSessionId === currentSessionId || !currentSessionId) {
|
||
console.log('✅ Stopping loading state');
|
||
setIsLoading(false);
|
||
setCanAbortSession(false);
|
||
setClaudeStatus(null);
|
||
|
||
// Fetch updated token usage after message completes
|
||
if (selectedProject && selectedSession?.id) {
|
||
const fetchUpdatedTokenUsage = async () => {
|
||
try {
|
||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||
const response = await authenticatedFetch(url);
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
setTokenBudget(data);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch updated token usage:', error);
|
||
}
|
||
};
|
||
fetchUpdatedTokenUsage();
|
||
}
|
||
}
|
||
|
||
// Always mark the completed session as inactive and not processing
|
||
if (completedSessionId) {
|
||
if (onSessionInactive) {
|
||
onSessionInactive(completedSessionId);
|
||
}
|
||
if (onSessionNotProcessing) {
|
||
onSessionNotProcessing(completedSessionId);
|
||
}
|
||
}
|
||
|
||
// If we have a pending session ID and the conversation completed successfully, use it
|
||
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
||
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
|
||
setCurrentSessionId(pendingSessionId);
|
||
sessionStorage.removeItem('pendingSessionId');
|
||
|
||
// No need to manually refresh - projects_updated WebSocket message will handle it
|
||
console.log('✅ New session complete, ID set to:', pendingSessionId);
|
||
}
|
||
|
||
// Clear persisted chat messages after successful completion
|
||
if (selectedProject && latestMessage.exitCode === 0) {
|
||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||
}
|
||
break;
|
||
|
||
case 'session-aborted': {
|
||
// Get session ID from message or fall back to current session
|
||
const abortedSessionId = latestMessage.sessionId || currentSessionId;
|
||
|
||
// Only update UI state if this is the current session
|
||
if (abortedSessionId === currentSessionId) {
|
||
setIsLoading(false);
|
||
setCanAbortSession(false);
|
||
setClaudeStatus(null);
|
||
}
|
||
|
||
// Always mark the aborted session as inactive and not processing
|
||
if (abortedSessionId) {
|
||
if (onSessionInactive) {
|
||
onSessionInactive(abortedSessionId);
|
||
}
|
||
if (onSessionNotProcessing) {
|
||
onSessionNotProcessing(abortedSessionId);
|
||
}
|
||
}
|
||
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'assistant',
|
||
content: 'Session interrupted by user.',
|
||
timestamp: new Date()
|
||
}]);
|
||
break;
|
||
}
|
||
|
||
case 'session-status': {
|
||
const statusSessionId = latestMessage.sessionId;
|
||
const isCurrentSession = statusSessionId === currentSessionId ||
|
||
(selectedSession && statusSessionId === selectedSession.id);
|
||
if (isCurrentSession && latestMessage.isProcessing) {
|
||
// Session is currently processing, restore UI state
|
||
setIsLoading(true);
|
||
setCanAbortSession(true);
|
||
if (onSessionProcessing) {
|
||
onSessionProcessing(statusSessionId);
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'claude-status':
|
||
// Handle Claude working status messages
|
||
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;
|
||
}
|
||
|
||
setClaudeStatus(statusInfo);
|
||
setIsLoading(true);
|
||
setCanAbortSession(statusInfo.can_interrupt);
|
||
}
|
||
break;
|
||
|
||
}
|
||
}
|
||
}, [messages]);
|
||
|
||
// Load file list when project changes
|
||
useEffect(() => {
|
||
if (selectedProject) {
|
||
fetchProjectFiles();
|
||
}
|
||
}, [selectedProject]);
|
||
|
||
const fetchProjectFiles = async () => {
|
||
try {
|
||
const response = await api.getFiles(selectedProject.name);
|
||
if (response.ok) {
|
||
const files = await response.json();
|
||
// Flatten the file tree to get all file paths
|
||
const flatFiles = flattenFileTree(files);
|
||
setFileList(flatFiles);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching files:', error);
|
||
}
|
||
};
|
||
|
||
const flattenFileTree = (files, basePath = '') => {
|
||
let result = [];
|
||
for (const file of files) {
|
||
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
|
||
if (file.type === 'directory' && file.children) {
|
||
result = result.concat(flattenFileTree(file.children, fullPath));
|
||
} else if (file.type === 'file') {
|
||
result.push({
|
||
name: file.name,
|
||
path: fullPath,
|
||
relativePath: file.path
|
||
});
|
||
}
|
||
}
|
||
return result;
|
||
};
|
||
|
||
// Handle @ symbol detection and file filtering
|
||
useEffect(() => {
|
||
const textBeforeCursor = input.slice(0, cursorPosition);
|
||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||
|
||
if (lastAtIndex !== -1) {
|
||
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
||
// Check if there's a space after the @ symbol (which would end the file reference)
|
||
if (!textAfterAt.includes(' ')) {
|
||
setAtSymbolPosition(lastAtIndex);
|
||
setShowFileDropdown(true);
|
||
|
||
// Filter files based on the text after @
|
||
const filtered = fileList.filter(file =>
|
||
file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
|
||
file.path.toLowerCase().includes(textAfterAt.toLowerCase())
|
||
).slice(0, 10); // Limit to 10 results
|
||
|
||
setFilteredFiles(filtered);
|
||
setSelectedFileIndex(-1);
|
||
} else {
|
||
setShowFileDropdown(false);
|
||
setAtSymbolPosition(-1);
|
||
}
|
||
} else {
|
||
setShowFileDropdown(false);
|
||
setAtSymbolPosition(-1);
|
||
}
|
||
}, [input, cursorPosition, fileList]);
|
||
|
||
// Debounced input handling
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
setDebouncedInput(input);
|
||
}, 150); // 150ms debounce
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [input]);
|
||
|
||
// Show only recent messages for better performance
|
||
const visibleMessages = useMemo(() => {
|
||
if (chatMessages.length <= visibleMessageCount) {
|
||
return chatMessages;
|
||
}
|
||
return chatMessages.slice(-visibleMessageCount);
|
||
}, [chatMessages, visibleMessageCount]);
|
||
|
||
// Capture scroll position before render when auto-scroll is disabled
|
||
useEffect(() => {
|
||
if (!autoScrollToBottom && scrollContainerRef.current) {
|
||
const container = scrollContainerRef.current;
|
||
scrollPositionRef.current = {
|
||
height: container.scrollHeight,
|
||
top: container.scrollTop
|
||
};
|
||
}
|
||
});
|
||
|
||
useEffect(() => {
|
||
// Auto-scroll to bottom when new messages arrive
|
||
if (scrollContainerRef.current && chatMessages.length > 0) {
|
||
if (autoScrollToBottom) {
|
||
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
|
||
if (!isUserScrolledUp) {
|
||
setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
|
||
}
|
||
} 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, autoScrollToBottom]);
|
||
|
||
// Scroll to bottom when messages first load after session switch
|
||
useEffect(() => {
|
||
if (scrollContainerRef.current && chatMessages.length > 0 && !isLoadingSessionRef.current) {
|
||
// Only scroll if we're not in the middle of loading a session
|
||
// This prevents the "double scroll" effect during session switching
|
||
// Also reset scroll state
|
||
setIsUserScrolledUp(false);
|
||
setTimeout(() => scrollToBottom(), 200); // Delay to ensure full rendering
|
||
}
|
||
}, [selectedSession?.id, selectedProject?.name]); // Only trigger when session/project changes
|
||
|
||
// Add scroll event listener to detect user scrolling
|
||
useEffect(() => {
|
||
const scrollContainer = scrollContainerRef.current;
|
||
if (scrollContainer) {
|
||
scrollContainer.addEventListener('scroll', handleScroll);
|
||
return () => scrollContainer.removeEventListener('scroll', handleScroll);
|
||
}
|
||
}, [handleScroll]);
|
||
|
||
// Initial textarea setup - set to 2 rows height
|
||
useEffect(() => {
|
||
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
|
||
|
||
// Reset textarea height when input is cleared programmatically
|
||
useEffect(() => {
|
||
if (textareaRef.current && !input.trim()) {
|
||
textareaRef.current.style.height = 'auto';
|
||
setIsTextareaExpanded(false);
|
||
}
|
||
}, [input]);
|
||
|
||
// Load token usage when session changes (but don't poll to avoid conflicts with WebSocket)
|
||
useEffect(() => {
|
||
if (!selectedProject || !selectedSession?.id || selectedSession.id.startsWith('new-session-')) {
|
||
// Reset for new/empty sessions
|
||
setTokenBudget(null);
|
||
return;
|
||
}
|
||
|
||
// Fetch token usage once when session loads
|
||
const fetchInitialTokenUsage = async () => {
|
||
try {
|
||
const url = `/api/projects/${selectedProject.name}/sessions/${selectedSession.id}/token-usage`;
|
||
console.log('📊 Fetching initial token usage from:', url);
|
||
|
||
const response = await authenticatedFetch(url);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
console.log('✅ Initial token usage loaded:', data);
|
||
setTokenBudget(data);
|
||
} else {
|
||
console.log('⚠️ No token usage data available for this session yet');
|
||
setTokenBudget(null);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch initial token usage:', error);
|
||
}
|
||
};
|
||
|
||
fetchInitialTokenUsage();
|
||
}, [selectedSession?.id, selectedProject?.path]);
|
||
|
||
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;
|
||
});
|
||
}
|
||
}, []);
|
||
|
||
// Load earlier messages by increasing the visible message count
|
||
const loadEarlierMessages = useCallback(() => {
|
||
setVisibleMessageCount(prevCount => prevCount + 100);
|
||
}, []);
|
||
|
||
// Handle image files from drag & drop or file picker
|
||
const handleImageFiles = useCallback((files) => {
|
||
const validFiles = files.filter(file => {
|
||
try {
|
||
// Validate file object and properties
|
||
if (!file || typeof file !== 'object') {
|
||
console.warn('Invalid file object:', file);
|
||
return false;
|
||
}
|
||
|
||
if (!file.type || !file.type.startsWith('image/')) {
|
||
return false;
|
||
}
|
||
|
||
if (!file.size || file.size > 5 * 1024 * 1024) {
|
||
// Safely get file name with fallback
|
||
const fileName = file.name || 'Unknown file';
|
||
setImageErrors(prev => {
|
||
const newMap = new Map(prev);
|
||
newMap.set(fileName, 'File too large (max 5MB)');
|
||
return newMap;
|
||
});
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('Error validating file:', error, file);
|
||
return false;
|
||
}
|
||
});
|
||
|
||
if (validFiles.length > 0) {
|
||
setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images
|
||
}
|
||
}, []);
|
||
|
||
// Handle clipboard paste for images
|
||
const handlePaste = useCallback(async (e) => {
|
||
const items = Array.from(e.clipboardData.items);
|
||
|
||
for (const item of items) {
|
||
if (item.type.startsWith('image/')) {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
handleImageFiles([file]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fallback for some browsers/platforms
|
||
if (items.length === 0 && e.clipboardData.files.length > 0) {
|
||
const files = Array.from(e.clipboardData.files);
|
||
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
||
if (imageFiles.length > 0) {
|
||
handleImageFiles(imageFiles);
|
||
}
|
||
}
|
||
}, [handleImageFiles]);
|
||
|
||
// Setup dropzone
|
||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||
accept: {
|
||
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
|
||
},
|
||
maxSize: 5 * 1024 * 1024, // 5MB
|
||
maxFiles: 5,
|
||
onDrop: handleImageFiles,
|
||
noClick: true, // We'll use our own button
|
||
noKeyboard: true
|
||
});
|
||
|
||
const handleSubmit = useCallback(async (e) => {
|
||
e.preventDefault();
|
||
if (!input.trim() || isLoading || !selectedProject) return;
|
||
|
||
// Upload images first if any
|
||
let uploadedImages = [];
|
||
if (attachedImages.length > 0) {
|
||
const formData = new FormData();
|
||
attachedImages.forEach(file => {
|
||
formData.append('images', file);
|
||
});
|
||
|
||
try {
|
||
const token = safeLocalStorage.getItem('auth-token');
|
||
const headers = {};
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
|
||
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
||
method: 'POST',
|
||
headers: headers,
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to upload images');
|
||
}
|
||
|
||
const result = await response.json();
|
||
uploadedImages = result.images;
|
||
} catch (error) {
|
||
console.error('Image upload failed:', error);
|
||
setChatMessages(prev => [...prev, {
|
||
type: 'error',
|
||
content: `Failed to upload images: ${error.message}`,
|
||
timestamp: new Date()
|
||
}]);
|
||
return;
|
||
}
|
||
}
|
||
|
||
const userMessage = {
|
||
type: 'user',
|
||
content: input,
|
||
images: uploadedImages,
|
||
timestamp: new Date()
|
||
};
|
||
|
||
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 and reset scroll state
|
||
setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
|
||
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
|
||
|
||
// Determine effective session id for replies to avoid race on state updates
|
||
const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
||
|
||
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
||
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
||
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
||
if (onSessionActive) {
|
||
onSessionActive(sessionToActivate);
|
||
}
|
||
|
||
// Get tools settings from localStorage based on provider
|
||
const getToolsSettings = () => {
|
||
try {
|
||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
|
||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||
if (savedSettings) {
|
||
return JSON.parse(savedSettings);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading tools settings:', error);
|
||
}
|
||
return {
|
||
allowedTools: [],
|
||
disallowedTools: [],
|
||
skipPermissions: false
|
||
};
|
||
};
|
||
|
||
const toolsSettings = getToolsSettings();
|
||
|
||
// Send command based on provider
|
||
if (provider === 'cursor') {
|
||
// Send Cursor command (always use cursor-command; include resume/sessionId when replying)
|
||
sendMessage({
|
||
type: 'cursor-command',
|
||
command: input,
|
||
sessionId: effectiveSessionId,
|
||
options: {
|
||
// Prefer fullPath (actual cwd for project), fallback to path
|
||
cwd: selectedProject.fullPath || selectedProject.path,
|
||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||
sessionId: effectiveSessionId,
|
||
resume: !!effectiveSessionId,
|
||
model: cursorModel,
|
||
skipPermissions: toolsSettings?.skipPermissions || false,
|
||
toolsSettings: toolsSettings
|
||
}
|
||
});
|
||
} else {
|
||
// Send Claude command (existing code)
|
||
sendMessage({
|
||
type: 'claude-command',
|
||
command: input,
|
||
options: {
|
||
projectPath: selectedProject.path,
|
||
cwd: selectedProject.fullPath,
|
||
sessionId: currentSessionId,
|
||
resume: !!currentSessionId,
|
||
toolsSettings: toolsSettings,
|
||
permissionMode: permissionMode,
|
||
images: uploadedImages // Pass images to backend
|
||
}
|
||
});
|
||
}
|
||
|
||
setInput('');
|
||
setAttachedImages([]);
|
||
setUploadingImages(new Map());
|
||
setImageErrors(new Map());
|
||
setIsTextareaExpanded(false);
|
||
|
||
// Reset textarea height
|
||
if (textareaRef.current) {
|
||
textareaRef.current.style.height = 'auto';
|
||
}
|
||
|
||
// Clear the saved draft since message was sent
|
||
if (selectedProject) {
|
||
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
||
}
|
||
}, [input, isLoading, selectedProject, attachedImages, currentSessionId, selectedSession, provider, permissionMode, onSessionActive, cursorModel, sendMessage, setInput, setAttachedImages, setUploadingImages, setImageErrors, setIsTextareaExpanded, textareaRef, setChatMessages, setIsLoading, setCanAbortSession, setClaudeStatus, setIsUserScrolledUp, scrollToBottom]);
|
||
|
||
// Store handleSubmit in ref so handleCustomCommand can access it
|
||
useEffect(() => {
|
||
handleSubmitRef.current = handleSubmit;
|
||
}, [handleSubmit]);
|
||
|
||
const selectCommand = (command) => {
|
||
if (!command) return;
|
||
|
||
// Prepare the input with command name and any arguments that were already typed
|
||
const textBeforeSlash = input.slice(0, slashPosition);
|
||
const textAfterSlash = input.slice(slashPosition);
|
||
const spaceIndex = textAfterSlash.indexOf(' ');
|
||
const textAfterQuery = spaceIndex !==-1 ? textAfterSlash.slice(spaceIndex) : '';
|
||
|
||
const newInput = textBeforeSlash + command.name + ' ' + textAfterQuery;
|
||
|
||
// Update input temporarily so executeCommand can parse arguments
|
||
setInput(newInput);
|
||
|
||
// Hide command menu
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
setSelectedCommandIndex(-1);
|
||
|
||
// Clear debounce timer
|
||
if (commandQueryTimerRef.current) {
|
||
clearTimeout(commandQueryTimerRef.current);
|
||
}
|
||
|
||
// Execute the command (which will load its content and send to Claude)
|
||
executeCommand(command);
|
||
};
|
||
|
||
const handleKeyDown = (e) => {
|
||
// Handle command menu navigation
|
||
if (showCommandMenu && filteredCommands.length > 0) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedCommandIndex(prev =>
|
||
prev < filteredCommands.length - 1 ? prev + 1 : 0
|
||
);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedCommandIndex(prev =>
|
||
prev > 0 ? prev - 1 : filteredCommands.length - 1
|
||
);
|
||
return;
|
||
}
|
||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (selectedCommandIndex >= 0) {
|
||
selectCommand(filteredCommands[selectedCommandIndex]);
|
||
} else if (filteredCommands.length > 0) {
|
||
selectCommand(filteredCommands[0]);
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
setSelectedCommandIndex(-1);
|
||
if (commandQueryTimerRef.current) {
|
||
clearTimeout(commandQueryTimerRef.current);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Handle file dropdown navigation
|
||
if (showFileDropdown && filteredFiles.length > 0) {
|
||
if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedFileIndex(prev =>
|
||
prev < filteredFiles.length - 1 ? prev + 1 : 0
|
||
);
|
||
return;
|
||
}
|
||
if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedFileIndex(prev =>
|
||
prev > 0 ? prev - 1 : filteredFiles.length - 1
|
||
);
|
||
return;
|
||
}
|
||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (selectedFileIndex >= 0) {
|
||
selectFile(filteredFiles[selectedFileIndex]);
|
||
} else if (filteredFiles.length > 0) {
|
||
selectFile(filteredFiles[0]);
|
||
}
|
||
return;
|
||
}
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
setShowFileDropdown(false);
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Handle Tab key for mode switching (only when dropdowns are not showing)
|
||
if (e.key === 'Tab' && !showFileDropdown && !showCommandMenu) {
|
||
e.preventDefault();
|
||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||
const currentIndex = modes.indexOf(permissionMode);
|
||
const nextIndex = (currentIndex + 1) % modes.length;
|
||
const newMode = modes[nextIndex];
|
||
setPermissionMode(newMode);
|
||
|
||
// Save mode for this session
|
||
if (selectedSession?.id) {
|
||
localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
||
if (e.key === 'Enter') {
|
||
// If we're in composition, don't send message
|
||
if (e.nativeEvent.isComposing) {
|
||
return; // Let IME handle the Enter key
|
||
}
|
||
|
||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
||
// Ctrl+Enter or Cmd+Enter: Send message
|
||
e.preventDefault();
|
||
handleSubmit(e);
|
||
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||
// Plain Enter: Send message only if not in IME composition
|
||
if (!sendByCtrlEnter) {
|
||
e.preventDefault();
|
||
handleSubmit(e);
|
||
}
|
||
}
|
||
// Shift+Enter: Allow default behavior (new line)
|
||
}
|
||
};
|
||
|
||
const selectFile = (file) => {
|
||
const textBeforeAt = input.slice(0, atSymbolPosition);
|
||
const textAfterAtQuery = input.slice(atSymbolPosition);
|
||
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;
|
||
|
||
// Immediately ensure focus is maintained
|
||
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
||
textareaRef.current.focus();
|
||
}
|
||
|
||
// Update input and cursor position
|
||
setInput(newInput);
|
||
setCursorPosition(newCursorPos);
|
||
|
||
// Hide dropdown
|
||
setShowFileDropdown(false);
|
||
setAtSymbolPosition(-1);
|
||
|
||
// Set cursor position synchronously
|
||
if (textareaRef.current) {
|
||
// Use requestAnimationFrame for smoother updates
|
||
requestAnimationFrame(() => {
|
||
if (textareaRef.current) {
|
||
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||
// Ensure focus is maintained
|
||
if (!textareaRef.current.matches(':focus')) {
|
||
textareaRef.current.focus();
|
||
}
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleInputChange = (e) => {
|
||
const newValue = e.target.value;
|
||
const cursorPos = e.target.selectionStart;
|
||
|
||
// Auto-select Claude provider if no session exists and user starts typing
|
||
if (!currentSessionId && newValue.trim() && provider === 'claude') {
|
||
// Provider is already set to 'claude' by default, so no need to change it
|
||
// The session will be created automatically when they submit
|
||
}
|
||
|
||
setInput(newValue);
|
||
setCursorPosition(cursorPos);
|
||
|
||
// Handle height reset when input becomes empty
|
||
if (!newValue.trim()) {
|
||
e.target.style.height = 'auto';
|
||
setIsTextareaExpanded(false);
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
return;
|
||
}
|
||
|
||
// Detect slash command at cursor position
|
||
// Look backwards from cursor to find a slash that starts a command
|
||
const textBeforeCursor = newValue.slice(0, cursorPos);
|
||
|
||
// Check if we're in a code block (simple heuristic: between triple backticks)
|
||
const backticksBefore = (textBeforeCursor.match(/```/g) || []).length;
|
||
const inCodeBlock = backticksBefore % 2 === 1;
|
||
|
||
if (inCodeBlock) {
|
||
// Don't show command menu in code blocks
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
return;
|
||
}
|
||
|
||
// Find the last slash before cursor that could start a command
|
||
// Slash is valid if it's at the start or preceded by whitespace
|
||
const slashPattern = /(^|\s)\/(\S*)$/;
|
||
const match = textBeforeCursor.match(slashPattern);
|
||
|
||
if (match) {
|
||
const slashPos = match.index + match[1].length; // Position of the slash
|
||
const query = match[2]; // Text after the slash
|
||
|
||
// Update states with debouncing for query
|
||
setSlashPosition(slashPos);
|
||
setShowCommandMenu(true);
|
||
setSelectedCommandIndex(-1);
|
||
|
||
// Debounce the command query update
|
||
if (commandQueryTimerRef.current) {
|
||
clearTimeout(commandQueryTimerRef.current);
|
||
}
|
||
|
||
commandQueryTimerRef.current = setTimeout(() => {
|
||
setCommandQuery(query);
|
||
}, 150); // 150ms debounce
|
||
} else {
|
||
// No slash command detected
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
|
||
if (commandQueryTimerRef.current) {
|
||
clearTimeout(commandQueryTimerRef.current);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTextareaClick = (e) => {
|
||
setCursorPosition(e.target.selectionStart);
|
||
};
|
||
|
||
|
||
|
||
const handleNewSession = () => {
|
||
setChatMessages([]);
|
||
setInput('');
|
||
setIsLoading(false);
|
||
setCanAbortSession(false);
|
||
};
|
||
|
||
const handleAbortSession = () => {
|
||
if (currentSessionId && canAbortSession) {
|
||
sendMessage({
|
||
type: 'abort-session',
|
||
sessionId: currentSessionId,
|
||
provider: provider
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleModeSwitch = () => {
|
||
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
||
const currentIndex = modes.indexOf(permissionMode);
|
||
const nextIndex = (currentIndex + 1) % modes.length;
|
||
const newMode = modes[nextIndex];
|
||
setPermissionMode(newMode);
|
||
|
||
// Save mode for this session
|
||
if (selectedSession?.id) {
|
||
localStorage.setItem(`permissionMode-${selectedSession.id}`, newMode);
|
||
}
|
||
};
|
||
|
||
// Don't render if no project is selected
|
||
if (!selectedProject) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||
<p>Select a project to start chatting with Claude</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<style>
|
||
{`
|
||
details[open] .details-chevron {
|
||
transform: rotate(180deg);
|
||
}
|
||
`}
|
||
</style>
|
||
<div className="h-full flex flex-col">
|
||
{/* Messages Area - Scrollable Middle Section */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
||
>
|
||
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
||
<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>
|
||
</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>
|
||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||
Select a provider to start a new conversation
|
||
</p>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
|
||
{/* Claude Button */}
|
||
<button
|
||
onClick={() => {
|
||
setProvider('claude');
|
||
localStorage.setItem('selected-provider', 'claude');
|
||
// Focus input after selection
|
||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||
}}
|
||
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
|
||
provider === 'claude'
|
||
? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
|
||
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
|
||
}`}
|
||
>
|
||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
{provider === 'claude' && (
|
||
<div className="absolute top-2 right-2">
|
||
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
|
||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</button>
|
||
|
||
{/* Cursor Button */}
|
||
<button
|
||
onClick={() => {
|
||
setProvider('cursor');
|
||
localStorage.setItem('selected-provider', 'cursor');
|
||
// Focus input after selection
|
||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||
}}
|
||
className={`group relative w-64 h-32 bg-white dark:bg-gray-800 rounded-xl border-2 transition-all duration-200 hover:scale-105 hover:shadow-xl ${
|
||
provider === 'cursor'
|
||
? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
|
||
: 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
|
||
}`}
|
||
>
|
||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||
<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>
|
||
</div>
|
||
</div>
|
||
{provider === 'cursor' && (
|
||
<div className="absolute top-2 right-2">
|
||
<div className="w-5 h-5 bg-purple-500 rounded-full flex items-center justify-center">
|
||
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Model Selection for Cursor - Always reserve space to prevent jumping */}
|
||
<div className={`mb-6 transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||
{provider === 'cursor' ? 'Select Model' : '\u00A0'}
|
||
</label>
|
||
<select
|
||
value={cursorModel}
|
||
onChange={(e) => {
|
||
const newModel = e.target.value;
|
||
setCursorModel(newModel);
|
||
localStorage.setItem('cursor-model', newModel);
|
||
}}
|
||
className="pl-4 pr-10 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 min-w-[140px]"
|
||
disabled={provider !== 'cursor'}
|
||
>
|
||
<option value="gpt-5">GPT-5</option>
|
||
<option value="sonnet-4">Sonnet-4</option>
|
||
<option value="opus-4.1">Opus 4.1</option>
|
||
</select>
|
||
</div>
|
||
|
||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||
{provider === 'claude'
|
||
? 'Ready to use Claude AI. Start typing your message below.'
|
||
: provider === 'cursor'
|
||
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
|
||
: 'Select a provider above to begin'
|
||
}
|
||
</p>
|
||
|
||
{/* Show NextTaskBanner when provider is selected and ready */}
|
||
{provider && tasksEnabled && (
|
||
<div className="mt-4 px-4 sm:px-0">
|
||
<NextTaskBanner
|
||
onStartTask={() => setInput('Start the next task')}
|
||
onShowAllTasks={onShowAllTasks}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{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="text-sm sm:text-base leading-relaxed">
|
||
Ask questions about your code, request changes, or get help with development tasks
|
||
</p>
|
||
|
||
{/* Show NextTaskBanner for existing sessions too */}
|
||
{tasksEnabled && (
|
||
<div className="mt-4 px-4 sm:px-0">
|
||
<NextTaskBanner
|
||
onStartTask={() => setInput('Start the next task')}
|
||
onShowAllTasks={onShowAllTasks}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Loading indicator for older messages */}
|
||
{isLoadingMoreMessages && (
|
||
<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>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Indicator showing there are more messages to load */}
|
||
{hasMoreMessages && !isLoadingMoreMessages && (
|
||
<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>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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
|
||
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
||
onClick={loadEarlierMessages}
|
||
>
|
||
Load earlier messages
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{visibleMessages.map((message, index) => {
|
||
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
||
|
||
return (
|
||
<MessageComponent
|
||
key={index}
|
||
message={message}
|
||
index={index}
|
||
prevMessage={prevMessage}
|
||
createDiff={createDiff}
|
||
onFileOpen={onFileOpen}
|
||
onShowSettings={onShowSettings}
|
||
autoExpandTools={autoExpandTools}
|
||
showRawParameters={showRawParameters}
|
||
showThinking={showThinking}
|
||
/>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
|
||
{isLoading && (
|
||
<div className="chat-message assistant">
|
||
<div className="w-full">
|
||
<div className="flex items-center space-x-3 mb-2">
|
||
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
||
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
||
<CursorLogo className="w-full h-full" />
|
||
) : (
|
||
<ClaudeLogo className="w-full h-full" />
|
||
)}
|
||
</div>
|
||
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
|
||
{/* Abort button removed - functionality not yet implemented at backend */}
|
||
</div>
|
||
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
||
<div className="flex items-center space-x-1">
|
||
<div className="animate-pulse">●</div>
|
||
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>●</div>
|
||
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>●</div>
|
||
<span className="ml-2">Thinking...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div ref={messagesEndRef} />
|
||
</div>
|
||
|
||
|
||
{/* Input Area - Fixed Bottom */}
|
||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-2 sm:pb-4 md:pb-6'
|
||
}`}>
|
||
|
||
<div className="flex-1">
|
||
<ClaudeStatus
|
||
status={claudeStatus}
|
||
isLoading={isLoading}
|
||
onAbort={handleAbortSession}
|
||
provider={provider}
|
||
showThinking={showThinking}
|
||
/>
|
||
</div>
|
||
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
|
||
<div className="flex items-center justify-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleModeSwitch}
|
||
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
|
||
permissionMode === 'default'
|
||
? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||
: permissionMode === 'acceptEdits'
|
||
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300 border-green-300 dark:border-green-600 hover:bg-green-100 dark:hover:bg-green-900/30'
|
||
: permissionMode === 'bypassPermissions'
|
||
? '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)"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className={`w-2 h-2 rounded-full ${
|
||
permissionMode === 'default'
|
||
? 'bg-gray-500'
|
||
: permissionMode === 'acceptEdits'
|
||
? 'bg-green-500'
|
||
: permissionMode === 'bypassPermissions'
|
||
? 'bg-orange-500'
|
||
: 'bg-blue-500'
|
||
}`} />
|
||
<span>
|
||
{permissionMode === 'default' && 'Default Mode'}
|
||
{permissionMode === 'acceptEdits' && 'Accept Edits'}
|
||
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
|
||
{permissionMode === 'plan' && 'Plan Mode'}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
{/* Token usage pie chart - positioned next to mode indicator */}
|
||
<TokenUsagePie
|
||
used={tokenBudget?.used || 0}
|
||
total={tokenBudget?.total || parseInt(import.meta.env.VITE_CONTEXT_WINDOW) || 160000}
|
||
/>
|
||
|
||
{/* Clear input button - positioned to the right of token pie, only shows when there's input */}
|
||
{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);
|
||
}}
|
||
className="w-8 h-8 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group shadow-sm"
|
||
title="Clear input"
|
||
>
|
||
<svg
|
||
className="w-4 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>
|
||
)}
|
||
|
||
{/* Scroll to bottom button - positioned next to mode indicator */}
|
||
{isUserScrolledUp && chatMessages.length > 0 && (
|
||
<button
|
||
onClick={scrollToBottom}
|
||
className="w-8 h-8 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"
|
||
title="Scroll to bottom"
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
||
{/* Drag overlay */}
|
||
{isDragActive && (
|
||
<div className="absolute inset-0 bg-blue-500/20 border-2 border-dashed border-blue-500 rounded-lg flex items-center justify-center z-50">
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
||
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||
</svg>
|
||
<p className="text-sm font-medium">Drop images here</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Image attachments preview */}
|
||
{attachedImages.length > 0 && (
|
||
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||
<div className="flex flex-wrap gap-2">
|
||
{attachedImages.map((file, index) => (
|
||
<ImageAttachment
|
||
key={index}
|
||
file={file}
|
||
onRemove={() => {
|
||
setAttachedImages(prev => prev.filter((_, i) => i !== index));
|
||
}}
|
||
uploadProgress={uploadingImages.get(file.name)}
|
||
error={imageErrors.get(file.name)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* File dropdown - positioned outside dropzone to avoid conflicts */}
|
||
{showFileDropdown && filteredFiles.length > 0 && (
|
||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg max-h-48 overflow-y-auto z-50 backdrop-blur-sm">
|
||
{filteredFiles.map((file, index) => (
|
||
<div
|
||
key={file.path}
|
||
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
|
||
index === selectedFileIndex
|
||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||
}`}
|
||
onMouseDown={(e) => {
|
||
// Prevent textarea from losing focus on mobile
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
selectFile(file);
|
||
}}
|
||
>
|
||
<div className="font-medium text-sm">{file.name}</div>
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
||
{file.path}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Command Menu */}
|
||
<CommandMenu
|
||
commands={filteredCommands}
|
||
selectedIndex={selectedCommandIndex}
|
||
onSelect={handleCommandSelect}
|
||
onClose={() => {
|
||
setShowCommandMenu(false);
|
||
setSlashPosition(-1);
|
||
setCommandQuery('');
|
||
setSelectedCommandIndex(-1);
|
||
}}
|
||
position={{
|
||
top: textareaRef.current
|
||
? Math.max(16, textareaRef.current.getBoundingClientRect().top - 316)
|
||
: 0,
|
||
left: textareaRef.current
|
||
? textareaRef.current.getBoundingClientRect().left
|
||
: 16,
|
||
bottom: textareaRef.current
|
||
? window.innerHeight - textareaRef.current.getBoundingClientRect().top + 8
|
||
: 90
|
||
}}
|
||
isOpen={showCommandMenu}
|
||
frequentCommands={commandQuery ? [] : frequentCommands}
|
||
/>
|
||
|
||
<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()} />
|
||
<textarea
|
||
ref={textareaRef}
|
||
value={input}
|
||
onChange={handleInputChange}
|
||
onClick={handleTextareaClick}
|
||
onKeyDown={handleKeyDown}
|
||
onPaste={handlePaste}
|
||
onFocus={() => setIsInputFocused(true)}
|
||
onBlur={() => setIsInputFocused(false)}
|
||
onInput={(e) => {
|
||
// Immediate resize on input for better UX
|
||
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={`Type / for commands, @ for files, or ask ${provider === 'cursor' ? 'Cursor' : 'Claude'} anything...`}
|
||
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' }}
|
||
/>
|
||
{/* Image upload button */}
|
||
<button
|
||
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"
|
||
>
|
||
<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" />
|
||
</svg>
|
||
</button>
|
||
|
||
{/* Mic button - HIDDEN */}
|
||
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
||
<MicButton
|
||
onTranscript={handleTranscript}
|
||
className="w-10 h-10 sm:w-10 sm:h-10"
|
||
/>
|
||
</div>
|
||
|
||
{/* Slash commands button */}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const isOpening = !showCommandMenu;
|
||
setShowCommandMenu(isOpening);
|
||
setCommandQuery('');
|
||
setSelectedCommandIndex(-1);
|
||
|
||
// When opening, ensure all commands are shown
|
||
if (isOpening) {
|
||
setFilteredCommands(slashCommands);
|
||
}
|
||
|
||
if (textareaRef.current) {
|
||
textareaRef.current.focus();
|
||
}
|
||
}}
|
||
className="absolute right-14 sm:right-36 top-1/2 transform -translate-y-1/2 w-10 h-10 sm:w-10 sm:h-10 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 relative z-10"
|
||
title="Show all commands"
|
||
>
|
||
<svg
|
||
className="w-5 h-5"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||
/>
|
||
</svg>
|
||
{/* Command count badge */}
|
||
{slashCommands.length > 0 && (
|
||
<span
|
||
className="absolute -top-1 -right-1 bg-blue-600 text-white text-xs font-bold rounded-full w-5 h-5 flex items-center justify-center"
|
||
style={{ fontSize: '10px' }}
|
||
>
|
||
{slashCommands.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
|
||
{/* Send button */}
|
||
<button
|
||
type="submit"
|
||
disabled={!input.trim() || isLoading}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault();
|
||
handleSubmit(e);
|
||
}}
|
||
onTouchStart={(e) => {
|
||
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 dark:ring-offset-gray-800"
|
||
>
|
||
<svg
|
||
className="w-4 h-4 sm:w-5 sm:h-5 text-white transform rotate-90"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
viewBox="0 0 24 24"
|
||
>
|
||
<path
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
strokeWidth={2}
|
||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
{/* Hint text */}
|
||
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
||
{sendByCtrlEnter
|
||
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • Type / for slash commands"
|
||
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • Type / for slash commands"}
|
||
</div>
|
||
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
||
isInputFocused ? 'opacity-100' : 'opacity-0'
|
||
}`}>
|
||
{sendByCtrlEnter
|
||
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files • / for commands"
|
||
: "Enter to send • Tab for modes • @ for files • / for commands"}
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default React.memo(ChatInterface);
|