@siteboon/claude-code-ui 1.8.2 → 1.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-BNGSzSdr.css +32 -0
- package/dist/assets/index-BZctMHnE.js +900 -0
- package/{index.html → dist/index.html} +4 -3
- package/package.json +6 -1
- package/server/database/auth.db +0 -0
- package/.env.example +0 -12
- package/.nvmrc +0 -1
- package/postcss.config.js +0 -6
- package/src/App.jsx +0 -751
- package/src/components/ChatInterface.jsx +0 -3485
- package/src/components/ClaudeLogo.jsx +0 -11
- package/src/components/ClaudeStatus.jsx +0 -107
- package/src/components/CodeEditor.jsx +0 -422
- package/src/components/CreateTaskModal.jsx +0 -88
- package/src/components/CursorLogo.jsx +0 -9
- package/src/components/DarkModeToggle.jsx +0 -35
- package/src/components/DiffViewer.jsx +0 -41
- package/src/components/ErrorBoundary.jsx +0 -73
- package/src/components/FileTree.jsx +0 -480
- package/src/components/GitPanel.jsx +0 -1283
- package/src/components/ImageViewer.jsx +0 -54
- package/src/components/LoginForm.jsx +0 -110
- package/src/components/MainContent.jsx +0 -577
- package/src/components/MicButton.jsx +0 -272
- package/src/components/MobileNav.jsx +0 -88
- package/src/components/NextTaskBanner.jsx +0 -695
- package/src/components/PRDEditor.jsx +0 -871
- package/src/components/ProtectedRoute.jsx +0 -44
- package/src/components/QuickSettingsPanel.jsx +0 -262
- package/src/components/Settings.jsx +0 -2023
- package/src/components/SetupForm.jsx +0 -135
- package/src/components/Shell.jsx +0 -663
- package/src/components/Sidebar.jsx +0 -1665
- package/src/components/StandaloneShell.jsx +0 -106
- package/src/components/TaskCard.jsx +0 -210
- package/src/components/TaskDetail.jsx +0 -406
- package/src/components/TaskIndicator.jsx +0 -108
- package/src/components/TaskList.jsx +0 -1054
- package/src/components/TaskMasterSetupWizard.jsx +0 -603
- package/src/components/TaskMasterStatus.jsx +0 -86
- package/src/components/TodoList.jsx +0 -91
- package/src/components/Tooltip.jsx +0 -91
- package/src/components/ui/badge.jsx +0 -31
- package/src/components/ui/button.jsx +0 -46
- package/src/components/ui/input.jsx +0 -19
- package/src/components/ui/scroll-area.jsx +0 -23
- package/src/contexts/AuthContext.jsx +0 -158
- package/src/contexts/TaskMasterContext.jsx +0 -324
- package/src/contexts/TasksSettingsContext.jsx +0 -95
- package/src/contexts/ThemeContext.jsx +0 -94
- package/src/contexts/WebSocketContext.jsx +0 -29
- package/src/hooks/useAudioRecorder.js +0 -109
- package/src/hooks/useVersionCheck.js +0 -39
- package/src/index.css +0 -822
- package/src/lib/utils.js +0 -6
- package/src/main.jsx +0 -10
- package/src/utils/api.js +0 -141
- package/src/utils/websocket.js +0 -109
- package/src/utils/whisper.js +0 -37
- package/tailwind.config.js +0 -63
- package/vite.config.js +0 -29
- /package/{public → dist}/convert-icons.md +0 -0
- /package/{public → dist}/favicon.png +0 -0
- /package/{public → dist}/favicon.svg +0 -0
- /package/{public → dist}/generate-icons.js +0 -0
- /package/{public → dist}/icons/claude-ai-icon.svg +0 -0
- /package/{public → dist}/icons/cursor.svg +0 -0
- /package/{public → dist}/icons/generate-icons.md +0 -0
- /package/{public → dist}/icons/icon-128x128.png +0 -0
- /package/{public → dist}/icons/icon-128x128.svg +0 -0
- /package/{public → dist}/icons/icon-144x144.png +0 -0
- /package/{public → dist}/icons/icon-144x144.svg +0 -0
- /package/{public → dist}/icons/icon-152x152.png +0 -0
- /package/{public → dist}/icons/icon-152x152.svg +0 -0
- /package/{public → dist}/icons/icon-192x192.png +0 -0
- /package/{public → dist}/icons/icon-192x192.svg +0 -0
- /package/{public → dist}/icons/icon-384x384.png +0 -0
- /package/{public → dist}/icons/icon-384x384.svg +0 -0
- /package/{public → dist}/icons/icon-512x512.png +0 -0
- /package/{public → dist}/icons/icon-512x512.svg +0 -0
- /package/{public → dist}/icons/icon-72x72.png +0 -0
- /package/{public → dist}/icons/icon-72x72.svg +0 -0
- /package/{public → dist}/icons/icon-96x96.png +0 -0
- /package/{public → dist}/icons/icon-96x96.svg +0 -0
- /package/{public → dist}/icons/icon-template.svg +0 -0
- /package/{public → dist}/logo.svg +0 -0
- /package/{public → dist}/manifest.json +0 -0
- /package/{public → dist}/screenshots/cli-selection.png +0 -0
- /package/{public → dist}/screenshots/desktop-main.png +0 -0
- /package/{public → dist}/screenshots/mobile-chat.png +0 -0
- /package/{public → dist}/screenshots/tools-modal.png +0 -0
- /package/{public → dist}/sw.js +0 -0
|
@@ -1,3485 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* ChatInterface.jsx - Chat Component with Session Protection Integration
|
|
3
|
-
*
|
|
4
|
-
* SESSION PROTECTION INTEGRATION:
|
|
5
|
-
* ===============================
|
|
6
|
-
*
|
|
7
|
-
* This component integrates with the Session Protection System to prevent project updates
|
|
8
|
-
* from interrupting active conversations:
|
|
9
|
-
*
|
|
10
|
-
* Key Integration Points:
|
|
11
|
-
* 1. handleSubmit() - Marks session as active when user sends message (including temp ID for new sessions)
|
|
12
|
-
* 2. session-created handler - Replaces temporary session ID with real WebSocket session ID
|
|
13
|
-
* 3. claude-complete handler - Marks session as inactive when conversation finishes
|
|
14
|
-
* 4. session-aborted handler - Marks session as inactive when conversation is aborted
|
|
15
|
-
*
|
|
16
|
-
* This ensures uninterrupted chat experience by coordinating with App.jsx to pause sidebar updates.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import React, { useState, useEffect, useRef, useMemo, useCallback, memo } from 'react';
|
|
20
|
-
import ReactMarkdown from 'react-markdown';
|
|
21
|
-
import { useDropzone } from 'react-dropzone';
|
|
22
|
-
import TodoList from './TodoList';
|
|
23
|
-
import ClaudeLogo from './ClaudeLogo.jsx';
|
|
24
|
-
import CursorLogo from './CursorLogo.jsx';
|
|
25
|
-
import NextTaskBanner from './NextTaskBanner.jsx';
|
|
26
|
-
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
|
27
|
-
|
|
28
|
-
import ClaudeStatus from './ClaudeStatus';
|
|
29
|
-
import { MicButton } from './MicButton.jsx';
|
|
30
|
-
import { api, authenticatedFetch } from '../utils/api';
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// Format "Claude AI usage limit reached|<epoch>" into a local time string
|
|
34
|
-
function formatUsageLimitText(text) {
|
|
35
|
-
try {
|
|
36
|
-
if (typeof text !== 'string') return text;
|
|
37
|
-
return text.replace(/Claude AI usage limit reached\|(\d{10,13})/g, (match, ts) => {
|
|
38
|
-
let timestampMs = parseInt(ts, 10);
|
|
39
|
-
if (!Number.isFinite(timestampMs)) return match;
|
|
40
|
-
if (timestampMs < 1e12) timestampMs *= 1000; // seconds → ms
|
|
41
|
-
const reset = new Date(timestampMs);
|
|
42
|
-
|
|
43
|
-
// Time HH:mm in local time
|
|
44
|
-
const timeStr = new Intl.DateTimeFormat(undefined, {
|
|
45
|
-
hour: '2-digit',
|
|
46
|
-
minute: '2-digit',
|
|
47
|
-
hour12: false
|
|
48
|
-
}).format(reset);
|
|
49
|
-
|
|
50
|
-
// Human-readable timezone: GMT±HH[:MM] (City)
|
|
51
|
-
const offsetMinutesLocal = -reset.getTimezoneOffset();
|
|
52
|
-
const sign = offsetMinutesLocal >= 0 ? '+' : '-';
|
|
53
|
-
const abs = Math.abs(offsetMinutesLocal);
|
|
54
|
-
const offH = Math.floor(abs / 60);
|
|
55
|
-
const offM = abs % 60;
|
|
56
|
-
const gmt = `GMT${sign}${offH}${offM ? ':' + String(offM).padStart(2, '0') : ''}`;
|
|
57
|
-
const tzId = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
|
58
|
-
const cityRaw = tzId.split('/').pop() || '';
|
|
59
|
-
const city = cityRaw
|
|
60
|
-
.replace(/_/g, ' ')
|
|
61
|
-
.toLowerCase()
|
|
62
|
-
.replace(/\b\w/g, c => c.toUpperCase());
|
|
63
|
-
const tzHuman = city ? `${gmt} (${city})` : gmt;
|
|
64
|
-
|
|
65
|
-
// Readable date like "8 Jun 2025"
|
|
66
|
-
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
67
|
-
const dateReadable = `${reset.getDate()} ${months[reset.getMonth()]} ${reset.getFullYear()}`;
|
|
68
|
-
|
|
69
|
-
return `Claude usage limit reached. Your limit will reset at **${timeStr} ${tzHuman}** - ${dateReadable}`;
|
|
70
|
-
});
|
|
71
|
-
} catch {
|
|
72
|
-
return text;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Safe localStorage utility to handle quota exceeded errors
|
|
77
|
-
const safeLocalStorage = {
|
|
78
|
-
setItem: (key, value) => {
|
|
79
|
-
try {
|
|
80
|
-
// For chat messages, implement compression and size limits
|
|
81
|
-
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
|
82
|
-
try {
|
|
83
|
-
const parsed = JSON.parse(value);
|
|
84
|
-
// Limit to last 50 messages to prevent storage bloat
|
|
85
|
-
if (Array.isArray(parsed) && parsed.length > 50) {
|
|
86
|
-
console.warn(`Truncating chat history for ${key} from ${parsed.length} to 50 messages`);
|
|
87
|
-
const truncated = parsed.slice(-50);
|
|
88
|
-
value = JSON.stringify(truncated);
|
|
89
|
-
}
|
|
90
|
-
} catch (parseError) {
|
|
91
|
-
console.warn('Could not parse chat messages for truncation:', parseError);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
localStorage.setItem(key, value);
|
|
96
|
-
} catch (error) {
|
|
97
|
-
if (error.name === 'QuotaExceededError') {
|
|
98
|
-
console.warn('localStorage quota exceeded, clearing old data');
|
|
99
|
-
// Clear old chat messages to free up space
|
|
100
|
-
const keys = Object.keys(localStorage);
|
|
101
|
-
const chatKeys = keys.filter(k => k.startsWith('chat_messages_')).sort();
|
|
102
|
-
|
|
103
|
-
// Remove oldest chat data first, keeping only the 3 most recent projects
|
|
104
|
-
if (chatKeys.length > 3) {
|
|
105
|
-
chatKeys.slice(0, chatKeys.length - 3).forEach(k => {
|
|
106
|
-
localStorage.removeItem(k);
|
|
107
|
-
console.log(`Removed old chat data: ${k}`);
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// If still failing, clear draft inputs too
|
|
112
|
-
const draftKeys = keys.filter(k => k.startsWith('draft_input_'));
|
|
113
|
-
draftKeys.forEach(k => {
|
|
114
|
-
localStorage.removeItem(k);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Try again with reduced data
|
|
118
|
-
try {
|
|
119
|
-
localStorage.setItem(key, value);
|
|
120
|
-
} catch (retryError) {
|
|
121
|
-
console.error('Failed to save to localStorage even after cleanup:', retryError);
|
|
122
|
-
// Last resort: Try to save just the last 10 messages
|
|
123
|
-
if (key.startsWith('chat_messages_') && typeof value === 'string') {
|
|
124
|
-
try {
|
|
125
|
-
const parsed = JSON.parse(value);
|
|
126
|
-
if (Array.isArray(parsed) && parsed.length > 10) {
|
|
127
|
-
const minimal = parsed.slice(-10);
|
|
128
|
-
localStorage.setItem(key, JSON.stringify(minimal));
|
|
129
|
-
console.warn('Saved only last 10 messages due to quota constraints');
|
|
130
|
-
}
|
|
131
|
-
} catch (finalError) {
|
|
132
|
-
console.error('Final save attempt failed:', finalError);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
console.error('localStorage error:', error);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
},
|
|
141
|
-
getItem: (key) => {
|
|
142
|
-
try {
|
|
143
|
-
return localStorage.getItem(key);
|
|
144
|
-
} catch (error) {
|
|
145
|
-
console.error('localStorage getItem error:', error);
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
},
|
|
149
|
-
removeItem: (key) => {
|
|
150
|
-
try {
|
|
151
|
-
localStorage.removeItem(key);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
console.error('localStorage removeItem error:', error);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
// Memoized message component to prevent unnecessary re-renders
|
|
159
|
-
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
|
160
|
-
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
|
161
|
-
((prevMessage.type === 'assistant') ||
|
|
162
|
-
(prevMessage.type === 'user') ||
|
|
163
|
-
(prevMessage.type === 'tool') ||
|
|
164
|
-
(prevMessage.type === 'error'));
|
|
165
|
-
const messageRef = React.useRef(null);
|
|
166
|
-
const [isExpanded, setIsExpanded] = React.useState(false);
|
|
167
|
-
React.useEffect(() => {
|
|
168
|
-
if (!autoExpandTools || !messageRef.current || !message.isToolUse) return;
|
|
169
|
-
|
|
170
|
-
const observer = new IntersectionObserver(
|
|
171
|
-
(entries) => {
|
|
172
|
-
entries.forEach((entry) => {
|
|
173
|
-
if (entry.isIntersecting && !isExpanded) {
|
|
174
|
-
setIsExpanded(true);
|
|
175
|
-
// Find all details elements and open them
|
|
176
|
-
const details = messageRef.current.querySelectorAll('details');
|
|
177
|
-
details.forEach(detail => {
|
|
178
|
-
detail.open = true;
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
});
|
|
182
|
-
},
|
|
183
|
-
{ threshold: 0.1 }
|
|
184
|
-
);
|
|
185
|
-
|
|
186
|
-
observer.observe(messageRef.current);
|
|
187
|
-
|
|
188
|
-
return () => {
|
|
189
|
-
if (messageRef.current) {
|
|
190
|
-
observer.unobserve(messageRef.current);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
}, [autoExpandTools, isExpanded, message.isToolUse]);
|
|
194
|
-
|
|
195
|
-
return (
|
|
196
|
-
<div
|
|
197
|
-
ref={messageRef}
|
|
198
|
-
className={`chat-message ${message.type} ${isGrouped ? 'grouped' : ''} ${message.type === 'user' ? 'flex justify-end px-3 sm:px-0' : 'px-3 sm:px-0'}`}
|
|
199
|
-
>
|
|
200
|
-
{message.type === 'user' ? (
|
|
201
|
-
/* User message bubble on the right */
|
|
202
|
-
<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">
|
|
203
|
-
<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">
|
|
204
|
-
<div className="text-sm whitespace-pre-wrap break-words">
|
|
205
|
-
{message.content}
|
|
206
|
-
</div>
|
|
207
|
-
{message.images && message.images.length > 0 && (
|
|
208
|
-
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
209
|
-
{message.images.map((img, idx) => (
|
|
210
|
-
<img
|
|
211
|
-
key={idx}
|
|
212
|
-
src={img.data}
|
|
213
|
-
alt={img.name}
|
|
214
|
-
className="rounded-lg max-w-full h-auto cursor-pointer hover:opacity-90 transition-opacity"
|
|
215
|
-
onClick={() => window.open(img.data, '_blank')}
|
|
216
|
-
/>
|
|
217
|
-
))}
|
|
218
|
-
</div>
|
|
219
|
-
)}
|
|
220
|
-
<div className="text-xs text-blue-100 mt-1 text-right">
|
|
221
|
-
{new Date(message.timestamp).toLocaleTimeString()}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
{!isGrouped && (
|
|
225
|
-
<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">
|
|
226
|
-
U
|
|
227
|
-
</div>
|
|
228
|
-
)}
|
|
229
|
-
</div>
|
|
230
|
-
) : (
|
|
231
|
-
/* Claude/Error/Tool messages on the left */
|
|
232
|
-
<div className="w-full">
|
|
233
|
-
{!isGrouped && (
|
|
234
|
-
<div className="flex items-center space-x-3 mb-2">
|
|
235
|
-
{message.type === 'error' ? (
|
|
236
|
-
<div className="w-8 h-8 bg-red-600 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0">
|
|
237
|
-
!
|
|
238
|
-
</div>
|
|
239
|
-
) : message.type === 'tool' ? (
|
|
240
|
-
<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">
|
|
241
|
-
🔧
|
|
242
|
-
</div>
|
|
243
|
-
) : (
|
|
244
|
-
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1">
|
|
245
|
-
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
|
246
|
-
<CursorLogo className="w-full h-full" />
|
|
247
|
-
) : (
|
|
248
|
-
<ClaudeLogo className="w-full h-full" />
|
|
249
|
-
)}
|
|
250
|
-
</div>
|
|
251
|
-
)}
|
|
252
|
-
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
253
|
-
{message.type === 'error' ? 'Error' : message.type === 'tool' ? 'Tool' : ((localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude')}
|
|
254
|
-
</div>
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
|
|
258
|
-
<div className="w-full">
|
|
259
|
-
|
|
260
|
-
{message.isToolUse && !['Read', 'TodoWrite', 'TodoRead'].includes(message.toolName) ? (
|
|
261
|
-
<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">
|
|
262
|
-
<div className="flex items-center justify-between mb-2">
|
|
263
|
-
<div className="flex items-center gap-2">
|
|
264
|
-
<div className="w-5 h-5 bg-blue-600 rounded flex items-center justify-center">
|
|
265
|
-
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
266
|
-
<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" />
|
|
267
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
268
|
-
</svg>
|
|
269
|
-
</div>
|
|
270
|
-
<span className="font-medium text-blue-900 dark:text-blue-100">
|
|
271
|
-
Using {message.toolName}
|
|
272
|
-
</span>
|
|
273
|
-
<span className="text-xs text-blue-600 dark:text-blue-400 font-mono">
|
|
274
|
-
{message.toolId}
|
|
275
|
-
</span>
|
|
276
|
-
</div>
|
|
277
|
-
{onShowSettings && (
|
|
278
|
-
<button
|
|
279
|
-
onClick={(e) => {
|
|
280
|
-
e.stopPropagation();
|
|
281
|
-
onShowSettings();
|
|
282
|
-
}}
|
|
283
|
-
className="p-1 rounded hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors"
|
|
284
|
-
title="Tool Settings"
|
|
285
|
-
>
|
|
286
|
-
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
287
|
-
<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" />
|
|
288
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
289
|
-
</svg>
|
|
290
|
-
</button>
|
|
291
|
-
)}
|
|
292
|
-
</div>
|
|
293
|
-
{message.toolInput && message.toolName === 'Edit' && (() => {
|
|
294
|
-
try {
|
|
295
|
-
const input = JSON.parse(message.toolInput);
|
|
296
|
-
if (input.file_path && input.old_string && input.new_string) {
|
|
297
|
-
return (
|
|
298
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
299
|
-
<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">
|
|
300
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
301
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
302
|
-
</svg>
|
|
303
|
-
📝 View edit diff for
|
|
304
|
-
<button
|
|
305
|
-
onClick={(e) => {
|
|
306
|
-
e.preventDefault();
|
|
307
|
-
e.stopPropagation();
|
|
308
|
-
onFileOpen && onFileOpen(input.file_path, {
|
|
309
|
-
old_string: input.old_string,
|
|
310
|
-
new_string: input.new_string
|
|
311
|
-
});
|
|
312
|
-
}}
|
|
313
|
-
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
|
314
|
-
>
|
|
315
|
-
{input.file_path.split('/').pop()}
|
|
316
|
-
</button>
|
|
317
|
-
</summary>
|
|
318
|
-
<div className="mt-3">
|
|
319
|
-
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
320
|
-
<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">
|
|
321
|
-
<button
|
|
322
|
-
onClick={() => onFileOpen && onFileOpen(input.file_path, {
|
|
323
|
-
old_string: input.old_string,
|
|
324
|
-
new_string: input.new_string
|
|
325
|
-
})}
|
|
326
|
-
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"
|
|
327
|
-
>
|
|
328
|
-
{input.file_path}
|
|
329
|
-
</button>
|
|
330
|
-
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
331
|
-
Diff
|
|
332
|
-
</span>
|
|
333
|
-
</div>
|
|
334
|
-
<div className="text-xs font-mono">
|
|
335
|
-
{createDiff(input.old_string, input.new_string).map((diffLine, i) => (
|
|
336
|
-
<div key={i} className="flex">
|
|
337
|
-
<span className={`w-8 text-center border-r ${
|
|
338
|
-
diffLine.type === 'removed'
|
|
339
|
-
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
|
340
|
-
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
|
341
|
-
}`}>
|
|
342
|
-
{diffLine.type === 'removed' ? '-' : '+'}
|
|
343
|
-
</span>
|
|
344
|
-
<span className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
|
345
|
-
diffLine.type === 'removed'
|
|
346
|
-
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
|
347
|
-
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
|
348
|
-
}`}>
|
|
349
|
-
{diffLine.content}
|
|
350
|
-
</span>
|
|
351
|
-
</div>
|
|
352
|
-
))}
|
|
353
|
-
</div>
|
|
354
|
-
</div>
|
|
355
|
-
{showRawParameters && (
|
|
356
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
357
|
-
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
|
358
|
-
View raw parameters
|
|
359
|
-
</summary>
|
|
360
|
-
<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">
|
|
361
|
-
{message.toolInput}
|
|
362
|
-
</pre>
|
|
363
|
-
</details>
|
|
364
|
-
)}
|
|
365
|
-
</div>
|
|
366
|
-
</details>
|
|
367
|
-
);
|
|
368
|
-
}
|
|
369
|
-
} catch (e) {
|
|
370
|
-
// Fall back to raw display if parsing fails
|
|
371
|
-
}
|
|
372
|
-
return (
|
|
373
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
374
|
-
<summary className="text-sm text-blue-700 dark:text-blue-300 cursor-pointer hover:text-blue-800 dark:hover:text-blue-200">
|
|
375
|
-
View input parameters
|
|
376
|
-
</summary>
|
|
377
|
-
<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">
|
|
378
|
-
{message.toolInput}
|
|
379
|
-
</pre>
|
|
380
|
-
</details>
|
|
381
|
-
);
|
|
382
|
-
})()}
|
|
383
|
-
{message.toolInput && message.toolName !== 'Edit' && (() => {
|
|
384
|
-
// Debug log to see what we're dealing with
|
|
385
|
-
|
|
386
|
-
// Special handling for Write tool
|
|
387
|
-
if (message.toolName === 'Write') {
|
|
388
|
-
try {
|
|
389
|
-
let input;
|
|
390
|
-
// Handle both JSON string and already parsed object
|
|
391
|
-
if (typeof message.toolInput === 'string') {
|
|
392
|
-
input = JSON.parse(message.toolInput);
|
|
393
|
-
} else {
|
|
394
|
-
input = message.toolInput;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
if (input.file_path && input.content !== undefined) {
|
|
399
|
-
return (
|
|
400
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
401
|
-
<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">
|
|
402
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
403
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
404
|
-
</svg>
|
|
405
|
-
📄 Creating new file:
|
|
406
|
-
<button
|
|
407
|
-
onClick={(e) => {
|
|
408
|
-
e.preventDefault();
|
|
409
|
-
e.stopPropagation();
|
|
410
|
-
onFileOpen && onFileOpen(input.file_path, {
|
|
411
|
-
old_string: '',
|
|
412
|
-
new_string: input.content
|
|
413
|
-
});
|
|
414
|
-
}}
|
|
415
|
-
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
|
416
|
-
>
|
|
417
|
-
{input.file_path.split('/').pop()}
|
|
418
|
-
</button>
|
|
419
|
-
</summary>
|
|
420
|
-
<div className="mt-3">
|
|
421
|
-
<div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
422
|
-
<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">
|
|
423
|
-
<button
|
|
424
|
-
onClick={() => onFileOpen && onFileOpen(input.file_path, {
|
|
425
|
-
old_string: '',
|
|
426
|
-
new_string: input.content
|
|
427
|
-
})}
|
|
428
|
-
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"
|
|
429
|
-
>
|
|
430
|
-
{input.file_path}
|
|
431
|
-
</button>
|
|
432
|
-
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
433
|
-
New File
|
|
434
|
-
</span>
|
|
435
|
-
</div>
|
|
436
|
-
<div className="text-xs font-mono">
|
|
437
|
-
{createDiff('', input.content).map((diffLine, i) => (
|
|
438
|
-
<div key={i} className="flex">
|
|
439
|
-
<span className={`w-8 text-center border-r ${
|
|
440
|
-
diffLine.type === 'removed'
|
|
441
|
-
? 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-200 dark:border-red-800'
|
|
442
|
-
: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 border-green-200 dark:border-green-800'
|
|
443
|
-
}`}>
|
|
444
|
-
{diffLine.type === 'removed' ? '-' : '+'}
|
|
445
|
-
</span>
|
|
446
|
-
<span className={`px-2 py-0.5 flex-1 whitespace-pre-wrap ${
|
|
447
|
-
diffLine.type === 'removed'
|
|
448
|
-
? 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200'
|
|
449
|
-
: 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200'
|
|
450
|
-
}`}>
|
|
451
|
-
{diffLine.content}
|
|
452
|
-
</span>
|
|
453
|
-
</div>
|
|
454
|
-
))}
|
|
455
|
-
</div>
|
|
456
|
-
</div>
|
|
457
|
-
{showRawParameters && (
|
|
458
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
459
|
-
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
|
460
|
-
View raw parameters
|
|
461
|
-
</summary>
|
|
462
|
-
<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">
|
|
463
|
-
{message.toolInput}
|
|
464
|
-
</pre>
|
|
465
|
-
</details>
|
|
466
|
-
)}
|
|
467
|
-
</div>
|
|
468
|
-
</details>
|
|
469
|
-
);
|
|
470
|
-
}
|
|
471
|
-
} catch (e) {
|
|
472
|
-
// Fall back to regular display
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Special handling for TodoWrite tool
|
|
477
|
-
if (message.toolName === 'TodoWrite') {
|
|
478
|
-
try {
|
|
479
|
-
const input = JSON.parse(message.toolInput);
|
|
480
|
-
if (input.todos && Array.isArray(input.todos)) {
|
|
481
|
-
return (
|
|
482
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
483
|
-
<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">
|
|
484
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
485
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
486
|
-
</svg>
|
|
487
|
-
Updating Todo List
|
|
488
|
-
</summary>
|
|
489
|
-
<div className="mt-3">
|
|
490
|
-
<TodoList todos={input.todos} />
|
|
491
|
-
{showRawParameters && (
|
|
492
|
-
<details className="mt-3" open={autoExpandTools}>
|
|
493
|
-
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
|
494
|
-
View raw parameters
|
|
495
|
-
</summary>
|
|
496
|
-
<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">
|
|
497
|
-
{message.toolInput}
|
|
498
|
-
</pre>
|
|
499
|
-
</details>
|
|
500
|
-
)}
|
|
501
|
-
</div>
|
|
502
|
-
</details>
|
|
503
|
-
);
|
|
504
|
-
}
|
|
505
|
-
} catch (e) {
|
|
506
|
-
// Fall back to regular display
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
// Special handling for Bash tool
|
|
511
|
-
if (message.toolName === 'Bash') {
|
|
512
|
-
try {
|
|
513
|
-
const input = JSON.parse(message.toolInput);
|
|
514
|
-
return (
|
|
515
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
516
|
-
<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">
|
|
517
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
518
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
519
|
-
</svg>
|
|
520
|
-
Running command
|
|
521
|
-
</summary>
|
|
522
|
-
<div className="mt-3 space-y-2">
|
|
523
|
-
<div className="bg-gray-900 dark:bg-gray-950 text-gray-100 rounded-lg p-3 font-mono text-sm">
|
|
524
|
-
<div className="flex items-center gap-2 mb-2 text-gray-400">
|
|
525
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
526
|
-
<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" />
|
|
527
|
-
</svg>
|
|
528
|
-
<span className="text-xs">Terminal</span>
|
|
529
|
-
</div>
|
|
530
|
-
<div className="whitespace-pre-wrap break-all text-green-400">
|
|
531
|
-
$ {input.command}
|
|
532
|
-
</div>
|
|
533
|
-
</div>
|
|
534
|
-
{input.description && (
|
|
535
|
-
<div className="text-xs text-gray-600 dark:text-gray-400 italic">
|
|
536
|
-
{input.description}
|
|
537
|
-
</div>
|
|
538
|
-
)}
|
|
539
|
-
{showRawParameters && (
|
|
540
|
-
<details className="mt-2">
|
|
541
|
-
<summary className="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:text-blue-700 dark:hover:text-blue-300">
|
|
542
|
-
View raw parameters
|
|
543
|
-
</summary>
|
|
544
|
-
<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">
|
|
545
|
-
{message.toolInput}
|
|
546
|
-
</pre>
|
|
547
|
-
</details>
|
|
548
|
-
)}
|
|
549
|
-
</div>
|
|
550
|
-
</details>
|
|
551
|
-
);
|
|
552
|
-
} catch (e) {
|
|
553
|
-
// Fall back to regular display
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
// Special handling for Read tool
|
|
558
|
-
if (message.toolName === 'Read') {
|
|
559
|
-
try {
|
|
560
|
-
const input = JSON.parse(message.toolInput);
|
|
561
|
-
if (input.file_path) {
|
|
562
|
-
const filename = input.file_path.split('/').pop();
|
|
563
|
-
|
|
564
|
-
return (
|
|
565
|
-
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
|
|
566
|
-
Read{' '}
|
|
567
|
-
<button
|
|
568
|
-
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
|
569
|
-
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
|
570
|
-
>
|
|
571
|
-
{filename}
|
|
572
|
-
</button>
|
|
573
|
-
</div>
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
} catch (e) {
|
|
577
|
-
// Fall back to regular display
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// Special handling for exit_plan_mode tool
|
|
582
|
-
if (message.toolName === 'exit_plan_mode') {
|
|
583
|
-
try {
|
|
584
|
-
const input = JSON.parse(message.toolInput);
|
|
585
|
-
if (input.plan) {
|
|
586
|
-
// Replace escaped newlines with actual newlines
|
|
587
|
-
const planContent = input.plan.replace(/\\n/g, '\n');
|
|
588
|
-
return (
|
|
589
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
590
|
-
<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">
|
|
591
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
592
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
593
|
-
</svg>
|
|
594
|
-
📋 View implementation plan
|
|
595
|
-
</summary>
|
|
596
|
-
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert">
|
|
597
|
-
<ReactMarkdown>{planContent}</ReactMarkdown>
|
|
598
|
-
</div>
|
|
599
|
-
</details>
|
|
600
|
-
);
|
|
601
|
-
}
|
|
602
|
-
} catch (e) {
|
|
603
|
-
// Fall back to regular display
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// Regular tool input display for other tools
|
|
608
|
-
return (
|
|
609
|
-
<details className="mt-2" open={autoExpandTools}>
|
|
610
|
-
<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">
|
|
611
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
612
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
613
|
-
</svg>
|
|
614
|
-
View input parameters
|
|
615
|
-
</summary>
|
|
616
|
-
<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">
|
|
617
|
-
{message.toolInput}
|
|
618
|
-
</pre>
|
|
619
|
-
</details>
|
|
620
|
-
);
|
|
621
|
-
})()}
|
|
622
|
-
|
|
623
|
-
{/* Tool Result Section */}
|
|
624
|
-
{message.toolResult && (
|
|
625
|
-
<div className="mt-3 border-t border-blue-200 dark:border-blue-700 pt-3">
|
|
626
|
-
<div className="flex items-center gap-2 mb-2">
|
|
627
|
-
<div className={`w-4 h-4 rounded flex items-center justify-center ${
|
|
628
|
-
message.toolResult.isError
|
|
629
|
-
? 'bg-red-500'
|
|
630
|
-
: 'bg-green-500'
|
|
631
|
-
}`}>
|
|
632
|
-
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
633
|
-
{message.toolResult.isError ? (
|
|
634
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
635
|
-
) : (
|
|
636
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
637
|
-
)}
|
|
638
|
-
</svg>
|
|
639
|
-
</div>
|
|
640
|
-
<span className={`text-sm font-medium ${
|
|
641
|
-
message.toolResult.isError
|
|
642
|
-
? 'text-red-700 dark:text-red-300'
|
|
643
|
-
: 'text-green-700 dark:text-green-300'
|
|
644
|
-
}`}>
|
|
645
|
-
{message.toolResult.isError ? 'Tool Error' : 'Tool Result'}
|
|
646
|
-
</span>
|
|
647
|
-
</div>
|
|
648
|
-
|
|
649
|
-
<div className={`text-sm ${
|
|
650
|
-
message.toolResult.isError
|
|
651
|
-
? 'text-red-800 dark:text-red-200'
|
|
652
|
-
: 'text-green-800 dark:text-green-200'
|
|
653
|
-
}`}>
|
|
654
|
-
{(() => {
|
|
655
|
-
const content = String(message.toolResult.content || '');
|
|
656
|
-
|
|
657
|
-
// Special handling for TodoWrite/TodoRead results
|
|
658
|
-
if ((message.toolName === 'TodoWrite' || message.toolName === 'TodoRead') &&
|
|
659
|
-
(content.includes('Todos have been modified successfully') ||
|
|
660
|
-
content.includes('Todo list') ||
|
|
661
|
-
(content.startsWith('[') && content.includes('"content"') && content.includes('"status"')))) {
|
|
662
|
-
try {
|
|
663
|
-
// Try to parse if it looks like todo JSON data
|
|
664
|
-
let todos = null;
|
|
665
|
-
if (content.startsWith('[')) {
|
|
666
|
-
todos = JSON.parse(content);
|
|
667
|
-
} else if (content.includes('Todos have been modified successfully')) {
|
|
668
|
-
// For TodoWrite success messages, we don't have the data in the result
|
|
669
|
-
return (
|
|
670
|
-
<div>
|
|
671
|
-
<div className="flex items-center gap-2 mb-2">
|
|
672
|
-
<span className="font-medium">Todo list has been updated successfully</span>
|
|
673
|
-
</div>
|
|
674
|
-
</div>
|
|
675
|
-
);
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
if (todos && Array.isArray(todos)) {
|
|
679
|
-
return (
|
|
680
|
-
<div>
|
|
681
|
-
<div className="flex items-center gap-2 mb-3">
|
|
682
|
-
<span className="font-medium">Current Todo List</span>
|
|
683
|
-
</div>
|
|
684
|
-
<TodoList todos={todos} isResult={true} />
|
|
685
|
-
</div>
|
|
686
|
-
);
|
|
687
|
-
}
|
|
688
|
-
} catch (e) {
|
|
689
|
-
// Fall through to regular handling
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Special handling for exit_plan_mode tool results
|
|
694
|
-
if (message.toolName === 'exit_plan_mode') {
|
|
695
|
-
try {
|
|
696
|
-
// The content should be JSON with a "plan" field
|
|
697
|
-
const parsed = JSON.parse(content);
|
|
698
|
-
if (parsed.plan) {
|
|
699
|
-
// Replace escaped newlines with actual newlines
|
|
700
|
-
const planContent = parsed.plan.replace(/\\n/g, '\n');
|
|
701
|
-
return (
|
|
702
|
-
<div>
|
|
703
|
-
<div className="flex items-center gap-2 mb-3">
|
|
704
|
-
<span className="font-medium">Implementation Plan</span>
|
|
705
|
-
</div>
|
|
706
|
-
<div className="prose prose-sm max-w-none dark:prose-invert">
|
|
707
|
-
<ReactMarkdown>{planContent}</ReactMarkdown>
|
|
708
|
-
</div>
|
|
709
|
-
</div>
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
|
-
} catch (e) {
|
|
713
|
-
// Fall through to regular handling
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// Special handling for interactive prompts
|
|
718
|
-
if (content.includes('Do you want to proceed?') && message.toolName === 'Bash') {
|
|
719
|
-
const lines = content.split('\n');
|
|
720
|
-
const promptIndex = lines.findIndex(line => line.includes('Do you want to proceed?'));
|
|
721
|
-
const beforePrompt = lines.slice(0, promptIndex).join('\n');
|
|
722
|
-
const promptLines = lines.slice(promptIndex);
|
|
723
|
-
|
|
724
|
-
// Extract the question and options
|
|
725
|
-
const questionLine = promptLines.find(line => line.includes('Do you want to proceed?')) || '';
|
|
726
|
-
const options = [];
|
|
727
|
-
|
|
728
|
-
// Parse numbered options (1. Yes, 2. No, etc.)
|
|
729
|
-
promptLines.forEach(line => {
|
|
730
|
-
const optionMatch = line.match(/^\s*(\d+)\.\s+(.+)$/);
|
|
731
|
-
if (optionMatch) {
|
|
732
|
-
options.push({
|
|
733
|
-
number: optionMatch[1],
|
|
734
|
-
text: optionMatch[2].trim()
|
|
735
|
-
});
|
|
736
|
-
}
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
// Find which option was selected (usually indicated by "> 1" or similar)
|
|
740
|
-
const selectedMatch = content.match(/>\s*(\d+)/);
|
|
741
|
-
const selectedOption = selectedMatch ? selectedMatch[1] : null;
|
|
742
|
-
|
|
743
|
-
return (
|
|
744
|
-
<div className="space-y-3">
|
|
745
|
-
{beforePrompt && (
|
|
746
|
-
<div className="bg-gray-900 dark:bg-gray-950 text-gray-100 rounded-lg p-3 font-mono text-xs overflow-x-auto">
|
|
747
|
-
<pre className="whitespace-pre-wrap break-words">{beforePrompt}</pre>
|
|
748
|
-
</div>
|
|
749
|
-
)}
|
|
750
|
-
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
|
751
|
-
<div className="flex items-start gap-3">
|
|
752
|
-
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
753
|
-
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
754
|
-
<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" />
|
|
755
|
-
</svg>
|
|
756
|
-
</div>
|
|
757
|
-
<div className="flex-1">
|
|
758
|
-
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-2">
|
|
759
|
-
Interactive Prompt
|
|
760
|
-
</h4>
|
|
761
|
-
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
|
762
|
-
{questionLine}
|
|
763
|
-
</p>
|
|
764
|
-
|
|
765
|
-
{/* Option buttons */}
|
|
766
|
-
<div className="space-y-2 mb-4">
|
|
767
|
-
{options.map((option) => (
|
|
768
|
-
<button
|
|
769
|
-
key={option.number}
|
|
770
|
-
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
|
771
|
-
selectedOption === option.number
|
|
772
|
-
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
|
773
|
-
: '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'
|
|
774
|
-
} ${
|
|
775
|
-
selectedOption ? 'cursor-default' : 'cursor-not-allowed opacity-75'
|
|
776
|
-
}`}
|
|
777
|
-
disabled
|
|
778
|
-
>
|
|
779
|
-
<div className="flex items-center gap-3">
|
|
780
|
-
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
|
781
|
-
selectedOption === option.number
|
|
782
|
-
? 'bg-white/20'
|
|
783
|
-
: 'bg-amber-100 dark:bg-amber-800/50'
|
|
784
|
-
}`}>
|
|
785
|
-
{option.number}
|
|
786
|
-
</span>
|
|
787
|
-
<span className="text-sm sm:text-base font-medium flex-1">
|
|
788
|
-
{option.text}
|
|
789
|
-
</span>
|
|
790
|
-
{selectedOption === option.number && (
|
|
791
|
-
<svg className="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
792
|
-
<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" />
|
|
793
|
-
</svg>
|
|
794
|
-
)}
|
|
795
|
-
</div>
|
|
796
|
-
</button>
|
|
797
|
-
))}
|
|
798
|
-
</div>
|
|
799
|
-
|
|
800
|
-
{selectedOption && (
|
|
801
|
-
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
|
802
|
-
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
|
803
|
-
✓ Claude selected option {selectedOption}
|
|
804
|
-
</p>
|
|
805
|
-
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
|
806
|
-
In the CLI, you would select this option interactively using arrow keys or by typing the number.
|
|
807
|
-
</p>
|
|
808
|
-
</div>
|
|
809
|
-
)}
|
|
810
|
-
</div>
|
|
811
|
-
</div>
|
|
812
|
-
</div>
|
|
813
|
-
</div>
|
|
814
|
-
);
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
const fileEditMatch = content.match(/The file (.+?) has been updated\./);
|
|
818
|
-
if (fileEditMatch) {
|
|
819
|
-
return (
|
|
820
|
-
<div>
|
|
821
|
-
<div className="flex items-center gap-2 mb-2">
|
|
822
|
-
<span className="font-medium">File updated successfully</span>
|
|
823
|
-
</div>
|
|
824
|
-
<button
|
|
825
|
-
onClick={() => onFileOpen && onFileOpen(fileEditMatch[1])}
|
|
826
|
-
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"
|
|
827
|
-
>
|
|
828
|
-
{fileEditMatch[1]}
|
|
829
|
-
</button>
|
|
830
|
-
</div>
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
// Handle Write tool output for file creation
|
|
835
|
-
const fileCreateMatch = content.match(/(?:The file|File) (.+?) has been (?:created|written)(?: successfully)?\.?/);
|
|
836
|
-
if (fileCreateMatch) {
|
|
837
|
-
return (
|
|
838
|
-
<div>
|
|
839
|
-
<div className="flex items-center gap-2 mb-2">
|
|
840
|
-
<span className="font-medium">File created successfully</span>
|
|
841
|
-
</div>
|
|
842
|
-
<button
|
|
843
|
-
onClick={() => onFileOpen && onFileOpen(fileCreateMatch[1])}
|
|
844
|
-
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"
|
|
845
|
-
>
|
|
846
|
-
{fileCreateMatch[1]}
|
|
847
|
-
</button>
|
|
848
|
-
</div>
|
|
849
|
-
);
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// Special handling for Write tool - hide content if it's just the file content
|
|
853
|
-
if (message.toolName === 'Write' && !message.toolResult.isError) {
|
|
854
|
-
// For Write tool, the diff is already shown in the tool input section
|
|
855
|
-
// So we just show a success message here
|
|
856
|
-
return (
|
|
857
|
-
<div className="text-green-700 dark:text-green-300">
|
|
858
|
-
<div className="flex items-center gap-2">
|
|
859
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
860
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
861
|
-
</svg>
|
|
862
|
-
<span className="font-medium">File written successfully</span>
|
|
863
|
-
</div>
|
|
864
|
-
<p className="text-xs mt-1 text-green-600 dark:text-green-400">
|
|
865
|
-
The file content is displayed in the diff view above
|
|
866
|
-
</p>
|
|
867
|
-
</div>
|
|
868
|
-
);
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
if (content.includes('cat -n') && content.includes('→')) {
|
|
872
|
-
return (
|
|
873
|
-
<details open={autoExpandTools}>
|
|
874
|
-
<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">
|
|
875
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
876
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
877
|
-
</svg>
|
|
878
|
-
View file content
|
|
879
|
-
</summary>
|
|
880
|
-
<div className="mt-2 bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
881
|
-
<div className="text-xs font-mono p-3 whitespace-pre-wrap break-words overflow-hidden">
|
|
882
|
-
{content}
|
|
883
|
-
</div>
|
|
884
|
-
</div>
|
|
885
|
-
</details>
|
|
886
|
-
);
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
if (content.length > 300) {
|
|
890
|
-
return (
|
|
891
|
-
<details open={autoExpandTools}>
|
|
892
|
-
<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">
|
|
893
|
-
<svg className="w-4 h-4 transition-transform details-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
894
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
895
|
-
</svg>
|
|
896
|
-
View full output ({content.length} chars)
|
|
897
|
-
</summary>
|
|
898
|
-
<div className="mt-2 prose prose-sm max-w-none prose-green dark:prose-invert">
|
|
899
|
-
<ReactMarkdown>{content}</ReactMarkdown>
|
|
900
|
-
</div>
|
|
901
|
-
</details>
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
return (
|
|
906
|
-
<div className="prose prose-sm max-w-none prose-green dark:prose-invert">
|
|
907
|
-
<ReactMarkdown>{content}</ReactMarkdown>
|
|
908
|
-
</div>
|
|
909
|
-
);
|
|
910
|
-
})()}
|
|
911
|
-
</div>
|
|
912
|
-
</div>
|
|
913
|
-
)}
|
|
914
|
-
</div>
|
|
915
|
-
) : message.isInteractivePrompt ? (
|
|
916
|
-
// Special handling for interactive prompts
|
|
917
|
-
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
|
918
|
-
<div className="flex items-start gap-3">
|
|
919
|
-
<div className="w-8 h-8 bg-amber-500 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
920
|
-
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
921
|
-
<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" />
|
|
922
|
-
</svg>
|
|
923
|
-
</div>
|
|
924
|
-
<div className="flex-1">
|
|
925
|
-
<h4 className="font-semibold text-amber-900 dark:text-amber-100 text-base mb-3">
|
|
926
|
-
Interactive Prompt
|
|
927
|
-
</h4>
|
|
928
|
-
{(() => {
|
|
929
|
-
const lines = message.content.split('\n').filter(line => line.trim());
|
|
930
|
-
const questionLine = lines.find(line => line.includes('?')) || lines[0] || '';
|
|
931
|
-
const options = [];
|
|
932
|
-
|
|
933
|
-
// Parse the menu options
|
|
934
|
-
lines.forEach(line => {
|
|
935
|
-
// Match lines like "❯ 1. Yes" or " 2. No"
|
|
936
|
-
const optionMatch = line.match(/[❯\s]*(\d+)\.\s+(.+)/);
|
|
937
|
-
if (optionMatch) {
|
|
938
|
-
const isSelected = line.includes('❯');
|
|
939
|
-
options.push({
|
|
940
|
-
number: optionMatch[1],
|
|
941
|
-
text: optionMatch[2].trim(),
|
|
942
|
-
isSelected
|
|
943
|
-
});
|
|
944
|
-
}
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
return (
|
|
948
|
-
<>
|
|
949
|
-
<p className="text-sm text-amber-800 dark:text-amber-200 mb-4">
|
|
950
|
-
{questionLine}
|
|
951
|
-
</p>
|
|
952
|
-
|
|
953
|
-
{/* Option buttons */}
|
|
954
|
-
<div className="space-y-2 mb-4">
|
|
955
|
-
{options.map((option) => (
|
|
956
|
-
<button
|
|
957
|
-
key={option.number}
|
|
958
|
-
className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-all ${
|
|
959
|
-
option.isSelected
|
|
960
|
-
? 'bg-amber-600 dark:bg-amber-700 text-white border-amber-600 dark:border-amber-700 shadow-md'
|
|
961
|
-
: 'bg-white dark:bg-gray-800 text-amber-900 dark:text-amber-100 border-amber-300 dark:border-amber-700'
|
|
962
|
-
} cursor-not-allowed opacity-75`}
|
|
963
|
-
disabled
|
|
964
|
-
>
|
|
965
|
-
<div className="flex items-center gap-3">
|
|
966
|
-
<span className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold ${
|
|
967
|
-
option.isSelected
|
|
968
|
-
? 'bg-white/20'
|
|
969
|
-
: 'bg-amber-100 dark:bg-amber-800/50'
|
|
970
|
-
}`}>
|
|
971
|
-
{option.number}
|
|
972
|
-
</span>
|
|
973
|
-
<span className="text-sm sm:text-base font-medium flex-1">
|
|
974
|
-
{option.text}
|
|
975
|
-
</span>
|
|
976
|
-
{option.isSelected && (
|
|
977
|
-
<span className="text-lg">❯</span>
|
|
978
|
-
)}
|
|
979
|
-
</div>
|
|
980
|
-
</button>
|
|
981
|
-
))}
|
|
982
|
-
</div>
|
|
983
|
-
|
|
984
|
-
<div className="bg-amber-100 dark:bg-amber-800/30 rounded-lg p-3">
|
|
985
|
-
<p className="text-amber-900 dark:text-amber-100 text-sm font-medium mb-1">
|
|
986
|
-
⏳ Waiting for your response in the CLI
|
|
987
|
-
</p>
|
|
988
|
-
<p className="text-amber-800 dark:text-amber-200 text-xs">
|
|
989
|
-
Please select an option in your terminal where Claude is running.
|
|
990
|
-
</p>
|
|
991
|
-
</div>
|
|
992
|
-
</>
|
|
993
|
-
);
|
|
994
|
-
})()}
|
|
995
|
-
</div>
|
|
996
|
-
</div>
|
|
997
|
-
</div>
|
|
998
|
-
) : message.isToolUse && message.toolName === 'Read' ? (
|
|
999
|
-
// Simple Read tool indicator
|
|
1000
|
-
(() => {
|
|
1001
|
-
try {
|
|
1002
|
-
const input = JSON.parse(message.toolInput);
|
|
1003
|
-
if (input.file_path) {
|
|
1004
|
-
const filename = input.file_path.split('/').pop();
|
|
1005
|
-
return (
|
|
1006
|
-
<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">
|
|
1007
|
-
📖 Read{' '}
|
|
1008
|
-
<button
|
|
1009
|
-
onClick={() => onFileOpen && onFileOpen(input.file_path)}
|
|
1010
|
-
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-mono"
|
|
1011
|
-
>
|
|
1012
|
-
{filename}
|
|
1013
|
-
</button>
|
|
1014
|
-
</div>
|
|
1015
|
-
);
|
|
1016
|
-
}
|
|
1017
|
-
} catch (e) {
|
|
1018
|
-
return (
|
|
1019
|
-
<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">
|
|
1020
|
-
📖 Read file
|
|
1021
|
-
</div>
|
|
1022
|
-
);
|
|
1023
|
-
}
|
|
1024
|
-
})()
|
|
1025
|
-
) : message.isToolUse && message.toolName === 'TodoWrite' ? (
|
|
1026
|
-
// Simple TodoWrite tool indicator with tasks
|
|
1027
|
-
(() => {
|
|
1028
|
-
try {
|
|
1029
|
-
const input = JSON.parse(message.toolInput);
|
|
1030
|
-
if (input.todos && Array.isArray(input.todos)) {
|
|
1031
|
-
return (
|
|
1032
|
-
<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">
|
|
1033
|
-
<div className="text-sm text-blue-700 dark:text-blue-300 mb-2">
|
|
1034
|
-
📝 Update todo list
|
|
1035
|
-
</div>
|
|
1036
|
-
<TodoList todos={input.todos} />
|
|
1037
|
-
</div>
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
} catch (e) {
|
|
1041
|
-
return (
|
|
1042
|
-
<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">
|
|
1043
|
-
📝 Update todo list
|
|
1044
|
-
</div>
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
})()
|
|
1048
|
-
) : message.isToolUse && message.toolName === 'TodoRead' ? (
|
|
1049
|
-
// Simple TodoRead tool indicator
|
|
1050
|
-
<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">
|
|
1051
|
-
📋 Read todo list
|
|
1052
|
-
</div>
|
|
1053
|
-
) : (
|
|
1054
|
-
<div className="text-sm text-gray-700 dark:text-gray-300">
|
|
1055
|
-
{/* Thinking accordion for reasoning */}
|
|
1056
|
-
{message.reasoning && (
|
|
1057
|
-
<details className="mb-3">
|
|
1058
|
-
<summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium">
|
|
1059
|
-
💭 Thinking...
|
|
1060
|
-
</summary>
|
|
1061
|
-
<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">
|
|
1062
|
-
<div className="whitespace-pre-wrap">
|
|
1063
|
-
{message.reasoning}
|
|
1064
|
-
</div>
|
|
1065
|
-
</div>
|
|
1066
|
-
</details>
|
|
1067
|
-
)}
|
|
1068
|
-
|
|
1069
|
-
{message.type === 'assistant' ? (
|
|
1070
|
-
<div className="prose prose-sm max-w-none dark:prose-invert prose-gray [&_code]:!bg-transparent [&_code]:!p-0 [&_pre]:!bg-transparent [&_pre]:!border-0 [&_pre]:!p-0">
|
|
1071
|
-
<ReactMarkdown
|
|
1072
|
-
components={{
|
|
1073
|
-
code: ({node, inline, className, children, ...props}) => {
|
|
1074
|
-
return inline ? (
|
|
1075
|
-
<strong className="text-blue-600 dark:text-blue-400 font-bold not-prose" {...props}>
|
|
1076
|
-
{children}
|
|
1077
|
-
</strong>
|
|
1078
|
-
) : (
|
|
1079
|
-
<div className="bg-gray-800 dark:bg-gray-800 border border-gray-600/30 dark:border-gray-600/30 p-3 rounded-lg overflow-hidden my-2">
|
|
1080
|
-
<code className="text-gray-100 dark:text-gray-200 text-sm font-mono block whitespace-pre-wrap break-words" {...props}>
|
|
1081
|
-
{children}
|
|
1082
|
-
</code>
|
|
1083
|
-
</div>
|
|
1084
|
-
);
|
|
1085
|
-
},
|
|
1086
|
-
blockquote: ({children}) => (
|
|
1087
|
-
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic text-gray-600 dark:text-gray-400 my-2">
|
|
1088
|
-
{children}
|
|
1089
|
-
</blockquote>
|
|
1090
|
-
),
|
|
1091
|
-
a: ({href, children}) => (
|
|
1092
|
-
<a href={href} className="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">
|
|
1093
|
-
{children}
|
|
1094
|
-
</a>
|
|
1095
|
-
),
|
|
1096
|
-
p: ({children}) => (
|
|
1097
|
-
<div className="mb-2 last:mb-0">
|
|
1098
|
-
{children}
|
|
1099
|
-
</div>
|
|
1100
|
-
)
|
|
1101
|
-
}}
|
|
1102
|
-
>
|
|
1103
|
-
{formatUsageLimitText(String(message.content || ''))}
|
|
1104
|
-
</ReactMarkdown>
|
|
1105
|
-
</div>
|
|
1106
|
-
) : (
|
|
1107
|
-
<div className="whitespace-pre-wrap">
|
|
1108
|
-
{formatUsageLimitText(String(message.content || ''))}
|
|
1109
|
-
</div>
|
|
1110
|
-
)}
|
|
1111
|
-
</div>
|
|
1112
|
-
)}
|
|
1113
|
-
|
|
1114
|
-
<div className={`text-xs text-gray-500 dark:text-gray-400 mt-1 ${isGrouped ? 'opacity-0 group-hover:opacity-100' : ''}`}>
|
|
1115
|
-
{new Date(message.timestamp).toLocaleTimeString()}
|
|
1116
|
-
</div>
|
|
1117
|
-
</div>
|
|
1118
|
-
</div>
|
|
1119
|
-
)}
|
|
1120
|
-
</div>
|
|
1121
|
-
);
|
|
1122
|
-
});
|
|
1123
|
-
|
|
1124
|
-
// ImageAttachment component for displaying image previews
|
|
1125
|
-
const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
|
1126
|
-
const [preview, setPreview] = useState(null);
|
|
1127
|
-
|
|
1128
|
-
useEffect(() => {
|
|
1129
|
-
const url = URL.createObjectURL(file);
|
|
1130
|
-
setPreview(url);
|
|
1131
|
-
return () => URL.revokeObjectURL(url);
|
|
1132
|
-
}, [file]);
|
|
1133
|
-
|
|
1134
|
-
return (
|
|
1135
|
-
<div className="relative group">
|
|
1136
|
-
<img src={preview} alt={file.name} className="w-20 h-20 object-cover rounded" />
|
|
1137
|
-
{uploadProgress !== undefined && uploadProgress < 100 && (
|
|
1138
|
-
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
|
1139
|
-
<div className="text-white text-xs">{uploadProgress}%</div>
|
|
1140
|
-
</div>
|
|
1141
|
-
)}
|
|
1142
|
-
{error && (
|
|
1143
|
-
<div className="absolute inset-0 bg-red-500/50 flex items-center justify-center">
|
|
1144
|
-
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1145
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
1146
|
-
</svg>
|
|
1147
|
-
</div>
|
|
1148
|
-
)}
|
|
1149
|
-
<button
|
|
1150
|
-
onClick={onRemove}
|
|
1151
|
-
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 opacity-0 group-hover:opacity-100"
|
|
1152
|
-
>
|
|
1153
|
-
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
1154
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
1155
|
-
</svg>
|
|
1156
|
-
</button>
|
|
1157
|
-
</div>
|
|
1158
|
-
);
|
|
1159
|
-
};
|
|
1160
|
-
|
|
1161
|
-
// ChatInterface: Main chat component with Session Protection System integration
|
|
1162
|
-
//
|
|
1163
|
-
// Session Protection System prevents automatic project updates from interrupting active conversations:
|
|
1164
|
-
// - onSessionActive: Called when user sends message to mark session as protected
|
|
1165
|
-
// - onSessionInactive: Called when conversation completes/aborts to re-enable updates
|
|
1166
|
-
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
|
1167
|
-
//
|
|
1168
|
-
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
|
1169
|
-
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
|
|
1170
|
-
const { tasksEnabled } = useTasksSettings();
|
|
1171
|
-
const [input, setInput] = useState(() => {
|
|
1172
|
-
if (typeof window !== 'undefined' && selectedProject) {
|
|
1173
|
-
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
|
1174
|
-
}
|
|
1175
|
-
return '';
|
|
1176
|
-
});
|
|
1177
|
-
const [chatMessages, setChatMessages] = useState(() => {
|
|
1178
|
-
if (typeof window !== 'undefined' && selectedProject) {
|
|
1179
|
-
const saved = safeLocalStorage.getItem(`chat_messages_${selectedProject.name}`);
|
|
1180
|
-
return saved ? JSON.parse(saved) : [];
|
|
1181
|
-
}
|
|
1182
|
-
return [];
|
|
1183
|
-
});
|
|
1184
|
-
const [isLoading, setIsLoading] = useState(false);
|
|
1185
|
-
const [currentSessionId, setCurrentSessionId] = useState(selectedSession?.id || null);
|
|
1186
|
-
const [isInputFocused, setIsInputFocused] = useState(false);
|
|
1187
|
-
const [sessionMessages, setSessionMessages] = useState([]);
|
|
1188
|
-
const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false);
|
|
1189
|
-
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false);
|
|
1190
|
-
const [messagesOffset, setMessagesOffset] = useState(0);
|
|
1191
|
-
const [hasMoreMessages, setHasMoreMessages] = useState(false);
|
|
1192
|
-
const [totalMessages, setTotalMessages] = useState(0);
|
|
1193
|
-
const MESSAGES_PER_PAGE = 20;
|
|
1194
|
-
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
|
1195
|
-
const [permissionMode, setPermissionMode] = useState('default');
|
|
1196
|
-
const [attachedImages, setAttachedImages] = useState([]);
|
|
1197
|
-
const [uploadingImages, setUploadingImages] = useState(new Map());
|
|
1198
|
-
const [imageErrors, setImageErrors] = useState(new Map());
|
|
1199
|
-
const messagesEndRef = useRef(null);
|
|
1200
|
-
const textareaRef = useRef(null);
|
|
1201
|
-
const scrollContainerRef = useRef(null);
|
|
1202
|
-
// Streaming throttle buffers
|
|
1203
|
-
const streamBufferRef = useRef('');
|
|
1204
|
-
const streamTimerRef = useRef(null);
|
|
1205
|
-
const [debouncedInput, setDebouncedInput] = useState('');
|
|
1206
|
-
const [showFileDropdown, setShowFileDropdown] = useState(false);
|
|
1207
|
-
const [fileList, setFileList] = useState([]);
|
|
1208
|
-
const [filteredFiles, setFilteredFiles] = useState([]);
|
|
1209
|
-
const [selectedFileIndex, setSelectedFileIndex] = useState(-1);
|
|
1210
|
-
const [cursorPosition, setCursorPosition] = useState(0);
|
|
1211
|
-
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
|
|
1212
|
-
const [canAbortSession, setCanAbortSession] = useState(false);
|
|
1213
|
-
const [isUserScrolledUp, setIsUserScrolledUp] = useState(false);
|
|
1214
|
-
const scrollPositionRef = useRef({ height: 0, top: 0 });
|
|
1215
|
-
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
|
1216
|
-
const [slashCommands, setSlashCommands] = useState([]);
|
|
1217
|
-
const [filteredCommands, setFilteredCommands] = useState([]);
|
|
1218
|
-
const [isTextareaExpanded, setIsTextareaExpanded] = useState(false);
|
|
1219
|
-
const [selectedCommandIndex, setSelectedCommandIndex] = useState(-1);
|
|
1220
|
-
const [slashPosition, setSlashPosition] = useState(-1);
|
|
1221
|
-
const [visibleMessageCount, setVisibleMessageCount] = useState(100);
|
|
1222
|
-
const [claudeStatus, setClaudeStatus] = useState(null);
|
|
1223
|
-
const [provider, setProvider] = useState(() => {
|
|
1224
|
-
return localStorage.getItem('selected-provider') || 'claude';
|
|
1225
|
-
});
|
|
1226
|
-
const [cursorModel, setCursorModel] = useState(() => {
|
|
1227
|
-
return localStorage.getItem('cursor-model') || 'gpt-5';
|
|
1228
|
-
});
|
|
1229
|
-
// When selecting a session from Sidebar, auto-switch provider to match session's origin
|
|
1230
|
-
useEffect(() => {
|
|
1231
|
-
if (selectedSession && selectedSession.__provider && selectedSession.__provider !== provider) {
|
|
1232
|
-
setProvider(selectedSession.__provider);
|
|
1233
|
-
localStorage.setItem('selected-provider', selectedSession.__provider);
|
|
1234
|
-
}
|
|
1235
|
-
}, [selectedSession]);
|
|
1236
|
-
|
|
1237
|
-
// Load Cursor default model from config
|
|
1238
|
-
useEffect(() => {
|
|
1239
|
-
if (provider === 'cursor') {
|
|
1240
|
-
fetch('/api/cursor/config', {
|
|
1241
|
-
headers: {
|
|
1242
|
-
'Authorization': `Bearer ${localStorage.getItem('auth-token')}`
|
|
1243
|
-
}
|
|
1244
|
-
})
|
|
1245
|
-
.then(res => res.json())
|
|
1246
|
-
.then(data => {
|
|
1247
|
-
if (data.success && data.config?.model?.modelId) {
|
|
1248
|
-
// Map Cursor model IDs to our simplified names
|
|
1249
|
-
const modelMap = {
|
|
1250
|
-
'gpt-5': 'gpt-5',
|
|
1251
|
-
'claude-4-sonnet': 'sonnet-4',
|
|
1252
|
-
'sonnet-4': 'sonnet-4',
|
|
1253
|
-
'claude-4-opus': 'opus-4.1',
|
|
1254
|
-
'opus-4.1': 'opus-4.1'
|
|
1255
|
-
};
|
|
1256
|
-
const mappedModel = modelMap[data.config.model.modelId] || data.config.model.modelId;
|
|
1257
|
-
if (!localStorage.getItem('cursor-model')) {
|
|
1258
|
-
setCursorModel(mappedModel);
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
})
|
|
1262
|
-
.catch(err => console.error('Error loading Cursor config:', err));
|
|
1263
|
-
}
|
|
1264
|
-
}, [provider]);
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
// Memoized diff calculation to prevent recalculating on every render
|
|
1268
|
-
const createDiff = useMemo(() => {
|
|
1269
|
-
const cache = new Map();
|
|
1270
|
-
return (oldStr, newStr) => {
|
|
1271
|
-
const key = `${oldStr.length}-${newStr.length}-${oldStr.slice(0, 50)}`;
|
|
1272
|
-
if (cache.has(key)) {
|
|
1273
|
-
return cache.get(key);
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
const result = calculateDiff(oldStr, newStr);
|
|
1277
|
-
cache.set(key, result);
|
|
1278
|
-
if (cache.size > 100) {
|
|
1279
|
-
const firstKey = cache.keys().next().value;
|
|
1280
|
-
cache.delete(firstKey);
|
|
1281
|
-
}
|
|
1282
|
-
return result;
|
|
1283
|
-
};
|
|
1284
|
-
}, []);
|
|
1285
|
-
|
|
1286
|
-
// Load session messages from API with pagination
|
|
1287
|
-
const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => {
|
|
1288
|
-
if (!projectName || !sessionId) return [];
|
|
1289
|
-
|
|
1290
|
-
const isInitialLoad = !loadMore;
|
|
1291
|
-
if (isInitialLoad) {
|
|
1292
|
-
setIsLoadingSessionMessages(true);
|
|
1293
|
-
} else {
|
|
1294
|
-
setIsLoadingMoreMessages(true);
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
try {
|
|
1298
|
-
const currentOffset = loadMore ? messagesOffset : 0;
|
|
1299
|
-
const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset);
|
|
1300
|
-
if (!response.ok) {
|
|
1301
|
-
throw new Error('Failed to load session messages');
|
|
1302
|
-
}
|
|
1303
|
-
const data = await response.json();
|
|
1304
|
-
|
|
1305
|
-
// Handle paginated response
|
|
1306
|
-
if (data.hasMore !== undefined) {
|
|
1307
|
-
setHasMoreMessages(data.hasMore);
|
|
1308
|
-
setTotalMessages(data.total);
|
|
1309
|
-
setMessagesOffset(currentOffset + (data.messages?.length || 0));
|
|
1310
|
-
return data.messages || [];
|
|
1311
|
-
} else {
|
|
1312
|
-
// Backward compatibility for non-paginated response
|
|
1313
|
-
const messages = data.messages || [];
|
|
1314
|
-
setHasMoreMessages(false);
|
|
1315
|
-
setTotalMessages(messages.length);
|
|
1316
|
-
return messages;
|
|
1317
|
-
}
|
|
1318
|
-
} catch (error) {
|
|
1319
|
-
console.error('Error loading session messages:', error);
|
|
1320
|
-
return [];
|
|
1321
|
-
} finally {
|
|
1322
|
-
if (isInitialLoad) {
|
|
1323
|
-
setIsLoadingSessionMessages(false);
|
|
1324
|
-
} else {
|
|
1325
|
-
setIsLoadingMoreMessages(false);
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
}, [messagesOffset]);
|
|
1329
|
-
|
|
1330
|
-
// Load Cursor session messages from SQLite via backend
|
|
1331
|
-
const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => {
|
|
1332
|
-
if (!projectPath || !sessionId) return [];
|
|
1333
|
-
setIsLoadingSessionMessages(true);
|
|
1334
|
-
try {
|
|
1335
|
-
const url = `/api/cursor/sessions/${encodeURIComponent(sessionId)}?projectPath=${encodeURIComponent(projectPath)}`;
|
|
1336
|
-
const res = await authenticatedFetch(url);
|
|
1337
|
-
if (!res.ok) return [];
|
|
1338
|
-
const data = await res.json();
|
|
1339
|
-
const blobs = data?.session?.messages || [];
|
|
1340
|
-
const converted = [];
|
|
1341
|
-
const toolUseMap = {}; // Map to store tool uses by ID for linking results
|
|
1342
|
-
|
|
1343
|
-
// First pass: process all messages maintaining order
|
|
1344
|
-
for (let blobIdx = 0; blobIdx < blobs.length; blobIdx++) {
|
|
1345
|
-
const blob = blobs[blobIdx];
|
|
1346
|
-
const content = blob.content;
|
|
1347
|
-
let text = '';
|
|
1348
|
-
let role = 'assistant';
|
|
1349
|
-
let reasoningText = null; // Move to outer scope
|
|
1350
|
-
try {
|
|
1351
|
-
// Handle different Cursor message formats
|
|
1352
|
-
if (content?.role && content?.content) {
|
|
1353
|
-
// Direct format: {"role":"user","content":[{"type":"text","text":"..."}]}
|
|
1354
|
-
// Skip system messages
|
|
1355
|
-
if (content.role === 'system') {
|
|
1356
|
-
continue;
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
// Handle tool messages
|
|
1360
|
-
if (content.role === 'tool') {
|
|
1361
|
-
// Tool result format - find the matching tool use message and update it
|
|
1362
|
-
if (Array.isArray(content.content)) {
|
|
1363
|
-
for (const item of content.content) {
|
|
1364
|
-
if (item?.type === 'tool-result') {
|
|
1365
|
-
// Map ApplyPatch to Edit for consistency
|
|
1366
|
-
let toolName = item.toolName || 'Unknown Tool';
|
|
1367
|
-
if (toolName === 'ApplyPatch') {
|
|
1368
|
-
toolName = 'Edit';
|
|
1369
|
-
}
|
|
1370
|
-
const toolCallId = item.toolCallId || content.id;
|
|
1371
|
-
const result = item.result || '';
|
|
1372
|
-
|
|
1373
|
-
// Store the tool result to be linked later
|
|
1374
|
-
if (toolUseMap[toolCallId]) {
|
|
1375
|
-
toolUseMap[toolCallId].toolResult = {
|
|
1376
|
-
content: result,
|
|
1377
|
-
isError: false
|
|
1378
|
-
};
|
|
1379
|
-
} else {
|
|
1380
|
-
// No matching tool use found, create a standalone result message
|
|
1381
|
-
converted.push({
|
|
1382
|
-
type: 'assistant',
|
|
1383
|
-
content: '',
|
|
1384
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1385
|
-
blobId: blob.id,
|
|
1386
|
-
sequence: blob.sequence,
|
|
1387
|
-
rowid: blob.rowid,
|
|
1388
|
-
isToolUse: true,
|
|
1389
|
-
toolName: toolName,
|
|
1390
|
-
toolId: toolCallId,
|
|
1391
|
-
toolInput: null,
|
|
1392
|
-
toolResult: {
|
|
1393
|
-
content: result,
|
|
1394
|
-
isError: false
|
|
1395
|
-
}
|
|
1396
|
-
});
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
continue; // Don't add tool messages as regular messages
|
|
1402
|
-
} else {
|
|
1403
|
-
// User or assistant messages
|
|
1404
|
-
role = content.role === 'user' ? 'user' : 'assistant';
|
|
1405
|
-
|
|
1406
|
-
if (Array.isArray(content.content)) {
|
|
1407
|
-
// Extract text, reasoning, and tool calls from content array
|
|
1408
|
-
const textParts = [];
|
|
1409
|
-
|
|
1410
|
-
for (const part of content.content) {
|
|
1411
|
-
if (part?.type === 'text' && part?.text) {
|
|
1412
|
-
textParts.push(part.text);
|
|
1413
|
-
} else if (part?.type === 'reasoning' && part?.text) {
|
|
1414
|
-
// Handle reasoning type - will be displayed in a collapsible section
|
|
1415
|
-
reasoningText = part.text;
|
|
1416
|
-
} else if (part?.type === 'tool-call') {
|
|
1417
|
-
// First, add any text/reasoning we've collected so far as a message
|
|
1418
|
-
if (textParts.length > 0 || reasoningText) {
|
|
1419
|
-
converted.push({
|
|
1420
|
-
type: role,
|
|
1421
|
-
content: textParts.join('\n'),
|
|
1422
|
-
reasoning: reasoningText,
|
|
1423
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1424
|
-
blobId: blob.id,
|
|
1425
|
-
sequence: blob.sequence,
|
|
1426
|
-
rowid: blob.rowid
|
|
1427
|
-
});
|
|
1428
|
-
textParts.length = 0;
|
|
1429
|
-
reasoningText = null;
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
// Tool call in assistant message - format like Claude Code
|
|
1433
|
-
// Map ApplyPatch to Edit for consistency with Claude Code
|
|
1434
|
-
let toolName = part.toolName || 'Unknown Tool';
|
|
1435
|
-
if (toolName === 'ApplyPatch') {
|
|
1436
|
-
toolName = 'Edit';
|
|
1437
|
-
}
|
|
1438
|
-
const toolId = part.toolCallId || `tool_${blobIdx}`;
|
|
1439
|
-
|
|
1440
|
-
// Create a tool use message with Claude Code format
|
|
1441
|
-
// Map Cursor args format to Claude Code format
|
|
1442
|
-
let toolInput = part.args;
|
|
1443
|
-
|
|
1444
|
-
if (toolName === 'Edit' && part.args) {
|
|
1445
|
-
// ApplyPatch uses 'patch' format, convert to Edit format
|
|
1446
|
-
if (part.args.patch) {
|
|
1447
|
-
// Parse the patch to extract old and new content
|
|
1448
|
-
const patchLines = part.args.patch.split('\n');
|
|
1449
|
-
let oldLines = [];
|
|
1450
|
-
let newLines = [];
|
|
1451
|
-
let inPatch = false;
|
|
1452
|
-
|
|
1453
|
-
for (const line of patchLines) {
|
|
1454
|
-
if (line.startsWith('@@')) {
|
|
1455
|
-
inPatch = true;
|
|
1456
|
-
} else if (inPatch) {
|
|
1457
|
-
if (line.startsWith('-')) {
|
|
1458
|
-
oldLines.push(line.substring(1));
|
|
1459
|
-
} else if (line.startsWith('+')) {
|
|
1460
|
-
newLines.push(line.substring(1));
|
|
1461
|
-
} else if (line.startsWith(' ')) {
|
|
1462
|
-
// Context line - add to both
|
|
1463
|
-
oldLines.push(line.substring(1));
|
|
1464
|
-
newLines.push(line.substring(1));
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
const filePath = part.args.file_path;
|
|
1470
|
-
const absolutePath = filePath && !filePath.startsWith('/')
|
|
1471
|
-
? `${projectPath}/${filePath}`
|
|
1472
|
-
: filePath;
|
|
1473
|
-
toolInput = {
|
|
1474
|
-
file_path: absolutePath,
|
|
1475
|
-
old_string: oldLines.join('\n') || part.args.patch,
|
|
1476
|
-
new_string: newLines.join('\n') || part.args.patch
|
|
1477
|
-
};
|
|
1478
|
-
} else {
|
|
1479
|
-
// Direct edit format
|
|
1480
|
-
toolInput = part.args;
|
|
1481
|
-
}
|
|
1482
|
-
} else if (toolName === 'Read' && part.args) {
|
|
1483
|
-
// Map 'path' to 'file_path'
|
|
1484
|
-
// Convert relative path to absolute if needed
|
|
1485
|
-
const filePath = part.args.path || part.args.file_path;
|
|
1486
|
-
const absolutePath = filePath && !filePath.startsWith('/')
|
|
1487
|
-
? `${projectPath}/${filePath}`
|
|
1488
|
-
: filePath;
|
|
1489
|
-
toolInput = {
|
|
1490
|
-
file_path: absolutePath
|
|
1491
|
-
};
|
|
1492
|
-
} else if (toolName === 'Write' && part.args) {
|
|
1493
|
-
// Map fields for Write tool
|
|
1494
|
-
const filePath = part.args.path || part.args.file_path;
|
|
1495
|
-
const absolutePath = filePath && !filePath.startsWith('/')
|
|
1496
|
-
? `${projectPath}/${filePath}`
|
|
1497
|
-
: filePath;
|
|
1498
|
-
toolInput = {
|
|
1499
|
-
file_path: absolutePath,
|
|
1500
|
-
content: part.args.contents || part.args.content
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const toolMessage = {
|
|
1505
|
-
type: 'assistant',
|
|
1506
|
-
content: '',
|
|
1507
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1508
|
-
blobId: blob.id,
|
|
1509
|
-
sequence: blob.sequence,
|
|
1510
|
-
rowid: blob.rowid,
|
|
1511
|
-
isToolUse: true,
|
|
1512
|
-
toolName: toolName,
|
|
1513
|
-
toolId: toolId,
|
|
1514
|
-
toolInput: toolInput ? JSON.stringify(toolInput) : null,
|
|
1515
|
-
toolResult: null // Will be filled when we get the tool result
|
|
1516
|
-
};
|
|
1517
|
-
converted.push(toolMessage);
|
|
1518
|
-
toolUseMap[toolId] = toolMessage; // Store for linking results
|
|
1519
|
-
} else if (part?.type === 'tool_use') {
|
|
1520
|
-
// Old format support
|
|
1521
|
-
if (textParts.length > 0 || reasoningText) {
|
|
1522
|
-
converted.push({
|
|
1523
|
-
type: role,
|
|
1524
|
-
content: textParts.join('\n'),
|
|
1525
|
-
reasoning: reasoningText,
|
|
1526
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1527
|
-
blobId: blob.id,
|
|
1528
|
-
sequence: blob.sequence,
|
|
1529
|
-
rowid: blob.rowid
|
|
1530
|
-
});
|
|
1531
|
-
textParts.length = 0;
|
|
1532
|
-
reasoningText = null;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
const toolName = part.name || 'Unknown Tool';
|
|
1536
|
-
const toolId = part.id || `tool_${blobIdx}`;
|
|
1537
|
-
|
|
1538
|
-
const toolMessage = {
|
|
1539
|
-
type: 'assistant',
|
|
1540
|
-
content: '',
|
|
1541
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1542
|
-
blobId: blob.id,
|
|
1543
|
-
sequence: blob.sequence,
|
|
1544
|
-
rowid: blob.rowid,
|
|
1545
|
-
isToolUse: true,
|
|
1546
|
-
toolName: toolName,
|
|
1547
|
-
toolId: toolId,
|
|
1548
|
-
toolInput: part.input ? JSON.stringify(part.input) : null,
|
|
1549
|
-
toolResult: null
|
|
1550
|
-
};
|
|
1551
|
-
converted.push(toolMessage);
|
|
1552
|
-
toolUseMap[toolId] = toolMessage;
|
|
1553
|
-
} else if (typeof part === 'string') {
|
|
1554
|
-
textParts.push(part);
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
// Add any remaining text/reasoning
|
|
1559
|
-
if (textParts.length > 0) {
|
|
1560
|
-
text = textParts.join('\n');
|
|
1561
|
-
if (reasoningText && !text) {
|
|
1562
|
-
// Just reasoning, no text
|
|
1563
|
-
converted.push({
|
|
1564
|
-
type: role,
|
|
1565
|
-
content: '',
|
|
1566
|
-
reasoning: reasoningText,
|
|
1567
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1568
|
-
blobId: blob.id,
|
|
1569
|
-
sequence: blob.sequence,
|
|
1570
|
-
rowid: blob.rowid
|
|
1571
|
-
});
|
|
1572
|
-
text = ''; // Clear to avoid duplicate
|
|
1573
|
-
}
|
|
1574
|
-
} else {
|
|
1575
|
-
text = '';
|
|
1576
|
-
}
|
|
1577
|
-
} else if (typeof content.content === 'string') {
|
|
1578
|
-
text = content.content;
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
} else if (content?.message?.role && content?.message?.content) {
|
|
1582
|
-
// Nested message format
|
|
1583
|
-
if (content.message.role === 'system') {
|
|
1584
|
-
continue;
|
|
1585
|
-
}
|
|
1586
|
-
role = content.message.role === 'user' ? 'user' : 'assistant';
|
|
1587
|
-
if (Array.isArray(content.message.content)) {
|
|
1588
|
-
text = content.message.content
|
|
1589
|
-
.map(p => (typeof p === 'string' ? p : (p?.text || '')))
|
|
1590
|
-
.filter(Boolean)
|
|
1591
|
-
.join('\n');
|
|
1592
|
-
} else if (typeof content.message.content === 'string') {
|
|
1593
|
-
text = content.message.content;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
} catch (e) {
|
|
1597
|
-
console.log('Error parsing blob content:', e);
|
|
1598
|
-
}
|
|
1599
|
-
if (text && text.trim()) {
|
|
1600
|
-
const message = {
|
|
1601
|
-
type: role,
|
|
1602
|
-
content: text,
|
|
1603
|
-
timestamp: new Date(Date.now() + blobIdx * 1000),
|
|
1604
|
-
blobId: blob.id,
|
|
1605
|
-
sequence: blob.sequence,
|
|
1606
|
-
rowid: blob.rowid
|
|
1607
|
-
};
|
|
1608
|
-
|
|
1609
|
-
// Add reasoning if we have it
|
|
1610
|
-
if (reasoningText) {
|
|
1611
|
-
message.reasoning = reasoningText;
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
converted.push(message);
|
|
1615
|
-
}
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
// Sort messages by sequence/rowid to maintain chronological order
|
|
1619
|
-
converted.sort((a, b) => {
|
|
1620
|
-
// First sort by sequence if available (clean 1,2,3... numbering)
|
|
1621
|
-
if (a.sequence !== undefined && b.sequence !== undefined) {
|
|
1622
|
-
return a.sequence - b.sequence;
|
|
1623
|
-
}
|
|
1624
|
-
// Then try rowid (original SQLite row IDs)
|
|
1625
|
-
if (a.rowid !== undefined && b.rowid !== undefined) {
|
|
1626
|
-
return a.rowid - b.rowid;
|
|
1627
|
-
}
|
|
1628
|
-
// Fallback to timestamp
|
|
1629
|
-
return new Date(a.timestamp) - new Date(b.timestamp);
|
|
1630
|
-
});
|
|
1631
|
-
|
|
1632
|
-
return converted;
|
|
1633
|
-
} catch (e) {
|
|
1634
|
-
console.error('Error loading Cursor session messages:', e);
|
|
1635
|
-
return [];
|
|
1636
|
-
} finally {
|
|
1637
|
-
setIsLoadingSessionMessages(false);
|
|
1638
|
-
}
|
|
1639
|
-
}, []);
|
|
1640
|
-
|
|
1641
|
-
// Actual diff calculation function
|
|
1642
|
-
const calculateDiff = (oldStr, newStr) => {
|
|
1643
|
-
const oldLines = oldStr.split('\n');
|
|
1644
|
-
const newLines = newStr.split('\n');
|
|
1645
|
-
|
|
1646
|
-
// Simple diff algorithm - find common lines and differences
|
|
1647
|
-
const diffLines = [];
|
|
1648
|
-
let oldIndex = 0;
|
|
1649
|
-
let newIndex = 0;
|
|
1650
|
-
|
|
1651
|
-
while (oldIndex < oldLines.length || newIndex < newLines.length) {
|
|
1652
|
-
const oldLine = oldLines[oldIndex];
|
|
1653
|
-
const newLine = newLines[newIndex];
|
|
1654
|
-
|
|
1655
|
-
if (oldIndex >= oldLines.length) {
|
|
1656
|
-
// Only new lines remaining
|
|
1657
|
-
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
|
1658
|
-
newIndex++;
|
|
1659
|
-
} else if (newIndex >= newLines.length) {
|
|
1660
|
-
// Only old lines remaining
|
|
1661
|
-
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
|
1662
|
-
oldIndex++;
|
|
1663
|
-
} else if (oldLine === newLine) {
|
|
1664
|
-
// Lines are the same - skip in diff view (or show as context)
|
|
1665
|
-
oldIndex++;
|
|
1666
|
-
newIndex++;
|
|
1667
|
-
} else {
|
|
1668
|
-
// Lines are different
|
|
1669
|
-
diffLines.push({ type: 'removed', content: oldLine, lineNum: oldIndex + 1 });
|
|
1670
|
-
diffLines.push({ type: 'added', content: newLine, lineNum: newIndex + 1 });
|
|
1671
|
-
oldIndex++;
|
|
1672
|
-
newIndex++;
|
|
1673
|
-
}
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
return diffLines;
|
|
1677
|
-
};
|
|
1678
|
-
|
|
1679
|
-
const convertSessionMessages = (rawMessages) => {
|
|
1680
|
-
const converted = [];
|
|
1681
|
-
const toolResults = new Map(); // Map tool_use_id to tool result
|
|
1682
|
-
|
|
1683
|
-
// First pass: collect all tool results
|
|
1684
|
-
for (const msg of rawMessages) {
|
|
1685
|
-
if (msg.message?.role === 'user' && Array.isArray(msg.message?.content)) {
|
|
1686
|
-
for (const part of msg.message.content) {
|
|
1687
|
-
if (part.type === 'tool_result') {
|
|
1688
|
-
toolResults.set(part.tool_use_id, {
|
|
1689
|
-
content: part.content,
|
|
1690
|
-
isError: part.is_error,
|
|
1691
|
-
timestamp: new Date(msg.timestamp || Date.now())
|
|
1692
|
-
});
|
|
1693
|
-
}
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
|
-
|
|
1698
|
-
// Second pass: process messages and attach tool results to tool uses
|
|
1699
|
-
for (const msg of rawMessages) {
|
|
1700
|
-
// Handle user messages
|
|
1701
|
-
if (msg.message?.role === 'user' && msg.message?.content) {
|
|
1702
|
-
let content = '';
|
|
1703
|
-
let messageType = 'user';
|
|
1704
|
-
|
|
1705
|
-
if (Array.isArray(msg.message.content)) {
|
|
1706
|
-
// Handle array content, but skip tool results (they're attached to tool uses)
|
|
1707
|
-
const textParts = [];
|
|
1708
|
-
|
|
1709
|
-
for (const part of msg.message.content) {
|
|
1710
|
-
if (part.type === 'text') {
|
|
1711
|
-
textParts.push(part.text);
|
|
1712
|
-
}
|
|
1713
|
-
// Skip tool_result parts - they're handled in the first pass
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
content = textParts.join('\n');
|
|
1717
|
-
} else if (typeof msg.message.content === 'string') {
|
|
1718
|
-
content = msg.message.content;
|
|
1719
|
-
} else {
|
|
1720
|
-
content = String(msg.message.content);
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
// Skip command messages and empty content
|
|
1724
|
-
if (content && !content.startsWith('<command-name>') && !content.startsWith('[Request interrupted')) {
|
|
1725
|
-
converted.push({
|
|
1726
|
-
type: messageType,
|
|
1727
|
-
content: content,
|
|
1728
|
-
timestamp: msg.timestamp || new Date().toISOString()
|
|
1729
|
-
});
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
// Handle assistant messages
|
|
1734
|
-
else if (msg.message?.role === 'assistant' && msg.message?.content) {
|
|
1735
|
-
if (Array.isArray(msg.message.content)) {
|
|
1736
|
-
for (const part of msg.message.content) {
|
|
1737
|
-
if (part.type === 'text') {
|
|
1738
|
-
converted.push({
|
|
1739
|
-
type: 'assistant',
|
|
1740
|
-
content: part.text,
|
|
1741
|
-
timestamp: msg.timestamp || new Date().toISOString()
|
|
1742
|
-
});
|
|
1743
|
-
} else if (part.type === 'tool_use') {
|
|
1744
|
-
// Get the corresponding tool result
|
|
1745
|
-
const toolResult = toolResults.get(part.id);
|
|
1746
|
-
|
|
1747
|
-
converted.push({
|
|
1748
|
-
type: 'assistant',
|
|
1749
|
-
content: '',
|
|
1750
|
-
timestamp: msg.timestamp || new Date().toISOString(),
|
|
1751
|
-
isToolUse: true,
|
|
1752
|
-
toolName: part.name,
|
|
1753
|
-
toolInput: JSON.stringify(part.input),
|
|
1754
|
-
toolResult: toolResult ? (typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content)) : null,
|
|
1755
|
-
toolError: toolResult?.isError || false,
|
|
1756
|
-
toolResultTimestamp: toolResult?.timestamp || new Date()
|
|
1757
|
-
});
|
|
1758
|
-
}
|
|
1759
|
-
}
|
|
1760
|
-
} else if (typeof msg.message.content === 'string') {
|
|
1761
|
-
converted.push({
|
|
1762
|
-
type: 'assistant',
|
|
1763
|
-
content: msg.message.content,
|
|
1764
|
-
timestamp: msg.timestamp || new Date().toISOString()
|
|
1765
|
-
});
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
|
|
1770
|
-
return converted;
|
|
1771
|
-
};
|
|
1772
|
-
|
|
1773
|
-
// Memoize expensive convertSessionMessages operation
|
|
1774
|
-
const convertedMessages = useMemo(() => {
|
|
1775
|
-
return convertSessionMessages(sessionMessages);
|
|
1776
|
-
}, [sessionMessages]);
|
|
1777
|
-
|
|
1778
|
-
// Define scroll functions early to avoid hoisting issues in useEffect dependencies
|
|
1779
|
-
const scrollToBottom = useCallback(() => {
|
|
1780
|
-
if (scrollContainerRef.current) {
|
|
1781
|
-
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
|
1782
|
-
setIsUserScrolledUp(false);
|
|
1783
|
-
}
|
|
1784
|
-
}, []);
|
|
1785
|
-
|
|
1786
|
-
// Check if user is near the bottom of the scroll container
|
|
1787
|
-
const isNearBottom = useCallback(() => {
|
|
1788
|
-
if (!scrollContainerRef.current) return false;
|
|
1789
|
-
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
|
1790
|
-
// Consider "near bottom" if within 50px of the bottom
|
|
1791
|
-
return scrollHeight - scrollTop - clientHeight < 50;
|
|
1792
|
-
}, []);
|
|
1793
|
-
|
|
1794
|
-
// Handle scroll events to detect when user manually scrolls up and load more messages
|
|
1795
|
-
const handleScroll = useCallback(async () => {
|
|
1796
|
-
if (scrollContainerRef.current) {
|
|
1797
|
-
const container = scrollContainerRef.current;
|
|
1798
|
-
const nearBottom = isNearBottom();
|
|
1799
|
-
setIsUserScrolledUp(!nearBottom);
|
|
1800
|
-
|
|
1801
|
-
// Check if we should load more messages (scrolled near top)
|
|
1802
|
-
const scrolledNearTop = container.scrollTop < 100;
|
|
1803
|
-
const provider = localStorage.getItem('selected-provider') || 'claude';
|
|
1804
|
-
|
|
1805
|
-
if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') {
|
|
1806
|
-
// Save current scroll position
|
|
1807
|
-
const previousScrollHeight = container.scrollHeight;
|
|
1808
|
-
const previousScrollTop = container.scrollTop;
|
|
1809
|
-
|
|
1810
|
-
// Load more messages
|
|
1811
|
-
const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true);
|
|
1812
|
-
|
|
1813
|
-
if (moreMessages.length > 0) {
|
|
1814
|
-
// Prepend new messages to the existing ones
|
|
1815
|
-
setSessionMessages(prev => [...moreMessages, ...prev]);
|
|
1816
|
-
|
|
1817
|
-
// Restore scroll position after DOM update
|
|
1818
|
-
setTimeout(() => {
|
|
1819
|
-
if (scrollContainerRef.current) {
|
|
1820
|
-
const newScrollHeight = scrollContainerRef.current.scrollHeight;
|
|
1821
|
-
const scrollDiff = newScrollHeight - previousScrollHeight;
|
|
1822
|
-
scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff;
|
|
1823
|
-
}
|
|
1824
|
-
}, 0);
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
}, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]);
|
|
1829
|
-
|
|
1830
|
-
useEffect(() => {
|
|
1831
|
-
// Load session messages when session changes
|
|
1832
|
-
const loadMessages = async () => {
|
|
1833
|
-
if (selectedSession && selectedProject) {
|
|
1834
|
-
const provider = localStorage.getItem('selected-provider') || 'claude';
|
|
1835
|
-
|
|
1836
|
-
// Reset pagination state when switching sessions
|
|
1837
|
-
setMessagesOffset(0);
|
|
1838
|
-
setHasMoreMessages(false);
|
|
1839
|
-
setTotalMessages(0);
|
|
1840
|
-
|
|
1841
|
-
if (provider === 'cursor') {
|
|
1842
|
-
// For Cursor, set the session ID for resuming
|
|
1843
|
-
setCurrentSessionId(selectedSession.id);
|
|
1844
|
-
sessionStorage.setItem('cursorSessionId', selectedSession.id);
|
|
1845
|
-
|
|
1846
|
-
// Only load messages from SQLite if this is NOT a system-initiated session change
|
|
1847
|
-
// For system-initiated changes, preserve existing messages
|
|
1848
|
-
if (!isSystemSessionChange) {
|
|
1849
|
-
// Load historical messages for Cursor session from SQLite
|
|
1850
|
-
const projectPath = selectedProject.fullPath || selectedProject.path;
|
|
1851
|
-
const converted = await loadCursorSessionMessages(projectPath, selectedSession.id);
|
|
1852
|
-
setSessionMessages([]);
|
|
1853
|
-
setChatMessages(converted);
|
|
1854
|
-
} else {
|
|
1855
|
-
// Reset the flag after handling system session change
|
|
1856
|
-
setIsSystemSessionChange(false);
|
|
1857
|
-
}
|
|
1858
|
-
} else {
|
|
1859
|
-
// For Claude, load messages normally with pagination
|
|
1860
|
-
setCurrentSessionId(selectedSession.id);
|
|
1861
|
-
|
|
1862
|
-
// Only load messages from API if this is a user-initiated session change
|
|
1863
|
-
// For system-initiated changes, preserve existing messages and rely on WebSocket
|
|
1864
|
-
if (!isSystemSessionChange) {
|
|
1865
|
-
const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false);
|
|
1866
|
-
setSessionMessages(messages);
|
|
1867
|
-
// convertedMessages will be automatically updated via useMemo
|
|
1868
|
-
// Scroll to bottom after loading session messages if auto-scroll is enabled
|
|
1869
|
-
if (autoScrollToBottom) {
|
|
1870
|
-
setTimeout(() => scrollToBottom(), 200);
|
|
1871
|
-
}
|
|
1872
|
-
} else {
|
|
1873
|
-
// Reset the flag after handling system session change
|
|
1874
|
-
setIsSystemSessionChange(false);
|
|
1875
|
-
}
|
|
1876
|
-
}
|
|
1877
|
-
} else {
|
|
1878
|
-
// Only clear messages if this is NOT a system-initiated session change AND we're not loading
|
|
1879
|
-
// During system session changes or while loading, preserve the chat messages
|
|
1880
|
-
if (!isSystemSessionChange && !isLoading) {
|
|
1881
|
-
setChatMessages([]);
|
|
1882
|
-
setSessionMessages([]);
|
|
1883
|
-
}
|
|
1884
|
-
setCurrentSessionId(null);
|
|
1885
|
-
sessionStorage.removeItem('cursorSessionId');
|
|
1886
|
-
setMessagesOffset(0);
|
|
1887
|
-
setHasMoreMessages(false);
|
|
1888
|
-
setTotalMessages(0);
|
|
1889
|
-
}
|
|
1890
|
-
};
|
|
1891
|
-
|
|
1892
|
-
loadMessages();
|
|
1893
|
-
}, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]);
|
|
1894
|
-
|
|
1895
|
-
// Update chatMessages when convertedMessages changes
|
|
1896
|
-
useEffect(() => {
|
|
1897
|
-
if (sessionMessages.length > 0) {
|
|
1898
|
-
setChatMessages(convertedMessages);
|
|
1899
|
-
}
|
|
1900
|
-
}, [convertedMessages, sessionMessages]);
|
|
1901
|
-
|
|
1902
|
-
// Notify parent when input focus changes
|
|
1903
|
-
useEffect(() => {
|
|
1904
|
-
if (onInputFocusChange) {
|
|
1905
|
-
onInputFocusChange(isInputFocused);
|
|
1906
|
-
}
|
|
1907
|
-
}, [isInputFocused, onInputFocusChange]);
|
|
1908
|
-
|
|
1909
|
-
// Persist input draft to localStorage
|
|
1910
|
-
useEffect(() => {
|
|
1911
|
-
if (selectedProject && input !== '') {
|
|
1912
|
-
safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, input);
|
|
1913
|
-
} else if (selectedProject && input === '') {
|
|
1914
|
-
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
|
1915
|
-
}
|
|
1916
|
-
}, [input, selectedProject]);
|
|
1917
|
-
|
|
1918
|
-
// Persist chat messages to localStorage
|
|
1919
|
-
useEffect(() => {
|
|
1920
|
-
if (selectedProject && chatMessages.length > 0) {
|
|
1921
|
-
safeLocalStorage.setItem(`chat_messages_${selectedProject.name}`, JSON.stringify(chatMessages));
|
|
1922
|
-
}
|
|
1923
|
-
}, [chatMessages, selectedProject]);
|
|
1924
|
-
|
|
1925
|
-
// Load saved state when project changes (but don't interfere with session loading)
|
|
1926
|
-
useEffect(() => {
|
|
1927
|
-
if (selectedProject) {
|
|
1928
|
-
// Always load saved input draft for the project
|
|
1929
|
-
const savedInput = safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
|
1930
|
-
if (savedInput !== input) {
|
|
1931
|
-
setInput(savedInput);
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
}, [selectedProject?.name]);
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
useEffect(() => {
|
|
1938
|
-
// Handle WebSocket messages
|
|
1939
|
-
if (messages.length > 0) {
|
|
1940
|
-
const latestMessage = messages[messages.length - 1];
|
|
1941
|
-
|
|
1942
|
-
switch (latestMessage.type) {
|
|
1943
|
-
case 'session-created':
|
|
1944
|
-
// New session created by Claude CLI - we receive the real session ID here
|
|
1945
|
-
// Store it temporarily until conversation completes (prevents premature session association)
|
|
1946
|
-
if (latestMessage.sessionId && !currentSessionId) {
|
|
1947
|
-
sessionStorage.setItem('pendingSessionId', latestMessage.sessionId);
|
|
1948
|
-
|
|
1949
|
-
// Session Protection: Replace temporary "new-session-*" identifier with real session ID
|
|
1950
|
-
// This maintains protection continuity - no gap between temp ID and real ID
|
|
1951
|
-
// The temporary session is removed and real session is marked as active
|
|
1952
|
-
if (onReplaceTemporarySession) {
|
|
1953
|
-
onReplaceTemporarySession(latestMessage.sessionId);
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
break;
|
|
1957
|
-
|
|
1958
|
-
case 'claude-response':
|
|
1959
|
-
const messageData = latestMessage.data.message || latestMessage.data;
|
|
1960
|
-
|
|
1961
|
-
// Handle Cursor streaming format (content_block_delta / content_block_stop)
|
|
1962
|
-
if (messageData && typeof messageData === 'object' && messageData.type) {
|
|
1963
|
-
if (messageData.type === 'content_block_delta' && messageData.delta?.text) {
|
|
1964
|
-
// Buffer deltas and flush periodically to reduce rerenders
|
|
1965
|
-
streamBufferRef.current += messageData.delta.text;
|
|
1966
|
-
if (!streamTimerRef.current) {
|
|
1967
|
-
streamTimerRef.current = setTimeout(() => {
|
|
1968
|
-
const chunk = streamBufferRef.current;
|
|
1969
|
-
streamBufferRef.current = '';
|
|
1970
|
-
streamTimerRef.current = null;
|
|
1971
|
-
if (!chunk) return;
|
|
1972
|
-
setChatMessages(prev => {
|
|
1973
|
-
const updated = [...prev];
|
|
1974
|
-
const last = updated[updated.length - 1];
|
|
1975
|
-
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
|
1976
|
-
last.content = (last.content || '') + chunk;
|
|
1977
|
-
} else {
|
|
1978
|
-
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
|
1979
|
-
}
|
|
1980
|
-
return updated;
|
|
1981
|
-
});
|
|
1982
|
-
}, 100);
|
|
1983
|
-
}
|
|
1984
|
-
return;
|
|
1985
|
-
}
|
|
1986
|
-
if (messageData.type === 'content_block_stop') {
|
|
1987
|
-
// Flush any buffered text and mark streaming message complete
|
|
1988
|
-
if (streamTimerRef.current) {
|
|
1989
|
-
clearTimeout(streamTimerRef.current);
|
|
1990
|
-
streamTimerRef.current = null;
|
|
1991
|
-
}
|
|
1992
|
-
const chunk = streamBufferRef.current;
|
|
1993
|
-
streamBufferRef.current = '';
|
|
1994
|
-
if (chunk) {
|
|
1995
|
-
setChatMessages(prev => {
|
|
1996
|
-
const updated = [...prev];
|
|
1997
|
-
const last = updated[updated.length - 1];
|
|
1998
|
-
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
|
1999
|
-
last.content = (last.content || '') + chunk;
|
|
2000
|
-
} else {
|
|
2001
|
-
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
|
2002
|
-
}
|
|
2003
|
-
return updated;
|
|
2004
|
-
});
|
|
2005
|
-
}
|
|
2006
|
-
setChatMessages(prev => {
|
|
2007
|
-
const updated = [...prev];
|
|
2008
|
-
const last = updated[updated.length - 1];
|
|
2009
|
-
if (last && last.type === 'assistant' && last.isStreaming) {
|
|
2010
|
-
last.isStreaming = false;
|
|
2011
|
-
}
|
|
2012
|
-
return updated;
|
|
2013
|
-
});
|
|
2014
|
-
return;
|
|
2015
|
-
}
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
// Handle Claude CLI session duplication bug workaround:
|
|
2019
|
-
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
|
2020
|
-
// We detect this by checking for system/init messages with session_id that differs
|
|
2021
|
-
// from our current session. When found, we need to switch the user to the new session.
|
|
2022
|
-
// This works exactly like new session detection - preserve messages during navigation.
|
|
2023
|
-
if (latestMessage.data.type === 'system' &&
|
|
2024
|
-
latestMessage.data.subtype === 'init' &&
|
|
2025
|
-
latestMessage.data.session_id &&
|
|
2026
|
-
currentSessionId &&
|
|
2027
|
-
latestMessage.data.session_id !== currentSessionId) {
|
|
2028
|
-
|
|
2029
|
-
console.log('🔄 Claude CLI session duplication detected:', {
|
|
2030
|
-
originalSession: currentSessionId,
|
|
2031
|
-
newSession: latestMessage.data.session_id
|
|
2032
|
-
});
|
|
2033
|
-
|
|
2034
|
-
// Mark this as a system-initiated session change to preserve messages
|
|
2035
|
-
// This works exactly like new session init - messages stay visible during navigation
|
|
2036
|
-
setIsSystemSessionChange(true);
|
|
2037
|
-
|
|
2038
|
-
// Switch to the new session using React Router navigation
|
|
2039
|
-
// This triggers the session loading logic in App.jsx without a page reload
|
|
2040
|
-
if (onNavigateToSession) {
|
|
2041
|
-
onNavigateToSession(latestMessage.data.session_id);
|
|
2042
|
-
}
|
|
2043
|
-
return; // Don't process the message further, let the navigation handle it
|
|
2044
|
-
}
|
|
2045
|
-
|
|
2046
|
-
// Handle system/init for new sessions (when currentSessionId is null)
|
|
2047
|
-
if (latestMessage.data.type === 'system' &&
|
|
2048
|
-
latestMessage.data.subtype === 'init' &&
|
|
2049
|
-
latestMessage.data.session_id &&
|
|
2050
|
-
!currentSessionId) {
|
|
2051
|
-
|
|
2052
|
-
console.log('🔄 New session init detected:', {
|
|
2053
|
-
newSession: latestMessage.data.session_id
|
|
2054
|
-
});
|
|
2055
|
-
|
|
2056
|
-
// Mark this as a system-initiated session change to preserve messages
|
|
2057
|
-
setIsSystemSessionChange(true);
|
|
2058
|
-
|
|
2059
|
-
// Switch to the new session
|
|
2060
|
-
if (onNavigateToSession) {
|
|
2061
|
-
onNavigateToSession(latestMessage.data.session_id);
|
|
2062
|
-
}
|
|
2063
|
-
return; // Don't process the message further, let the navigation handle it
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
// For system/init messages that match current session, just ignore them
|
|
2067
|
-
if (latestMessage.data.type === 'system' &&
|
|
2068
|
-
latestMessage.data.subtype === 'init' &&
|
|
2069
|
-
latestMessage.data.session_id &&
|
|
2070
|
-
currentSessionId &&
|
|
2071
|
-
latestMessage.data.session_id === currentSessionId) {
|
|
2072
|
-
console.log('🔄 System init message for current session, ignoring');
|
|
2073
|
-
return; // Don't process the message further
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
// Handle different types of content in the response
|
|
2077
|
-
if (Array.isArray(messageData.content)) {
|
|
2078
|
-
for (const part of messageData.content) {
|
|
2079
|
-
if (part.type === 'tool_use') {
|
|
2080
|
-
// Add tool use message
|
|
2081
|
-
const toolInput = part.input ? JSON.stringify(part.input, null, 2) : '';
|
|
2082
|
-
setChatMessages(prev => [...prev, {
|
|
2083
|
-
type: 'assistant',
|
|
2084
|
-
content: '',
|
|
2085
|
-
timestamp: new Date(),
|
|
2086
|
-
isToolUse: true,
|
|
2087
|
-
toolName: part.name,
|
|
2088
|
-
toolInput: toolInput,
|
|
2089
|
-
toolId: part.id,
|
|
2090
|
-
toolResult: null // Will be updated when result comes in
|
|
2091
|
-
}]);
|
|
2092
|
-
} else if (part.type === 'text' && part.text?.trim()) {
|
|
2093
|
-
// Normalize usage limit message to local time
|
|
2094
|
-
let content = formatUsageLimitText(part.text);
|
|
2095
|
-
|
|
2096
|
-
// Add regular text message
|
|
2097
|
-
setChatMessages(prev => [...prev, {
|
|
2098
|
-
type: 'assistant',
|
|
2099
|
-
content: content,
|
|
2100
|
-
timestamp: new Date()
|
|
2101
|
-
}]);
|
|
2102
|
-
}
|
|
2103
|
-
}
|
|
2104
|
-
} else if (typeof messageData.content === 'string' && messageData.content.trim()) {
|
|
2105
|
-
// Normalize usage limit message to local time
|
|
2106
|
-
let content = formatUsageLimitText(messageData.content);
|
|
2107
|
-
|
|
2108
|
-
// Add regular text message
|
|
2109
|
-
setChatMessages(prev => [...prev, {
|
|
2110
|
-
type: 'assistant',
|
|
2111
|
-
content: content,
|
|
2112
|
-
timestamp: new Date()
|
|
2113
|
-
}]);
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
// Handle tool results from user messages (these come separately)
|
|
2117
|
-
if (messageData.role === 'user' && Array.isArray(messageData.content)) {
|
|
2118
|
-
for (const part of messageData.content) {
|
|
2119
|
-
if (part.type === 'tool_result') {
|
|
2120
|
-
// Find the corresponding tool use and update it with the result
|
|
2121
|
-
setChatMessages(prev => prev.map(msg => {
|
|
2122
|
-
if (msg.isToolUse && msg.toolId === part.tool_use_id) {
|
|
2123
|
-
return {
|
|
2124
|
-
...msg,
|
|
2125
|
-
toolResult: {
|
|
2126
|
-
content: part.content,
|
|
2127
|
-
isError: part.is_error,
|
|
2128
|
-
timestamp: new Date()
|
|
2129
|
-
}
|
|
2130
|
-
};
|
|
2131
|
-
}
|
|
2132
|
-
return msg;
|
|
2133
|
-
}));
|
|
2134
|
-
}
|
|
2135
|
-
}
|
|
2136
|
-
}
|
|
2137
|
-
break;
|
|
2138
|
-
|
|
2139
|
-
case 'claude-output':
|
|
2140
|
-
{
|
|
2141
|
-
const cleaned = String(latestMessage.data || '');
|
|
2142
|
-
if (cleaned.trim()) {
|
|
2143
|
-
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
|
2144
|
-
if (!streamTimerRef.current) {
|
|
2145
|
-
streamTimerRef.current = setTimeout(() => {
|
|
2146
|
-
const chunk = streamBufferRef.current;
|
|
2147
|
-
streamBufferRef.current = '';
|
|
2148
|
-
streamTimerRef.current = null;
|
|
2149
|
-
if (!chunk) return;
|
|
2150
|
-
setChatMessages(prev => {
|
|
2151
|
-
const updated = [...prev];
|
|
2152
|
-
const last = updated[updated.length - 1];
|
|
2153
|
-
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
|
2154
|
-
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
|
2155
|
-
} else {
|
|
2156
|
-
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
|
2157
|
-
}
|
|
2158
|
-
return updated;
|
|
2159
|
-
});
|
|
2160
|
-
}, 100);
|
|
2161
|
-
}
|
|
2162
|
-
}
|
|
2163
|
-
}
|
|
2164
|
-
break;
|
|
2165
|
-
case 'claude-interactive-prompt':
|
|
2166
|
-
// Handle interactive prompts from CLI
|
|
2167
|
-
setChatMessages(prev => [...prev, {
|
|
2168
|
-
type: 'assistant',
|
|
2169
|
-
content: latestMessage.data,
|
|
2170
|
-
timestamp: new Date(),
|
|
2171
|
-
isInteractivePrompt: true
|
|
2172
|
-
}]);
|
|
2173
|
-
break;
|
|
2174
|
-
|
|
2175
|
-
case 'claude-error':
|
|
2176
|
-
setChatMessages(prev => [...prev, {
|
|
2177
|
-
type: 'error',
|
|
2178
|
-
content: `Error: ${latestMessage.error}`,
|
|
2179
|
-
timestamp: new Date()
|
|
2180
|
-
}]);
|
|
2181
|
-
break;
|
|
2182
|
-
|
|
2183
|
-
case 'cursor-system':
|
|
2184
|
-
// Handle Cursor system/init messages similar to Claude
|
|
2185
|
-
try {
|
|
2186
|
-
const cdata = latestMessage.data;
|
|
2187
|
-
if (cdata && cdata.type === 'system' && cdata.subtype === 'init' && cdata.session_id) {
|
|
2188
|
-
// If we already have a session and this differs, switch (duplication/redirect)
|
|
2189
|
-
if (currentSessionId && cdata.session_id !== currentSessionId) {
|
|
2190
|
-
console.log('🔄 Cursor session switch detected:', { originalSession: currentSessionId, newSession: cdata.session_id });
|
|
2191
|
-
setIsSystemSessionChange(true);
|
|
2192
|
-
if (onNavigateToSession) {
|
|
2193
|
-
onNavigateToSession(cdata.session_id);
|
|
2194
|
-
}
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
// If we don't yet have a session, adopt this one
|
|
2198
|
-
if (!currentSessionId) {
|
|
2199
|
-
console.log('🔄 Cursor new session init detected:', { newSession: cdata.session_id });
|
|
2200
|
-
setIsSystemSessionChange(true);
|
|
2201
|
-
if (onNavigateToSession) {
|
|
2202
|
-
onNavigateToSession(cdata.session_id);
|
|
2203
|
-
}
|
|
2204
|
-
return;
|
|
2205
|
-
}
|
|
2206
|
-
}
|
|
2207
|
-
// For other cursor-system messages, avoid dumping raw objects to chat
|
|
2208
|
-
} catch (e) {
|
|
2209
|
-
console.warn('Error handling cursor-system message:', e);
|
|
2210
|
-
}
|
|
2211
|
-
break;
|
|
2212
|
-
|
|
2213
|
-
case 'cursor-user':
|
|
2214
|
-
// Handle Cursor user messages (usually echoes)
|
|
2215
|
-
// Don't add user messages as they're already shown from input
|
|
2216
|
-
break;
|
|
2217
|
-
|
|
2218
|
-
case 'cursor-tool-use':
|
|
2219
|
-
// Handle Cursor tool use messages
|
|
2220
|
-
setChatMessages(prev => [...prev, {
|
|
2221
|
-
type: 'assistant',
|
|
2222
|
-
content: `Using tool: ${latestMessage.tool} ${latestMessage.input ? `with ${latestMessage.input}` : ''}`,
|
|
2223
|
-
timestamp: new Date(),
|
|
2224
|
-
isToolUse: true,
|
|
2225
|
-
toolName: latestMessage.tool,
|
|
2226
|
-
toolInput: latestMessage.input
|
|
2227
|
-
}]);
|
|
2228
|
-
break;
|
|
2229
|
-
|
|
2230
|
-
case 'cursor-error':
|
|
2231
|
-
// Show Cursor errors as error messages in chat
|
|
2232
|
-
setChatMessages(prev => [...prev, {
|
|
2233
|
-
type: 'error',
|
|
2234
|
-
content: `Cursor error: ${latestMessage.error || 'Unknown error'}`,
|
|
2235
|
-
timestamp: new Date()
|
|
2236
|
-
}]);
|
|
2237
|
-
break;
|
|
2238
|
-
|
|
2239
|
-
case 'cursor-result':
|
|
2240
|
-
// Handle Cursor completion and final result text
|
|
2241
|
-
setIsLoading(false);
|
|
2242
|
-
setCanAbortSession(false);
|
|
2243
|
-
setClaudeStatus(null);
|
|
2244
|
-
try {
|
|
2245
|
-
const r = latestMessage.data || {};
|
|
2246
|
-
const textResult = typeof r.result === 'string' ? r.result : '';
|
|
2247
|
-
// Flush buffered deltas before finalizing
|
|
2248
|
-
if (streamTimerRef.current) {
|
|
2249
|
-
clearTimeout(streamTimerRef.current);
|
|
2250
|
-
streamTimerRef.current = null;
|
|
2251
|
-
}
|
|
2252
|
-
const pendingChunk = streamBufferRef.current;
|
|
2253
|
-
streamBufferRef.current = '';
|
|
2254
|
-
|
|
2255
|
-
setChatMessages(prev => {
|
|
2256
|
-
const updated = [...prev];
|
|
2257
|
-
// Try to consolidate into the last streaming assistant message
|
|
2258
|
-
const last = updated[updated.length - 1];
|
|
2259
|
-
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
|
2260
|
-
// Replace streaming content with the final content so deltas don't remain
|
|
2261
|
-
const finalContent = textResult && textResult.trim() ? textResult : (last.content || '') + (pendingChunk || '');
|
|
2262
|
-
last.content = finalContent;
|
|
2263
|
-
last.isStreaming = false;
|
|
2264
|
-
} else if (textResult && textResult.trim()) {
|
|
2265
|
-
updated.push({ type: r.is_error ? 'error' : 'assistant', content: textResult, timestamp: new Date(), isStreaming: false });
|
|
2266
|
-
}
|
|
2267
|
-
return updated;
|
|
2268
|
-
});
|
|
2269
|
-
} catch (e) {
|
|
2270
|
-
console.warn('Error handling cursor-result message:', e);
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
// Mark session as inactive
|
|
2274
|
-
const cursorSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
|
|
2275
|
-
if (cursorSessionId && onSessionInactive) {
|
|
2276
|
-
onSessionInactive(cursorSessionId);
|
|
2277
|
-
}
|
|
2278
|
-
|
|
2279
|
-
// Store session ID for future use and trigger refresh
|
|
2280
|
-
if (cursorSessionId && !currentSessionId) {
|
|
2281
|
-
setCurrentSessionId(cursorSessionId);
|
|
2282
|
-
sessionStorage.removeItem('pendingSessionId');
|
|
2283
|
-
|
|
2284
|
-
// Trigger a project refresh to update the sidebar with the new session
|
|
2285
|
-
if (window.refreshProjects) {
|
|
2286
|
-
setTimeout(() => window.refreshProjects(), 500);
|
|
2287
|
-
}
|
|
2288
|
-
}
|
|
2289
|
-
break;
|
|
2290
|
-
|
|
2291
|
-
case 'cursor-output':
|
|
2292
|
-
// Handle Cursor raw terminal output; strip ANSI and ignore empty control-only payloads
|
|
2293
|
-
try {
|
|
2294
|
-
const raw = String(latestMessage.data ?? '');
|
|
2295
|
-
const cleaned = raw.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
|
|
2296
|
-
if (cleaned) {
|
|
2297
|
-
streamBufferRef.current += (streamBufferRef.current ? `\n${cleaned}` : cleaned);
|
|
2298
|
-
if (!streamTimerRef.current) {
|
|
2299
|
-
streamTimerRef.current = setTimeout(() => {
|
|
2300
|
-
const chunk = streamBufferRef.current;
|
|
2301
|
-
streamBufferRef.current = '';
|
|
2302
|
-
streamTimerRef.current = null;
|
|
2303
|
-
if (!chunk) return;
|
|
2304
|
-
setChatMessages(prev => {
|
|
2305
|
-
const updated = [...prev];
|
|
2306
|
-
const last = updated[updated.length - 1];
|
|
2307
|
-
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
|
|
2308
|
-
last.content = last.content ? `${last.content}\n${chunk}` : chunk;
|
|
2309
|
-
} else {
|
|
2310
|
-
updated.push({ type: 'assistant', content: chunk, timestamp: new Date(), isStreaming: true });
|
|
2311
|
-
}
|
|
2312
|
-
return updated;
|
|
2313
|
-
});
|
|
2314
|
-
}, 100);
|
|
2315
|
-
}
|
|
2316
|
-
}
|
|
2317
|
-
} catch (e) {
|
|
2318
|
-
console.warn('Error handling cursor-output message:', e);
|
|
2319
|
-
}
|
|
2320
|
-
break;
|
|
2321
|
-
|
|
2322
|
-
case 'claude-complete':
|
|
2323
|
-
setIsLoading(false);
|
|
2324
|
-
setCanAbortSession(false);
|
|
2325
|
-
setClaudeStatus(null);
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
// Session Protection: Mark session as inactive to re-enable automatic project updates
|
|
2329
|
-
// Conversation is complete, safe to allow project updates again
|
|
2330
|
-
// Use real session ID if available, otherwise use pending session ID
|
|
2331
|
-
const activeSessionId = currentSessionId || sessionStorage.getItem('pendingSessionId');
|
|
2332
|
-
if (activeSessionId && onSessionInactive) {
|
|
2333
|
-
onSessionInactive(activeSessionId);
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
// If we have a pending session ID and the conversation completed successfully, use it
|
|
2337
|
-
const pendingSessionId = sessionStorage.getItem('pendingSessionId');
|
|
2338
|
-
if (pendingSessionId && !currentSessionId && latestMessage.exitCode === 0) {
|
|
2339
|
-
setCurrentSessionId(pendingSessionId);
|
|
2340
|
-
sessionStorage.removeItem('pendingSessionId');
|
|
2341
|
-
|
|
2342
|
-
// Trigger a project refresh to update the sidebar with the new session
|
|
2343
|
-
if (window.refreshProjects) {
|
|
2344
|
-
setTimeout(() => window.refreshProjects(), 500);
|
|
2345
|
-
}
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
// Clear persisted chat messages after successful completion
|
|
2349
|
-
if (selectedProject && latestMessage.exitCode === 0) {
|
|
2350
|
-
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
|
2351
|
-
}
|
|
2352
|
-
break;
|
|
2353
|
-
|
|
2354
|
-
case 'session-aborted':
|
|
2355
|
-
setIsLoading(false);
|
|
2356
|
-
setCanAbortSession(false);
|
|
2357
|
-
setClaudeStatus(null);
|
|
2358
|
-
|
|
2359
|
-
// Session Protection: Mark session as inactive when aborted
|
|
2360
|
-
// User or system aborted the conversation, re-enable project updates
|
|
2361
|
-
if (currentSessionId && onSessionInactive) {
|
|
2362
|
-
onSessionInactive(currentSessionId);
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
setChatMessages(prev => [...prev, {
|
|
2366
|
-
type: 'assistant',
|
|
2367
|
-
content: 'Session interrupted by user.',
|
|
2368
|
-
timestamp: new Date()
|
|
2369
|
-
}]);
|
|
2370
|
-
break;
|
|
2371
|
-
|
|
2372
|
-
case 'claude-status':
|
|
2373
|
-
// Handle Claude working status messages
|
|
2374
|
-
const statusData = latestMessage.data;
|
|
2375
|
-
if (statusData) {
|
|
2376
|
-
// Parse the status message to extract relevant information
|
|
2377
|
-
let statusInfo = {
|
|
2378
|
-
text: 'Working...',
|
|
2379
|
-
tokens: 0,
|
|
2380
|
-
can_interrupt: true
|
|
2381
|
-
};
|
|
2382
|
-
|
|
2383
|
-
// Check for different status message formats
|
|
2384
|
-
if (statusData.message) {
|
|
2385
|
-
statusInfo.text = statusData.message;
|
|
2386
|
-
} else if (statusData.status) {
|
|
2387
|
-
statusInfo.text = statusData.status;
|
|
2388
|
-
} else if (typeof statusData === 'string') {
|
|
2389
|
-
statusInfo.text = statusData;
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
// Extract token count
|
|
2393
|
-
if (statusData.tokens) {
|
|
2394
|
-
statusInfo.tokens = statusData.tokens;
|
|
2395
|
-
} else if (statusData.token_count) {
|
|
2396
|
-
statusInfo.tokens = statusData.token_count;
|
|
2397
|
-
}
|
|
2398
|
-
|
|
2399
|
-
// Check if can interrupt
|
|
2400
|
-
if (statusData.can_interrupt !== undefined) {
|
|
2401
|
-
statusInfo.can_interrupt = statusData.can_interrupt;
|
|
2402
|
-
}
|
|
2403
|
-
|
|
2404
|
-
setClaudeStatus(statusInfo);
|
|
2405
|
-
setIsLoading(true);
|
|
2406
|
-
setCanAbortSession(statusInfo.can_interrupt);
|
|
2407
|
-
}
|
|
2408
|
-
break;
|
|
2409
|
-
|
|
2410
|
-
}
|
|
2411
|
-
}
|
|
2412
|
-
}, [messages]);
|
|
2413
|
-
|
|
2414
|
-
// Load file list when project changes
|
|
2415
|
-
useEffect(() => {
|
|
2416
|
-
if (selectedProject) {
|
|
2417
|
-
fetchProjectFiles();
|
|
2418
|
-
}
|
|
2419
|
-
}, [selectedProject]);
|
|
2420
|
-
|
|
2421
|
-
const fetchProjectFiles = async () => {
|
|
2422
|
-
try {
|
|
2423
|
-
const response = await api.getFiles(selectedProject.name);
|
|
2424
|
-
if (response.ok) {
|
|
2425
|
-
const files = await response.json();
|
|
2426
|
-
// Flatten the file tree to get all file paths
|
|
2427
|
-
const flatFiles = flattenFileTree(files);
|
|
2428
|
-
setFileList(flatFiles);
|
|
2429
|
-
}
|
|
2430
|
-
} catch (error) {
|
|
2431
|
-
console.error('Error fetching files:', error);
|
|
2432
|
-
}
|
|
2433
|
-
};
|
|
2434
|
-
|
|
2435
|
-
const flattenFileTree = (files, basePath = '') => {
|
|
2436
|
-
let result = [];
|
|
2437
|
-
for (const file of files) {
|
|
2438
|
-
const fullPath = basePath ? `${basePath}/${file.name}` : file.name;
|
|
2439
|
-
if (file.type === 'directory' && file.children) {
|
|
2440
|
-
result = result.concat(flattenFileTree(file.children, fullPath));
|
|
2441
|
-
} else if (file.type === 'file') {
|
|
2442
|
-
result.push({
|
|
2443
|
-
name: file.name,
|
|
2444
|
-
path: fullPath,
|
|
2445
|
-
relativePath: file.path
|
|
2446
|
-
});
|
|
2447
|
-
}
|
|
2448
|
-
}
|
|
2449
|
-
return result;
|
|
2450
|
-
};
|
|
2451
|
-
|
|
2452
|
-
// Handle @ symbol detection and file filtering
|
|
2453
|
-
useEffect(() => {
|
|
2454
|
-
const textBeforeCursor = input.slice(0, cursorPosition);
|
|
2455
|
-
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
|
2456
|
-
|
|
2457
|
-
if (lastAtIndex !== -1) {
|
|
2458
|
-
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
|
|
2459
|
-
// Check if there's a space after the @ symbol (which would end the file reference)
|
|
2460
|
-
if (!textAfterAt.includes(' ')) {
|
|
2461
|
-
setAtSymbolPosition(lastAtIndex);
|
|
2462
|
-
setShowFileDropdown(true);
|
|
2463
|
-
|
|
2464
|
-
// Filter files based on the text after @
|
|
2465
|
-
const filtered = fileList.filter(file =>
|
|
2466
|
-
file.name.toLowerCase().includes(textAfterAt.toLowerCase()) ||
|
|
2467
|
-
file.path.toLowerCase().includes(textAfterAt.toLowerCase())
|
|
2468
|
-
).slice(0, 10); // Limit to 10 results
|
|
2469
|
-
|
|
2470
|
-
setFilteredFiles(filtered);
|
|
2471
|
-
setSelectedFileIndex(-1);
|
|
2472
|
-
} else {
|
|
2473
|
-
setShowFileDropdown(false);
|
|
2474
|
-
setAtSymbolPosition(-1);
|
|
2475
|
-
}
|
|
2476
|
-
} else {
|
|
2477
|
-
setShowFileDropdown(false);
|
|
2478
|
-
setAtSymbolPosition(-1);
|
|
2479
|
-
}
|
|
2480
|
-
}, [input, cursorPosition, fileList]);
|
|
2481
|
-
|
|
2482
|
-
// Debounced input handling
|
|
2483
|
-
useEffect(() => {
|
|
2484
|
-
const timer = setTimeout(() => {
|
|
2485
|
-
setDebouncedInput(input);
|
|
2486
|
-
}, 150); // 150ms debounce
|
|
2487
|
-
|
|
2488
|
-
return () => clearTimeout(timer);
|
|
2489
|
-
}, [input]);
|
|
2490
|
-
|
|
2491
|
-
// Show only recent messages for better performance
|
|
2492
|
-
const visibleMessages = useMemo(() => {
|
|
2493
|
-
if (chatMessages.length <= visibleMessageCount) {
|
|
2494
|
-
return chatMessages;
|
|
2495
|
-
}
|
|
2496
|
-
return chatMessages.slice(-visibleMessageCount);
|
|
2497
|
-
}, [chatMessages, visibleMessageCount]);
|
|
2498
|
-
|
|
2499
|
-
// Capture scroll position before render when auto-scroll is disabled
|
|
2500
|
-
useEffect(() => {
|
|
2501
|
-
if (!autoScrollToBottom && scrollContainerRef.current) {
|
|
2502
|
-
const container = scrollContainerRef.current;
|
|
2503
|
-
scrollPositionRef.current = {
|
|
2504
|
-
height: container.scrollHeight,
|
|
2505
|
-
top: container.scrollTop
|
|
2506
|
-
};
|
|
2507
|
-
}
|
|
2508
|
-
});
|
|
2509
|
-
|
|
2510
|
-
useEffect(() => {
|
|
2511
|
-
// Auto-scroll to bottom when new messages arrive
|
|
2512
|
-
if (scrollContainerRef.current && chatMessages.length > 0) {
|
|
2513
|
-
if (autoScrollToBottom) {
|
|
2514
|
-
// If auto-scroll is enabled, always scroll to bottom unless user has manually scrolled up
|
|
2515
|
-
if (!isUserScrolledUp) {
|
|
2516
|
-
setTimeout(() => scrollToBottom(), 50); // Small delay to ensure DOM is updated
|
|
2517
|
-
}
|
|
2518
|
-
} else {
|
|
2519
|
-
// When auto-scroll is disabled, preserve the visual position
|
|
2520
|
-
const container = scrollContainerRef.current;
|
|
2521
|
-
const prevHeight = scrollPositionRef.current.height;
|
|
2522
|
-
const prevTop = scrollPositionRef.current.top;
|
|
2523
|
-
const newHeight = container.scrollHeight;
|
|
2524
|
-
const heightDiff = newHeight - prevHeight;
|
|
2525
|
-
|
|
2526
|
-
// If content was added above the current view, adjust scroll position
|
|
2527
|
-
if (heightDiff > 0 && prevTop > 0) {
|
|
2528
|
-
container.scrollTop = prevTop + heightDiff;
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
}, [chatMessages.length, isUserScrolledUp, scrollToBottom, autoScrollToBottom]);
|
|
2533
|
-
|
|
2534
|
-
// Scroll to bottom when component mounts with existing messages or when messages first load
|
|
2535
|
-
useEffect(() => {
|
|
2536
|
-
if (scrollContainerRef.current && chatMessages.length > 0) {
|
|
2537
|
-
// Always scroll to bottom when messages first load (user expects to see latest)
|
|
2538
|
-
// Also reset scroll state
|
|
2539
|
-
setIsUserScrolledUp(false);
|
|
2540
|
-
setTimeout(() => scrollToBottom(), 200); // Longer delay to ensure full rendering
|
|
2541
|
-
}
|
|
2542
|
-
}, [chatMessages.length > 0, scrollToBottom]); // Trigger when messages first appear
|
|
2543
|
-
|
|
2544
|
-
// Add scroll event listener to detect user scrolling
|
|
2545
|
-
useEffect(() => {
|
|
2546
|
-
const scrollContainer = scrollContainerRef.current;
|
|
2547
|
-
if (scrollContainer) {
|
|
2548
|
-
scrollContainer.addEventListener('scroll', handleScroll);
|
|
2549
|
-
return () => scrollContainer.removeEventListener('scroll', handleScroll);
|
|
2550
|
-
}
|
|
2551
|
-
}, [handleScroll]);
|
|
2552
|
-
|
|
2553
|
-
// Initial textarea setup
|
|
2554
|
-
useEffect(() => {
|
|
2555
|
-
if (textareaRef.current) {
|
|
2556
|
-
textareaRef.current.style.height = 'auto';
|
|
2557
|
-
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
|
2558
|
-
|
|
2559
|
-
// Check if initially expanded
|
|
2560
|
-
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
|
2561
|
-
const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
|
2562
|
-
setIsTextareaExpanded(isExpanded);
|
|
2563
|
-
}
|
|
2564
|
-
}, []); // Only run once on mount
|
|
2565
|
-
|
|
2566
|
-
// Reset textarea height when input is cleared programmatically
|
|
2567
|
-
useEffect(() => {
|
|
2568
|
-
if (textareaRef.current && !input.trim()) {
|
|
2569
|
-
textareaRef.current.style.height = 'auto';
|
|
2570
|
-
setIsTextareaExpanded(false);
|
|
2571
|
-
}
|
|
2572
|
-
}, [input]);
|
|
2573
|
-
|
|
2574
|
-
const handleTranscript = useCallback((text) => {
|
|
2575
|
-
if (text.trim()) {
|
|
2576
|
-
setInput(prevInput => {
|
|
2577
|
-
const newInput = prevInput.trim() ? `${prevInput} ${text}` : text;
|
|
2578
|
-
|
|
2579
|
-
// Update textarea height after setting new content
|
|
2580
|
-
setTimeout(() => {
|
|
2581
|
-
if (textareaRef.current) {
|
|
2582
|
-
textareaRef.current.style.height = 'auto';
|
|
2583
|
-
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px';
|
|
2584
|
-
|
|
2585
|
-
// Check if expanded after transcript
|
|
2586
|
-
const lineHeight = parseInt(window.getComputedStyle(textareaRef.current).lineHeight);
|
|
2587
|
-
const isExpanded = textareaRef.current.scrollHeight > lineHeight * 2;
|
|
2588
|
-
setIsTextareaExpanded(isExpanded);
|
|
2589
|
-
}
|
|
2590
|
-
}, 0);
|
|
2591
|
-
|
|
2592
|
-
return newInput;
|
|
2593
|
-
});
|
|
2594
|
-
}
|
|
2595
|
-
}, []);
|
|
2596
|
-
|
|
2597
|
-
// Load earlier messages by increasing the visible message count
|
|
2598
|
-
const loadEarlierMessages = useCallback(() => {
|
|
2599
|
-
setVisibleMessageCount(prevCount => prevCount + 100);
|
|
2600
|
-
}, []);
|
|
2601
|
-
|
|
2602
|
-
// Handle image files from drag & drop or file picker
|
|
2603
|
-
const handleImageFiles = useCallback((files) => {
|
|
2604
|
-
const validFiles = files.filter(file => {
|
|
2605
|
-
try {
|
|
2606
|
-
// Validate file object and properties
|
|
2607
|
-
if (!file || typeof file !== 'object') {
|
|
2608
|
-
console.warn('Invalid file object:', file);
|
|
2609
|
-
return false;
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
if (!file.type || !file.type.startsWith('image/')) {
|
|
2613
|
-
return false;
|
|
2614
|
-
}
|
|
2615
|
-
|
|
2616
|
-
if (!file.size || file.size > 5 * 1024 * 1024) {
|
|
2617
|
-
// Safely get file name with fallback
|
|
2618
|
-
const fileName = file.name || 'Unknown file';
|
|
2619
|
-
setImageErrors(prev => {
|
|
2620
|
-
const newMap = new Map(prev);
|
|
2621
|
-
newMap.set(fileName, 'File too large (max 5MB)');
|
|
2622
|
-
return newMap;
|
|
2623
|
-
});
|
|
2624
|
-
return false;
|
|
2625
|
-
}
|
|
2626
|
-
|
|
2627
|
-
return true;
|
|
2628
|
-
} catch (error) {
|
|
2629
|
-
console.error('Error validating file:', error, file);
|
|
2630
|
-
return false;
|
|
2631
|
-
}
|
|
2632
|
-
});
|
|
2633
|
-
|
|
2634
|
-
if (validFiles.length > 0) {
|
|
2635
|
-
setAttachedImages(prev => [...prev, ...validFiles].slice(0, 5)); // Max 5 images
|
|
2636
|
-
}
|
|
2637
|
-
}, []);
|
|
2638
|
-
|
|
2639
|
-
// Handle clipboard paste for images
|
|
2640
|
-
const handlePaste = useCallback(async (e) => {
|
|
2641
|
-
const items = Array.from(e.clipboardData.items);
|
|
2642
|
-
|
|
2643
|
-
for (const item of items) {
|
|
2644
|
-
if (item.type.startsWith('image/')) {
|
|
2645
|
-
const file = item.getAsFile();
|
|
2646
|
-
if (file) {
|
|
2647
|
-
handleImageFiles([file]);
|
|
2648
|
-
}
|
|
2649
|
-
}
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
|
-
// Fallback for some browsers/platforms
|
|
2653
|
-
if (items.length === 0 && e.clipboardData.files.length > 0) {
|
|
2654
|
-
const files = Array.from(e.clipboardData.files);
|
|
2655
|
-
const imageFiles = files.filter(f => f.type.startsWith('image/'));
|
|
2656
|
-
if (imageFiles.length > 0) {
|
|
2657
|
-
handleImageFiles(imageFiles);
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
|
-
}, [handleImageFiles]);
|
|
2661
|
-
|
|
2662
|
-
// Setup dropzone
|
|
2663
|
-
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
|
2664
|
-
accept: {
|
|
2665
|
-
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg']
|
|
2666
|
-
},
|
|
2667
|
-
maxSize: 5 * 1024 * 1024, // 5MB
|
|
2668
|
-
maxFiles: 5,
|
|
2669
|
-
onDrop: handleImageFiles,
|
|
2670
|
-
noClick: true, // We'll use our own button
|
|
2671
|
-
noKeyboard: true
|
|
2672
|
-
});
|
|
2673
|
-
|
|
2674
|
-
const handleSubmit = async (e) => {
|
|
2675
|
-
e.preventDefault();
|
|
2676
|
-
if (!input.trim() || isLoading || !selectedProject) return;
|
|
2677
|
-
|
|
2678
|
-
// Upload images first if any
|
|
2679
|
-
let uploadedImages = [];
|
|
2680
|
-
if (attachedImages.length > 0) {
|
|
2681
|
-
const formData = new FormData();
|
|
2682
|
-
attachedImages.forEach(file => {
|
|
2683
|
-
formData.append('images', file);
|
|
2684
|
-
});
|
|
2685
|
-
|
|
2686
|
-
try {
|
|
2687
|
-
const token = safeLocalStorage.getItem('auth-token');
|
|
2688
|
-
const headers = {};
|
|
2689
|
-
if (token) {
|
|
2690
|
-
headers['Authorization'] = `Bearer ${token}`;
|
|
2691
|
-
}
|
|
2692
|
-
|
|
2693
|
-
const response = await fetch(`/api/projects/${selectedProject.name}/upload-images`, {
|
|
2694
|
-
method: 'POST',
|
|
2695
|
-
headers: headers,
|
|
2696
|
-
body: formData
|
|
2697
|
-
});
|
|
2698
|
-
|
|
2699
|
-
if (!response.ok) {
|
|
2700
|
-
throw new Error('Failed to upload images');
|
|
2701
|
-
}
|
|
2702
|
-
|
|
2703
|
-
const result = await response.json();
|
|
2704
|
-
uploadedImages = result.images;
|
|
2705
|
-
} catch (error) {
|
|
2706
|
-
console.error('Image upload failed:', error);
|
|
2707
|
-
setChatMessages(prev => [...prev, {
|
|
2708
|
-
type: 'error',
|
|
2709
|
-
content: `Failed to upload images: ${error.message}`,
|
|
2710
|
-
timestamp: new Date()
|
|
2711
|
-
}]);
|
|
2712
|
-
return;
|
|
2713
|
-
}
|
|
2714
|
-
}
|
|
2715
|
-
|
|
2716
|
-
const userMessage = {
|
|
2717
|
-
type: 'user',
|
|
2718
|
-
content: input,
|
|
2719
|
-
images: uploadedImages,
|
|
2720
|
-
timestamp: new Date()
|
|
2721
|
-
};
|
|
2722
|
-
|
|
2723
|
-
setChatMessages(prev => [...prev, userMessage]);
|
|
2724
|
-
setIsLoading(true);
|
|
2725
|
-
setCanAbortSession(true);
|
|
2726
|
-
// Set a default status when starting
|
|
2727
|
-
setClaudeStatus({
|
|
2728
|
-
text: 'Processing',
|
|
2729
|
-
tokens: 0,
|
|
2730
|
-
can_interrupt: true
|
|
2731
|
-
});
|
|
2732
|
-
|
|
2733
|
-
// Always scroll to bottom when user sends a message and reset scroll state
|
|
2734
|
-
setIsUserScrolledUp(false); // Reset scroll state so auto-scroll works for Claude's response
|
|
2735
|
-
setTimeout(() => scrollToBottom(), 100); // Longer delay to ensure message is rendered
|
|
2736
|
-
|
|
2737
|
-
// Determine effective session id for replies to avoid race on state updates
|
|
2738
|
-
const effectiveSessionId = currentSessionId || selectedSession?.id || sessionStorage.getItem('cursorSessionId');
|
|
2739
|
-
|
|
2740
|
-
// Session Protection: Mark session as active to prevent automatic project updates during conversation
|
|
2741
|
-
// Use existing session if available; otherwise a temporary placeholder until backend provides real ID
|
|
2742
|
-
const sessionToActivate = effectiveSessionId || `new-session-${Date.now()}`;
|
|
2743
|
-
if (onSessionActive) {
|
|
2744
|
-
onSessionActive(sessionToActivate);
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
// Get tools settings from localStorage based on provider
|
|
2748
|
-
const getToolsSettings = () => {
|
|
2749
|
-
try {
|
|
2750
|
-
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
|
|
2751
|
-
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
|
2752
|
-
if (savedSettings) {
|
|
2753
|
-
return JSON.parse(savedSettings);
|
|
2754
|
-
}
|
|
2755
|
-
} catch (error) {
|
|
2756
|
-
console.error('Error loading tools settings:', error);
|
|
2757
|
-
}
|
|
2758
|
-
return {
|
|
2759
|
-
allowedTools: [],
|
|
2760
|
-
disallowedTools: [],
|
|
2761
|
-
skipPermissions: false
|
|
2762
|
-
};
|
|
2763
|
-
};
|
|
2764
|
-
|
|
2765
|
-
const toolsSettings = getToolsSettings();
|
|
2766
|
-
|
|
2767
|
-
// Send command based on provider
|
|
2768
|
-
if (provider === 'cursor') {
|
|
2769
|
-
// Send Cursor command (always use cursor-command; include resume/sessionId when replying)
|
|
2770
|
-
sendMessage({
|
|
2771
|
-
type: 'cursor-command',
|
|
2772
|
-
command: input,
|
|
2773
|
-
sessionId: effectiveSessionId,
|
|
2774
|
-
options: {
|
|
2775
|
-
// Prefer fullPath (actual cwd for project), fallback to path
|
|
2776
|
-
cwd: selectedProject.fullPath || selectedProject.path,
|
|
2777
|
-
projectPath: selectedProject.fullPath || selectedProject.path,
|
|
2778
|
-
sessionId: effectiveSessionId,
|
|
2779
|
-
resume: !!effectiveSessionId,
|
|
2780
|
-
model: cursorModel,
|
|
2781
|
-
skipPermissions: toolsSettings?.skipPermissions || false,
|
|
2782
|
-
toolsSettings: toolsSettings
|
|
2783
|
-
}
|
|
2784
|
-
});
|
|
2785
|
-
} else {
|
|
2786
|
-
// Send Claude command (existing code)
|
|
2787
|
-
sendMessage({
|
|
2788
|
-
type: 'claude-command',
|
|
2789
|
-
command: input,
|
|
2790
|
-
options: {
|
|
2791
|
-
projectPath: selectedProject.path,
|
|
2792
|
-
cwd: selectedProject.fullPath,
|
|
2793
|
-
sessionId: currentSessionId,
|
|
2794
|
-
resume: !!currentSessionId,
|
|
2795
|
-
toolsSettings: toolsSettings,
|
|
2796
|
-
permissionMode: permissionMode,
|
|
2797
|
-
images: uploadedImages // Pass images to backend
|
|
2798
|
-
}
|
|
2799
|
-
});
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
setInput('');
|
|
2803
|
-
setAttachedImages([]);
|
|
2804
|
-
setUploadingImages(new Map());
|
|
2805
|
-
setImageErrors(new Map());
|
|
2806
|
-
setIsTextareaExpanded(false);
|
|
2807
|
-
|
|
2808
|
-
// Reset textarea height
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
if (textareaRef.current) {
|
|
2812
|
-
textareaRef.current.style.height = 'auto';
|
|
2813
|
-
}
|
|
2814
|
-
|
|
2815
|
-
// Clear the saved draft since message was sent
|
|
2816
|
-
if (selectedProject) {
|
|
2817
|
-
safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`);
|
|
2818
|
-
}
|
|
2819
|
-
};
|
|
2820
|
-
|
|
2821
|
-
const handleKeyDown = (e) => {
|
|
2822
|
-
// Handle file dropdown navigation
|
|
2823
|
-
if (showFileDropdown && filteredFiles.length > 0) {
|
|
2824
|
-
if (e.key === 'ArrowDown') {
|
|
2825
|
-
e.preventDefault();
|
|
2826
|
-
setSelectedFileIndex(prev =>
|
|
2827
|
-
prev < filteredFiles.length - 1 ? prev + 1 : 0
|
|
2828
|
-
);
|
|
2829
|
-
return;
|
|
2830
|
-
}
|
|
2831
|
-
if (e.key === 'ArrowUp') {
|
|
2832
|
-
e.preventDefault();
|
|
2833
|
-
setSelectedFileIndex(prev =>
|
|
2834
|
-
prev > 0 ? prev - 1 : filteredFiles.length - 1
|
|
2835
|
-
);
|
|
2836
|
-
return;
|
|
2837
|
-
}
|
|
2838
|
-
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
2839
|
-
e.preventDefault();
|
|
2840
|
-
if (selectedFileIndex >= 0) {
|
|
2841
|
-
selectFile(filteredFiles[selectedFileIndex]);
|
|
2842
|
-
} else if (filteredFiles.length > 0) {
|
|
2843
|
-
selectFile(filteredFiles[0]);
|
|
2844
|
-
}
|
|
2845
|
-
return;
|
|
2846
|
-
}
|
|
2847
|
-
if (e.key === 'Escape') {
|
|
2848
|
-
e.preventDefault();
|
|
2849
|
-
setShowFileDropdown(false);
|
|
2850
|
-
return;
|
|
2851
|
-
}
|
|
2852
|
-
}
|
|
2853
|
-
|
|
2854
|
-
// Handle Tab key for mode switching (only when file dropdown is not showing)
|
|
2855
|
-
if (e.key === 'Tab' && !showFileDropdown) {
|
|
2856
|
-
e.preventDefault();
|
|
2857
|
-
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
2858
|
-
const currentIndex = modes.indexOf(permissionMode);
|
|
2859
|
-
const nextIndex = (currentIndex + 1) % modes.length;
|
|
2860
|
-
setPermissionMode(modes[nextIndex]);
|
|
2861
|
-
return;
|
|
2862
|
-
}
|
|
2863
|
-
|
|
2864
|
-
// Handle Enter key: Ctrl+Enter (Cmd+Enter on Mac) sends, Shift+Enter creates new line
|
|
2865
|
-
if (e.key === 'Enter') {
|
|
2866
|
-
// If we're in composition, don't send message
|
|
2867
|
-
if (e.nativeEvent.isComposing) {
|
|
2868
|
-
return; // Let IME handle the Enter key
|
|
2869
|
-
}
|
|
2870
|
-
|
|
2871
|
-
if ((e.ctrlKey || e.metaKey) && !e.shiftKey) {
|
|
2872
|
-
// Ctrl+Enter or Cmd+Enter: Send message
|
|
2873
|
-
e.preventDefault();
|
|
2874
|
-
handleSubmit(e);
|
|
2875
|
-
} else if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
2876
|
-
// Plain Enter: Send message only if not in IME composition
|
|
2877
|
-
if (!sendByCtrlEnter) {
|
|
2878
|
-
e.preventDefault();
|
|
2879
|
-
handleSubmit(e);
|
|
2880
|
-
}
|
|
2881
|
-
}
|
|
2882
|
-
// Shift+Enter: Allow default behavior (new line)
|
|
2883
|
-
}
|
|
2884
|
-
};
|
|
2885
|
-
|
|
2886
|
-
const selectFile = (file) => {
|
|
2887
|
-
const textBeforeAt = input.slice(0, atSymbolPosition);
|
|
2888
|
-
const textAfterAtQuery = input.slice(atSymbolPosition);
|
|
2889
|
-
const spaceIndex = textAfterAtQuery.indexOf(' ');
|
|
2890
|
-
const textAfterQuery = spaceIndex !== -1 ? textAfterAtQuery.slice(spaceIndex) : '';
|
|
2891
|
-
|
|
2892
|
-
const newInput = textBeforeAt + '@' + file.path + ' ' + textAfterQuery;
|
|
2893
|
-
const newCursorPos = textBeforeAt.length + 1 + file.path.length + 1;
|
|
2894
|
-
|
|
2895
|
-
// Immediately ensure focus is maintained
|
|
2896
|
-
if (textareaRef.current && !textareaRef.current.matches(':focus')) {
|
|
2897
|
-
textareaRef.current.focus();
|
|
2898
|
-
}
|
|
2899
|
-
|
|
2900
|
-
// Update input and cursor position
|
|
2901
|
-
setInput(newInput);
|
|
2902
|
-
setCursorPosition(newCursorPos);
|
|
2903
|
-
|
|
2904
|
-
// Hide dropdown
|
|
2905
|
-
setShowFileDropdown(false);
|
|
2906
|
-
setAtSymbolPosition(-1);
|
|
2907
|
-
|
|
2908
|
-
// Set cursor position synchronously
|
|
2909
|
-
if (textareaRef.current) {
|
|
2910
|
-
// Use requestAnimationFrame for smoother updates
|
|
2911
|
-
requestAnimationFrame(() => {
|
|
2912
|
-
if (textareaRef.current) {
|
|
2913
|
-
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
|
2914
|
-
// Ensure focus is maintained
|
|
2915
|
-
if (!textareaRef.current.matches(':focus')) {
|
|
2916
|
-
textareaRef.current.focus();
|
|
2917
|
-
}
|
|
2918
|
-
}
|
|
2919
|
-
});
|
|
2920
|
-
}
|
|
2921
|
-
};
|
|
2922
|
-
|
|
2923
|
-
const handleInputChange = (e) => {
|
|
2924
|
-
const newValue = e.target.value;
|
|
2925
|
-
setInput(newValue);
|
|
2926
|
-
setCursorPosition(e.target.selectionStart);
|
|
2927
|
-
|
|
2928
|
-
// Handle height reset when input becomes empty
|
|
2929
|
-
if (!newValue.trim()) {
|
|
2930
|
-
e.target.style.height = 'auto';
|
|
2931
|
-
setIsTextareaExpanded(false);
|
|
2932
|
-
}
|
|
2933
|
-
};
|
|
2934
|
-
|
|
2935
|
-
const handleTextareaClick = (e) => {
|
|
2936
|
-
setCursorPosition(e.target.selectionStart);
|
|
2937
|
-
};
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
const handleNewSession = () => {
|
|
2942
|
-
setChatMessages([]);
|
|
2943
|
-
setInput('');
|
|
2944
|
-
setIsLoading(false);
|
|
2945
|
-
setCanAbortSession(false);
|
|
2946
|
-
};
|
|
2947
|
-
|
|
2948
|
-
const handleAbortSession = () => {
|
|
2949
|
-
if (currentSessionId && canAbortSession) {
|
|
2950
|
-
sendMessage({
|
|
2951
|
-
type: 'abort-session',
|
|
2952
|
-
sessionId: currentSessionId,
|
|
2953
|
-
provider: provider
|
|
2954
|
-
});
|
|
2955
|
-
}
|
|
2956
|
-
};
|
|
2957
|
-
|
|
2958
|
-
const handleModeSwitch = () => {
|
|
2959
|
-
const modes = ['default', 'acceptEdits', 'bypassPermissions', 'plan'];
|
|
2960
|
-
const currentIndex = modes.indexOf(permissionMode);
|
|
2961
|
-
const nextIndex = (currentIndex + 1) % modes.length;
|
|
2962
|
-
setPermissionMode(modes[nextIndex]);
|
|
2963
|
-
};
|
|
2964
|
-
|
|
2965
|
-
// Don't render if no project is selected
|
|
2966
|
-
if (!selectedProject) {
|
|
2967
|
-
return (
|
|
2968
|
-
<div className="flex items-center justify-center h-full">
|
|
2969
|
-
<div className="text-center text-gray-500 dark:text-gray-400">
|
|
2970
|
-
<p>Select a project to start chatting with Claude</p>
|
|
2971
|
-
</div>
|
|
2972
|
-
</div>
|
|
2973
|
-
);
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
return (
|
|
2977
|
-
<>
|
|
2978
|
-
<style>
|
|
2979
|
-
{`
|
|
2980
|
-
details[open] .details-chevron {
|
|
2981
|
-
transform: rotate(180deg);
|
|
2982
|
-
}
|
|
2983
|
-
`}
|
|
2984
|
-
</style>
|
|
2985
|
-
<div className="h-full flex flex-col">
|
|
2986
|
-
{/* Messages Area - Scrollable Middle Section */}
|
|
2987
|
-
<div
|
|
2988
|
-
ref={scrollContainerRef}
|
|
2989
|
-
className="flex-1 overflow-y-auto overflow-x-hidden px-0 py-3 sm:p-4 space-y-3 sm:space-y-4 relative"
|
|
2990
|
-
>
|
|
2991
|
-
{isLoadingSessionMessages && chatMessages.length === 0 ? (
|
|
2992
|
-
<div className="text-center text-gray-500 dark:text-gray-400 mt-8">
|
|
2993
|
-
<div className="flex items-center justify-center space-x-2">
|
|
2994
|
-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
|
2995
|
-
<p>Loading session messages...</p>
|
|
2996
|
-
</div>
|
|
2997
|
-
</div>
|
|
2998
|
-
) : chatMessages.length === 0 ? (
|
|
2999
|
-
<div className="flex items-center justify-center h-full">
|
|
3000
|
-
{!selectedSession && !currentSessionId && (
|
|
3001
|
-
<div className="text-center px-6 sm:px-4 py-8">
|
|
3002
|
-
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">Choose Your AI Assistant</h2>
|
|
3003
|
-
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
|
3004
|
-
Select a provider to start a new conversation
|
|
3005
|
-
</p>
|
|
3006
|
-
|
|
3007
|
-
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8">
|
|
3008
|
-
{/* Claude Button */}
|
|
3009
|
-
<button
|
|
3010
|
-
onClick={() => {
|
|
3011
|
-
setProvider('claude');
|
|
3012
|
-
localStorage.setItem('selected-provider', 'claude');
|
|
3013
|
-
// Focus input after selection
|
|
3014
|
-
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
3015
|
-
}}
|
|
3016
|
-
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 ${
|
|
3017
|
-
provider === 'claude'
|
|
3018
|
-
? 'border-blue-500 shadow-lg ring-2 ring-blue-500/20'
|
|
3019
|
-
: 'border-gray-200 dark:border-gray-700 hover:border-blue-400'
|
|
3020
|
-
}`}
|
|
3021
|
-
>
|
|
3022
|
-
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
3023
|
-
<ClaudeLogo className="w-10 h-10" />
|
|
3024
|
-
<div>
|
|
3025
|
-
<p className="font-semibold text-gray-900 dark:text-white">Claude</p>
|
|
3026
|
-
<p className="text-xs text-gray-500 dark:text-gray-400">by Anthropic</p>
|
|
3027
|
-
</div>
|
|
3028
|
-
</div>
|
|
3029
|
-
{provider === 'claude' && (
|
|
3030
|
-
<div className="absolute top-2 right-2">
|
|
3031
|
-
<div className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
|
|
3032
|
-
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3033
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
3034
|
-
</svg>
|
|
3035
|
-
</div>
|
|
3036
|
-
</div>
|
|
3037
|
-
)}
|
|
3038
|
-
</button>
|
|
3039
|
-
|
|
3040
|
-
{/* Cursor Button */}
|
|
3041
|
-
<button
|
|
3042
|
-
onClick={() => {
|
|
3043
|
-
setProvider('cursor');
|
|
3044
|
-
localStorage.setItem('selected-provider', 'cursor');
|
|
3045
|
-
// Focus input after selection
|
|
3046
|
-
setTimeout(() => textareaRef.current?.focus(), 100);
|
|
3047
|
-
}}
|
|
3048
|
-
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 ${
|
|
3049
|
-
provider === 'cursor'
|
|
3050
|
-
? 'border-purple-500 shadow-lg ring-2 ring-purple-500/20'
|
|
3051
|
-
: 'border-gray-200 dark:border-gray-700 hover:border-purple-400'
|
|
3052
|
-
}`}
|
|
3053
|
-
>
|
|
3054
|
-
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
3055
|
-
<CursorLogo className="w-10 h-10" />
|
|
3056
|
-
<div>
|
|
3057
|
-
<p className="font-semibold text-gray-900 dark:text-white">Cursor</p>
|
|
3058
|
-
<p className="text-xs text-gray-500 dark:text-gray-400">AI Code Editor</p>
|
|
3059
|
-
</div>
|
|
3060
|
-
</div>
|
|
3061
|
-
{provider === 'cursor' && (
|
|
3062
|
-
<div className="absolute top-2 right-2">
|
|
3063
|
-
<div className="w-5 h-5 bg-purple-500 rounded-full flex items-center justify-center">
|
|
3064
|
-
<svg className="w-3 h-3 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3065
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
|
3066
|
-
</svg>
|
|
3067
|
-
</div>
|
|
3068
|
-
</div>
|
|
3069
|
-
)}
|
|
3070
|
-
</button>
|
|
3071
|
-
</div>
|
|
3072
|
-
|
|
3073
|
-
{/* Model Selection for Cursor - Always reserve space to prevent jumping */}
|
|
3074
|
-
<div className={`mb-6 transition-opacity duration-200 ${provider === 'cursor' ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}>
|
|
3075
|
-
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
3076
|
-
{provider === 'cursor' ? 'Select Model' : '\u00A0'}
|
|
3077
|
-
</label>
|
|
3078
|
-
<select
|
|
3079
|
-
value={cursorModel}
|
|
3080
|
-
onChange={(e) => {
|
|
3081
|
-
const newModel = e.target.value;
|
|
3082
|
-
setCursorModel(newModel);
|
|
3083
|
-
localStorage.setItem('cursor-model', newModel);
|
|
3084
|
-
}}
|
|
3085
|
-
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]"
|
|
3086
|
-
disabled={provider !== 'cursor'}
|
|
3087
|
-
>
|
|
3088
|
-
<option value="gpt-5">GPT-5</option>
|
|
3089
|
-
<option value="sonnet-4">Sonnet-4</option>
|
|
3090
|
-
<option value="opus-4.1">Opus 4.1</option>
|
|
3091
|
-
</select>
|
|
3092
|
-
</div>
|
|
3093
|
-
|
|
3094
|
-
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
3095
|
-
{provider === 'claude'
|
|
3096
|
-
? 'Ready to use Claude AI. Start typing your message below.'
|
|
3097
|
-
: provider === 'cursor'
|
|
3098
|
-
? `Ready to use Cursor with ${cursorModel}. Start typing your message below.`
|
|
3099
|
-
: 'Select a provider above to begin'
|
|
3100
|
-
}
|
|
3101
|
-
</p>
|
|
3102
|
-
|
|
3103
|
-
{/* Show NextTaskBanner when provider is selected and ready */}
|
|
3104
|
-
{provider && tasksEnabled && (
|
|
3105
|
-
<div className="mt-4 px-4 sm:px-0">
|
|
3106
|
-
<NextTaskBanner
|
|
3107
|
-
onStartTask={() => setInput('Start the next task')}
|
|
3108
|
-
onShowAllTasks={onShowAllTasks}
|
|
3109
|
-
/>
|
|
3110
|
-
</div>
|
|
3111
|
-
)}
|
|
3112
|
-
</div>
|
|
3113
|
-
)}
|
|
3114
|
-
{selectedSession && (
|
|
3115
|
-
<div className="text-center text-gray-500 dark:text-gray-400 px-6 sm:px-4">
|
|
3116
|
-
<p className="font-bold text-lg sm:text-xl mb-3">Continue your conversation</p>
|
|
3117
|
-
<p className="text-sm sm:text-base leading-relaxed">
|
|
3118
|
-
Ask questions about your code, request changes, or get help with development tasks
|
|
3119
|
-
</p>
|
|
3120
|
-
|
|
3121
|
-
{/* Show NextTaskBanner for existing sessions too */}
|
|
3122
|
-
{tasksEnabled && (
|
|
3123
|
-
<div className="mt-4 px-4 sm:px-0">
|
|
3124
|
-
<NextTaskBanner
|
|
3125
|
-
onStartTask={() => setInput('Start the next task')}
|
|
3126
|
-
onShowAllTasks={onShowAllTasks}
|
|
3127
|
-
/>
|
|
3128
|
-
</div>
|
|
3129
|
-
)}
|
|
3130
|
-
</div>
|
|
3131
|
-
)}
|
|
3132
|
-
</div>
|
|
3133
|
-
) : (
|
|
3134
|
-
<>
|
|
3135
|
-
{/* Loading indicator for older messages */}
|
|
3136
|
-
{isLoadingMoreMessages && (
|
|
3137
|
-
<div className="text-center text-gray-500 dark:text-gray-400 py-3">
|
|
3138
|
-
<div className="flex items-center justify-center space-x-2">
|
|
3139
|
-
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-400"></div>
|
|
3140
|
-
<p className="text-sm">Loading older messages...</p>
|
|
3141
|
-
</div>
|
|
3142
|
-
</div>
|
|
3143
|
-
)}
|
|
3144
|
-
|
|
3145
|
-
{/* Indicator showing there are more messages to load */}
|
|
3146
|
-
{hasMoreMessages && !isLoadingMoreMessages && (
|
|
3147
|
-
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
|
3148
|
-
{totalMessages > 0 && (
|
|
3149
|
-
<span>
|
|
3150
|
-
Showing {sessionMessages.length} of {totalMessages} messages •
|
|
3151
|
-
<span className="text-xs">Scroll up to load more</span>
|
|
3152
|
-
</span>
|
|
3153
|
-
)}
|
|
3154
|
-
</div>
|
|
3155
|
-
)}
|
|
3156
|
-
|
|
3157
|
-
{/* Legacy message count indicator (for non-paginated view) */}
|
|
3158
|
-
{!hasMoreMessages && chatMessages.length > visibleMessageCount && (
|
|
3159
|
-
<div className="text-center text-gray-500 dark:text-gray-400 text-sm py-2 border-b border-gray-200 dark:border-gray-700">
|
|
3160
|
-
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •
|
|
3161
|
-
<button
|
|
3162
|
-
className="ml-1 text-blue-600 hover:text-blue-700 underline"
|
|
3163
|
-
onClick={loadEarlierMessages}
|
|
3164
|
-
>
|
|
3165
|
-
Load earlier messages
|
|
3166
|
-
</button>
|
|
3167
|
-
</div>
|
|
3168
|
-
)}
|
|
3169
|
-
|
|
3170
|
-
{visibleMessages.map((message, index) => {
|
|
3171
|
-
const prevMessage = index > 0 ? visibleMessages[index - 1] : null;
|
|
3172
|
-
|
|
3173
|
-
return (
|
|
3174
|
-
<MessageComponent
|
|
3175
|
-
key={index}
|
|
3176
|
-
message={message}
|
|
3177
|
-
index={index}
|
|
3178
|
-
prevMessage={prevMessage}
|
|
3179
|
-
createDiff={createDiff}
|
|
3180
|
-
onFileOpen={onFileOpen}
|
|
3181
|
-
onShowSettings={onShowSettings}
|
|
3182
|
-
autoExpandTools={autoExpandTools}
|
|
3183
|
-
showRawParameters={showRawParameters}
|
|
3184
|
-
/>
|
|
3185
|
-
);
|
|
3186
|
-
})}
|
|
3187
|
-
</>
|
|
3188
|
-
)}
|
|
3189
|
-
|
|
3190
|
-
{isLoading && (
|
|
3191
|
-
<div className="chat-message assistant">
|
|
3192
|
-
<div className="w-full">
|
|
3193
|
-
<div className="flex items-center space-x-3 mb-2">
|
|
3194
|
-
<div className="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm flex-shrink-0 p-1 bg-transparent">
|
|
3195
|
-
{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? (
|
|
3196
|
-
<CursorLogo className="w-full h-full" />
|
|
3197
|
-
) : (
|
|
3198
|
-
<ClaudeLogo className="w-full h-full" />
|
|
3199
|
-
)}
|
|
3200
|
-
</div>
|
|
3201
|
-
<div className="text-sm font-medium text-gray-900 dark:text-white">{(localStorage.getItem('selected-provider') || 'claude') === 'cursor' ? 'Cursor' : 'Claude'}</div>
|
|
3202
|
-
{/* Abort button removed - functionality not yet implemented at backend */}
|
|
3203
|
-
</div>
|
|
3204
|
-
<div className="w-full text-sm text-gray-500 dark:text-gray-400 pl-3 sm:pl-0">
|
|
3205
|
-
<div className="flex items-center space-x-1">
|
|
3206
|
-
<div className="animate-pulse">●</div>
|
|
3207
|
-
<div className="animate-pulse" style={{ animationDelay: '0.2s' }}>●</div>
|
|
3208
|
-
<div className="animate-pulse" style={{ animationDelay: '0.4s' }}>●</div>
|
|
3209
|
-
<span className="ml-2">Thinking...</span>
|
|
3210
|
-
</div>
|
|
3211
|
-
</div>
|
|
3212
|
-
</div>
|
|
3213
|
-
</div>
|
|
3214
|
-
)}
|
|
3215
|
-
|
|
3216
|
-
<div ref={messagesEndRef} />
|
|
3217
|
-
</div>
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
{/* Input Area - Fixed Bottom */}
|
|
3221
|
-
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
|
3222
|
-
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-2 sm:pb-4 md:pb-6'
|
|
3223
|
-
}`}>
|
|
3224
|
-
|
|
3225
|
-
<div className="flex-1">
|
|
3226
|
-
<ClaudeStatus
|
|
3227
|
-
status={claudeStatus}
|
|
3228
|
-
isLoading={isLoading}
|
|
3229
|
-
onAbort={handleAbortSession}
|
|
3230
|
-
provider={provider}
|
|
3231
|
-
/>
|
|
3232
|
-
</div>
|
|
3233
|
-
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
|
3234
|
-
<div className="max-w-4xl mx-auto mb-3">
|
|
3235
|
-
<div className="flex items-center justify-center gap-3">
|
|
3236
|
-
<button
|
|
3237
|
-
type="button"
|
|
3238
|
-
onClick={handleModeSwitch}
|
|
3239
|
-
className={`px-3 py-1.5 rounded-lg text-sm font-medium border transition-all duration-200 ${
|
|
3240
|
-
permissionMode === 'default'
|
|
3241
|
-
? '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'
|
|
3242
|
-
: permissionMode === 'acceptEdits'
|
|
3243
|
-
? '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'
|
|
3244
|
-
: permissionMode === 'bypassPermissions'
|
|
3245
|
-
? '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'
|
|
3246
|
-
: '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'
|
|
3247
|
-
}`}
|
|
3248
|
-
title="Click to change permission mode (or press Tab in input)"
|
|
3249
|
-
>
|
|
3250
|
-
<div className="flex items-center gap-2">
|
|
3251
|
-
<div className={`w-2 h-2 rounded-full ${
|
|
3252
|
-
permissionMode === 'default'
|
|
3253
|
-
? 'bg-gray-500'
|
|
3254
|
-
: permissionMode === 'acceptEdits'
|
|
3255
|
-
? 'bg-green-500'
|
|
3256
|
-
: permissionMode === 'bypassPermissions'
|
|
3257
|
-
? 'bg-orange-500'
|
|
3258
|
-
: 'bg-blue-500'
|
|
3259
|
-
}`} />
|
|
3260
|
-
<span>
|
|
3261
|
-
{permissionMode === 'default' && 'Default Mode'}
|
|
3262
|
-
{permissionMode === 'acceptEdits' && 'Accept Edits'}
|
|
3263
|
-
{permissionMode === 'bypassPermissions' && 'Bypass Permissions'}
|
|
3264
|
-
{permissionMode === 'plan' && 'Plan Mode'}
|
|
3265
|
-
</span>
|
|
3266
|
-
</div>
|
|
3267
|
-
</button>
|
|
3268
|
-
|
|
3269
|
-
{/* Scroll to bottom button - positioned next to mode indicator */}
|
|
3270
|
-
{isUserScrolledUp && chatMessages.length > 0 && (
|
|
3271
|
-
<button
|
|
3272
|
-
onClick={scrollToBottom}
|
|
3273
|
-
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"
|
|
3274
|
-
title="Scroll to bottom"
|
|
3275
|
-
>
|
|
3276
|
-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3277
|
-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
3278
|
-
</svg>
|
|
3279
|
-
</button>
|
|
3280
|
-
)}
|
|
3281
|
-
</div>
|
|
3282
|
-
</div>
|
|
3283
|
-
|
|
3284
|
-
<form onSubmit={handleSubmit} className="relative max-w-4xl mx-auto">
|
|
3285
|
-
{/* Drag overlay */}
|
|
3286
|
-
{isDragActive && (
|
|
3287
|
-
<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">
|
|
3288
|
-
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-lg">
|
|
3289
|
-
<svg className="w-8 h-8 text-blue-500 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3290
|
-
<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" />
|
|
3291
|
-
</svg>
|
|
3292
|
-
<p className="text-sm font-medium">Drop images here</p>
|
|
3293
|
-
</div>
|
|
3294
|
-
</div>
|
|
3295
|
-
)}
|
|
3296
|
-
|
|
3297
|
-
{/* Image attachments preview */}
|
|
3298
|
-
{attachedImages.length > 0 && (
|
|
3299
|
-
<div className="mb-2 p-2 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
3300
|
-
<div className="flex flex-wrap gap-2">
|
|
3301
|
-
{attachedImages.map((file, index) => (
|
|
3302
|
-
<ImageAttachment
|
|
3303
|
-
key={index}
|
|
3304
|
-
file={file}
|
|
3305
|
-
onRemove={() => {
|
|
3306
|
-
setAttachedImages(prev => prev.filter((_, i) => i !== index));
|
|
3307
|
-
}}
|
|
3308
|
-
uploadProgress={uploadingImages.get(file.name)}
|
|
3309
|
-
error={imageErrors.get(file.name)}
|
|
3310
|
-
/>
|
|
3311
|
-
))}
|
|
3312
|
-
</div>
|
|
3313
|
-
</div>
|
|
3314
|
-
)}
|
|
3315
|
-
|
|
3316
|
-
{/* File dropdown - positioned outside dropzone to avoid conflicts */}
|
|
3317
|
-
{showFileDropdown && filteredFiles.length > 0 && (
|
|
3318
|
-
<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">
|
|
3319
|
-
{filteredFiles.map((file, index) => (
|
|
3320
|
-
<div
|
|
3321
|
-
key={file.path}
|
|
3322
|
-
className={`px-4 py-3 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 touch-manipulation ${
|
|
3323
|
-
index === selectedFileIndex
|
|
3324
|
-
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
|
|
3325
|
-
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300'
|
|
3326
|
-
}`}
|
|
3327
|
-
onMouseDown={(e) => {
|
|
3328
|
-
// Prevent textarea from losing focus on mobile
|
|
3329
|
-
e.preventDefault();
|
|
3330
|
-
e.stopPropagation();
|
|
3331
|
-
}}
|
|
3332
|
-
onClick={(e) => {
|
|
3333
|
-
e.preventDefault();
|
|
3334
|
-
e.stopPropagation();
|
|
3335
|
-
selectFile(file);
|
|
3336
|
-
}}
|
|
3337
|
-
>
|
|
3338
|
-
<div className="font-medium text-sm">{file.name}</div>
|
|
3339
|
-
<div className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
|
3340
|
-
{file.path}
|
|
3341
|
-
</div>
|
|
3342
|
-
</div>
|
|
3343
|
-
))}
|
|
3344
|
-
</div>
|
|
3345
|
-
)}
|
|
3346
|
-
|
|
3347
|
-
<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 ${isTextareaExpanded ? 'chat-input-expanded' : ''}`}>
|
|
3348
|
-
<input {...getInputProps()} />
|
|
3349
|
-
<textarea
|
|
3350
|
-
ref={textareaRef}
|
|
3351
|
-
value={input}
|
|
3352
|
-
onChange={handleInputChange}
|
|
3353
|
-
onClick={handleTextareaClick}
|
|
3354
|
-
onKeyDown={handleKeyDown}
|
|
3355
|
-
onPaste={handlePaste}
|
|
3356
|
-
onFocus={() => setIsInputFocused(true)}
|
|
3357
|
-
onBlur={() => setIsInputFocused(false)}
|
|
3358
|
-
onInput={(e) => {
|
|
3359
|
-
// Immediate resize on input for better UX
|
|
3360
|
-
e.target.style.height = 'auto';
|
|
3361
|
-
e.target.style.height = e.target.scrollHeight + 'px';
|
|
3362
|
-
setCursorPosition(e.target.selectionStart);
|
|
3363
|
-
|
|
3364
|
-
// Check if textarea is expanded (more than 2 lines worth of height)
|
|
3365
|
-
const lineHeight = parseInt(window.getComputedStyle(e.target).lineHeight);
|
|
3366
|
-
const isExpanded = e.target.scrollHeight > lineHeight * 2;
|
|
3367
|
-
setIsTextareaExpanded(isExpanded);
|
|
3368
|
-
}}
|
|
3369
|
-
placeholder="Ask Claude to help with your code... (@ to reference files)"
|
|
3370
|
-
disabled={isLoading}
|
|
3371
|
-
rows={1}
|
|
3372
|
-
className="chat-input-placeholder w-full pl-12 pr-28 sm:pr-40 py-3 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-[40px] sm:min-h-[56px] max-h-[40vh] sm:max-h-[300px] overflow-y-auto text-sm sm:text-base transition-all duration-200"
|
|
3373
|
-
style={{ height: 'auto' }}
|
|
3374
|
-
/>
|
|
3375
|
-
{/* Clear button - shown when there's text */}
|
|
3376
|
-
{input.trim() && (
|
|
3377
|
-
<button
|
|
3378
|
-
type="button"
|
|
3379
|
-
onClick={(e) => {
|
|
3380
|
-
e.preventDefault();
|
|
3381
|
-
e.stopPropagation();
|
|
3382
|
-
setInput('');
|
|
3383
|
-
if (textareaRef.current) {
|
|
3384
|
-
textareaRef.current.style.height = 'auto';
|
|
3385
|
-
textareaRef.current.focus();
|
|
3386
|
-
}
|
|
3387
|
-
setIsTextareaExpanded(false);
|
|
3388
|
-
}}
|
|
3389
|
-
onTouchEnd={(e) => {
|
|
3390
|
-
e.preventDefault();
|
|
3391
|
-
e.stopPropagation();
|
|
3392
|
-
setInput('');
|
|
3393
|
-
if (textareaRef.current) {
|
|
3394
|
-
textareaRef.current.style.height = 'auto';
|
|
3395
|
-
textareaRef.current.focus();
|
|
3396
|
-
}
|
|
3397
|
-
setIsTextareaExpanded(false);
|
|
3398
|
-
}}
|
|
3399
|
-
className="absolute -left-0.5 -top-3 sm:right-28 sm:left-auto sm:top-1/2 sm:-translate-y-1/2 w-6 h-6 sm:w-8 sm:h-8 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-full flex items-center justify-center transition-all duration-200 group z-10 shadow-sm"
|
|
3400
|
-
title="Clear input"
|
|
3401
|
-
>
|
|
3402
|
-
<svg
|
|
3403
|
-
className="w-3 h-3 sm:w-4 sm:h-4 text-gray-600 dark:text-gray-300 group-hover:text-gray-800 dark:group-hover:text-gray-100 transition-colors"
|
|
3404
|
-
fill="none"
|
|
3405
|
-
stroke="currentColor"
|
|
3406
|
-
viewBox="0 0 24 24"
|
|
3407
|
-
>
|
|
3408
|
-
<path
|
|
3409
|
-
strokeLinecap="round"
|
|
3410
|
-
strokeLinejoin="round"
|
|
3411
|
-
strokeWidth={2}
|
|
3412
|
-
d="M6 18L18 6M6 6l12 12"
|
|
3413
|
-
/>
|
|
3414
|
-
</svg>
|
|
3415
|
-
</button>
|
|
3416
|
-
)}
|
|
3417
|
-
{/* Image upload button */}
|
|
3418
|
-
<button
|
|
3419
|
-
type="button"
|
|
3420
|
-
onClick={open}
|
|
3421
|
-
className="absolute left-2 bottom-4 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
|
3422
|
-
title="Attach images"
|
|
3423
|
-
>
|
|
3424
|
-
<svg className="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
3425
|
-
<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" />
|
|
3426
|
-
</svg>
|
|
3427
|
-
</button>
|
|
3428
|
-
|
|
3429
|
-
{/* Mic button - HIDDEN */}
|
|
3430
|
-
<div className="absolute right-16 sm:right-16 top-1/2 transform -translate-y-1/2" style={{ display: 'none' }}>
|
|
3431
|
-
<MicButton
|
|
3432
|
-
onTranscript={handleTranscript}
|
|
3433
|
-
className="w-10 h-10 sm:w-10 sm:h-10"
|
|
3434
|
-
/>
|
|
3435
|
-
</div>
|
|
3436
|
-
{/* Send button */}
|
|
3437
|
-
<button
|
|
3438
|
-
type="submit"
|
|
3439
|
-
disabled={!input.trim() || isLoading}
|
|
3440
|
-
onMouseDown={(e) => {
|
|
3441
|
-
e.preventDefault();
|
|
3442
|
-
handleSubmit(e);
|
|
3443
|
-
}}
|
|
3444
|
-
onTouchStart={(e) => {
|
|
3445
|
-
e.preventDefault();
|
|
3446
|
-
handleSubmit(e);
|
|
3447
|
-
}}
|
|
3448
|
-
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"
|
|
3449
|
-
>
|
|
3450
|
-
<svg
|
|
3451
|
-
className="w-4 h-4 sm:w-5 sm:h-5 text-white transform rotate-90"
|
|
3452
|
-
fill="none"
|
|
3453
|
-
stroke="currentColor"
|
|
3454
|
-
viewBox="0 0 24 24"
|
|
3455
|
-
>
|
|
3456
|
-
<path
|
|
3457
|
-
strokeLinecap="round"
|
|
3458
|
-
strokeLinejoin="round"
|
|
3459
|
-
strokeWidth={2}
|
|
3460
|
-
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
3461
|
-
/>
|
|
3462
|
-
</svg>
|
|
3463
|
-
</button>
|
|
3464
|
-
</div>
|
|
3465
|
-
{/* Hint text */}
|
|
3466
|
-
<div className="text-xs text-gray-500 dark:text-gray-400 text-center mt-2 hidden sm:block">
|
|
3467
|
-
{sendByCtrlEnter
|
|
3468
|
-
? "Ctrl+Enter to send (IME safe) • Shift+Enter for new line • Tab to change modes • @ to reference files"
|
|
3469
|
-
: "Press Enter to send • Shift+Enter for new line • Tab to change modes • @ to reference files"}
|
|
3470
|
-
</div>
|
|
3471
|
-
<div className={`text-xs text-gray-500 dark:text-gray-400 text-center mt-2 sm:hidden transition-opacity duration-200 ${
|
|
3472
|
-
isInputFocused ? 'opacity-100' : 'opacity-0'
|
|
3473
|
-
}`}>
|
|
3474
|
-
{sendByCtrlEnter
|
|
3475
|
-
? "Ctrl+Enter to send (IME safe) • Tab for modes • @ for files"
|
|
3476
|
-
: "Enter to send • Tab for modes • @ for files"}
|
|
3477
|
-
</div>
|
|
3478
|
-
</form>
|
|
3479
|
-
</div>
|
|
3480
|
-
</div>
|
|
3481
|
-
</>
|
|
3482
|
-
);
|
|
3483
|
-
}
|
|
3484
|
-
|
|
3485
|
-
export default React.memo(ChatInterface);
|