@siteboon/claude-code-ui 1.8.2

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.
Files changed (106) hide show
  1. package/.env.example +12 -0
  2. package/.nvmrc +1 -0
  3. package/LICENSE +675 -0
  4. package/README.md +275 -0
  5. package/index.html +48 -0
  6. package/package.json +84 -0
  7. package/postcss.config.js +6 -0
  8. package/public/convert-icons.md +53 -0
  9. package/public/favicon.png +0 -0
  10. package/public/favicon.svg +9 -0
  11. package/public/generate-icons.js +49 -0
  12. package/public/icons/claude-ai-icon.svg +1 -0
  13. package/public/icons/cursor.svg +1 -0
  14. package/public/icons/generate-icons.md +19 -0
  15. package/public/icons/icon-128x128.png +0 -0
  16. package/public/icons/icon-128x128.svg +12 -0
  17. package/public/icons/icon-144x144.png +0 -0
  18. package/public/icons/icon-144x144.svg +12 -0
  19. package/public/icons/icon-152x152.png +0 -0
  20. package/public/icons/icon-152x152.svg +12 -0
  21. package/public/icons/icon-192x192.png +0 -0
  22. package/public/icons/icon-192x192.svg +12 -0
  23. package/public/icons/icon-384x384.png +0 -0
  24. package/public/icons/icon-384x384.svg +12 -0
  25. package/public/icons/icon-512x512.png +0 -0
  26. package/public/icons/icon-512x512.svg +12 -0
  27. package/public/icons/icon-72x72.png +0 -0
  28. package/public/icons/icon-72x72.svg +12 -0
  29. package/public/icons/icon-96x96.png +0 -0
  30. package/public/icons/icon-96x96.svg +12 -0
  31. package/public/icons/icon-template.svg +12 -0
  32. package/public/logo.svg +9 -0
  33. package/public/manifest.json +61 -0
  34. package/public/screenshots/cli-selection.png +0 -0
  35. package/public/screenshots/desktop-main.png +0 -0
  36. package/public/screenshots/mobile-chat.png +0 -0
  37. package/public/screenshots/tools-modal.png +0 -0
  38. package/public/sw.js +49 -0
  39. package/server/claude-cli.js +391 -0
  40. package/server/cursor-cli.js +250 -0
  41. package/server/database/db.js +86 -0
  42. package/server/database/init.sql +16 -0
  43. package/server/index.js +1167 -0
  44. package/server/middleware/auth.js +80 -0
  45. package/server/projects.js +1063 -0
  46. package/server/routes/auth.js +135 -0
  47. package/server/routes/cursor.js +794 -0
  48. package/server/routes/git.js +823 -0
  49. package/server/routes/mcp-utils.js +48 -0
  50. package/server/routes/mcp.js +552 -0
  51. package/server/routes/taskmaster.js +1971 -0
  52. package/server/utils/mcp-detector.js +198 -0
  53. package/server/utils/taskmaster-websocket.js +129 -0
  54. package/src/App.jsx +751 -0
  55. package/src/components/ChatInterface.jsx +3485 -0
  56. package/src/components/ClaudeLogo.jsx +11 -0
  57. package/src/components/ClaudeStatus.jsx +107 -0
  58. package/src/components/CodeEditor.jsx +422 -0
  59. package/src/components/CreateTaskModal.jsx +88 -0
  60. package/src/components/CursorLogo.jsx +9 -0
  61. package/src/components/DarkModeToggle.jsx +35 -0
  62. package/src/components/DiffViewer.jsx +41 -0
  63. package/src/components/ErrorBoundary.jsx +73 -0
  64. package/src/components/FileTree.jsx +480 -0
  65. package/src/components/GitPanel.jsx +1283 -0
  66. package/src/components/ImageViewer.jsx +54 -0
  67. package/src/components/LoginForm.jsx +110 -0
  68. package/src/components/MainContent.jsx +577 -0
  69. package/src/components/MicButton.jsx +272 -0
  70. package/src/components/MobileNav.jsx +88 -0
  71. package/src/components/NextTaskBanner.jsx +695 -0
  72. package/src/components/PRDEditor.jsx +871 -0
  73. package/src/components/ProtectedRoute.jsx +44 -0
  74. package/src/components/QuickSettingsPanel.jsx +262 -0
  75. package/src/components/Settings.jsx +2023 -0
  76. package/src/components/SetupForm.jsx +135 -0
  77. package/src/components/Shell.jsx +663 -0
  78. package/src/components/Sidebar.jsx +1665 -0
  79. package/src/components/StandaloneShell.jsx +106 -0
  80. package/src/components/TaskCard.jsx +210 -0
  81. package/src/components/TaskDetail.jsx +406 -0
  82. package/src/components/TaskIndicator.jsx +108 -0
  83. package/src/components/TaskList.jsx +1054 -0
  84. package/src/components/TaskMasterSetupWizard.jsx +603 -0
  85. package/src/components/TaskMasterStatus.jsx +86 -0
  86. package/src/components/TodoList.jsx +91 -0
  87. package/src/components/Tooltip.jsx +91 -0
  88. package/src/components/ui/badge.jsx +31 -0
  89. package/src/components/ui/button.jsx +46 -0
  90. package/src/components/ui/input.jsx +19 -0
  91. package/src/components/ui/scroll-area.jsx +23 -0
  92. package/src/contexts/AuthContext.jsx +158 -0
  93. package/src/contexts/TaskMasterContext.jsx +324 -0
  94. package/src/contexts/TasksSettingsContext.jsx +95 -0
  95. package/src/contexts/ThemeContext.jsx +94 -0
  96. package/src/contexts/WebSocketContext.jsx +29 -0
  97. package/src/hooks/useAudioRecorder.js +109 -0
  98. package/src/hooks/useVersionCheck.js +39 -0
  99. package/src/index.css +822 -0
  100. package/src/lib/utils.js +6 -0
  101. package/src/main.jsx +10 -0
  102. package/src/utils/api.js +141 -0
  103. package/src/utils/websocket.js +109 -0
  104. package/src/utils/whisper.js +37 -0
  105. package/tailwind.config.js +63 -0
  106. package/vite.config.js +29 -0
@@ -0,0 +1,3485 @@
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);