@siteboon/claude-code-ui 1.8.2 → 1.8.4

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 (92) hide show
  1. package/dist/assets/index-CeR_JfKq.js +895 -0
  2. package/dist/assets/index-Co7ALK3i.css +32 -0
  3. package/{index.html → dist/index.html} +2 -1
  4. package/package.json +6 -1
  5. package/server/database/auth.db +0 -0
  6. package/.env.example +0 -12
  7. package/.nvmrc +0 -1
  8. package/postcss.config.js +0 -6
  9. package/src/App.jsx +0 -751
  10. package/src/components/ChatInterface.jsx +0 -3485
  11. package/src/components/ClaudeLogo.jsx +0 -11
  12. package/src/components/ClaudeStatus.jsx +0 -107
  13. package/src/components/CodeEditor.jsx +0 -422
  14. package/src/components/CreateTaskModal.jsx +0 -88
  15. package/src/components/CursorLogo.jsx +0 -9
  16. package/src/components/DarkModeToggle.jsx +0 -35
  17. package/src/components/DiffViewer.jsx +0 -41
  18. package/src/components/ErrorBoundary.jsx +0 -73
  19. package/src/components/FileTree.jsx +0 -480
  20. package/src/components/GitPanel.jsx +0 -1283
  21. package/src/components/ImageViewer.jsx +0 -54
  22. package/src/components/LoginForm.jsx +0 -110
  23. package/src/components/MainContent.jsx +0 -577
  24. package/src/components/MicButton.jsx +0 -272
  25. package/src/components/MobileNav.jsx +0 -88
  26. package/src/components/NextTaskBanner.jsx +0 -695
  27. package/src/components/PRDEditor.jsx +0 -871
  28. package/src/components/ProtectedRoute.jsx +0 -44
  29. package/src/components/QuickSettingsPanel.jsx +0 -262
  30. package/src/components/Settings.jsx +0 -2023
  31. package/src/components/SetupForm.jsx +0 -135
  32. package/src/components/Shell.jsx +0 -663
  33. package/src/components/Sidebar.jsx +0 -1665
  34. package/src/components/StandaloneShell.jsx +0 -106
  35. package/src/components/TaskCard.jsx +0 -210
  36. package/src/components/TaskDetail.jsx +0 -406
  37. package/src/components/TaskIndicator.jsx +0 -108
  38. package/src/components/TaskList.jsx +0 -1054
  39. package/src/components/TaskMasterSetupWizard.jsx +0 -603
  40. package/src/components/TaskMasterStatus.jsx +0 -86
  41. package/src/components/TodoList.jsx +0 -91
  42. package/src/components/Tooltip.jsx +0 -91
  43. package/src/components/ui/badge.jsx +0 -31
  44. package/src/components/ui/button.jsx +0 -46
  45. package/src/components/ui/input.jsx +0 -19
  46. package/src/components/ui/scroll-area.jsx +0 -23
  47. package/src/contexts/AuthContext.jsx +0 -158
  48. package/src/contexts/TaskMasterContext.jsx +0 -324
  49. package/src/contexts/TasksSettingsContext.jsx +0 -95
  50. package/src/contexts/ThemeContext.jsx +0 -94
  51. package/src/contexts/WebSocketContext.jsx +0 -29
  52. package/src/hooks/useAudioRecorder.js +0 -109
  53. package/src/hooks/useVersionCheck.js +0 -39
  54. package/src/index.css +0 -822
  55. package/src/lib/utils.js +0 -6
  56. package/src/main.jsx +0 -10
  57. package/src/utils/api.js +0 -141
  58. package/src/utils/websocket.js +0 -109
  59. package/src/utils/whisper.js +0 -37
  60. package/tailwind.config.js +0 -63
  61. package/vite.config.js +0 -29
  62. /package/{public → dist}/convert-icons.md +0 -0
  63. /package/{public → dist}/favicon.png +0 -0
  64. /package/{public → dist}/favicon.svg +0 -0
  65. /package/{public → dist}/generate-icons.js +0 -0
  66. /package/{public → dist}/icons/claude-ai-icon.svg +0 -0
  67. /package/{public → dist}/icons/cursor.svg +0 -0
  68. /package/{public → dist}/icons/generate-icons.md +0 -0
  69. /package/{public → dist}/icons/icon-128x128.png +0 -0
  70. /package/{public → dist}/icons/icon-128x128.svg +0 -0
  71. /package/{public → dist}/icons/icon-144x144.png +0 -0
  72. /package/{public → dist}/icons/icon-144x144.svg +0 -0
  73. /package/{public → dist}/icons/icon-152x152.png +0 -0
  74. /package/{public → dist}/icons/icon-152x152.svg +0 -0
  75. /package/{public → dist}/icons/icon-192x192.png +0 -0
  76. /package/{public → dist}/icons/icon-192x192.svg +0 -0
  77. /package/{public → dist}/icons/icon-384x384.png +0 -0
  78. /package/{public → dist}/icons/icon-384x384.svg +0 -0
  79. /package/{public → dist}/icons/icon-512x512.png +0 -0
  80. /package/{public → dist}/icons/icon-512x512.svg +0 -0
  81. /package/{public → dist}/icons/icon-72x72.png +0 -0
  82. /package/{public → dist}/icons/icon-72x72.svg +0 -0
  83. /package/{public → dist}/icons/icon-96x96.png +0 -0
  84. /package/{public → dist}/icons/icon-96x96.svg +0 -0
  85. /package/{public → dist}/icons/icon-template.svg +0 -0
  86. /package/{public → dist}/logo.svg +0 -0
  87. /package/{public → dist}/manifest.json +0 -0
  88. /package/{public → dist}/screenshots/cli-selection.png +0 -0
  89. /package/{public → dist}/screenshots/desktop-main.png +0 -0
  90. /package/{public → dist}/screenshots/mobile-chat.png +0 -0
  91. /package/{public → dist}/screenshots/tools-modal.png +0 -0
  92. /package/{public → dist}/sw.js +0 -0
@@ -1,1665 +0,0 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { ScrollArea } from './ui/scroll-area';
3
- import { Button } from './ui/button';
4
- import { Badge } from './ui/badge';
5
- import { Input } from './ui/input';
6
-
7
- import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRight, Edit3, Check, X, Trash2, Settings, FolderPlus, RefreshCw, Sparkles, Edit2, Star, Search } from 'lucide-react';
8
- import { cn } from '../lib/utils';
9
- import ClaudeLogo from './ClaudeLogo';
10
- import CursorLogo from './CursorLogo.jsx';
11
- import TaskIndicator from './TaskIndicator';
12
- import { api } from '../utils/api';
13
- import { useTaskMaster } from '../contexts/TaskMasterContext';
14
- import { useTasksSettings } from '../contexts/TasksSettingsContext';
15
-
16
- // Move formatTimeAgo outside component to avoid recreation on every render
17
- const formatTimeAgo = (dateString, currentTime) => {
18
- const date = new Date(dateString);
19
- const now = currentTime;
20
-
21
- // Check if date is valid
22
- if (isNaN(date.getTime())) {
23
- return 'Unknown';
24
- }
25
-
26
- const diffInMs = now - date;
27
- const diffInSeconds = Math.floor(diffInMs / 1000);
28
- const diffInMinutes = Math.floor(diffInMs / (1000 * 60));
29
- const diffInHours = Math.floor(diffInMs / (1000 * 60 * 60));
30
- const diffInDays = Math.floor(diffInMs / (1000 * 60 * 60 * 24));
31
-
32
- if (diffInSeconds < 60) return 'Just now';
33
- if (diffInMinutes === 1) return '1 min ago';
34
- if (diffInMinutes < 60) return `${diffInMinutes} mins ago`;
35
- if (diffInHours === 1) return '1 hour ago';
36
- if (diffInHours < 24) return `${diffInHours} hours ago`;
37
- if (diffInDays === 1) return '1 day ago';
38
- if (diffInDays < 7) return `${diffInDays} days ago`;
39
- return date.toLocaleDateString();
40
- };
41
-
42
- function Sidebar({
43
- projects,
44
- selectedProject,
45
- selectedSession,
46
- onProjectSelect,
47
- onSessionSelect,
48
- onNewSession,
49
- onSessionDelete,
50
- onProjectDelete,
51
- isLoading,
52
- onRefresh,
53
- onShowSettings,
54
- updateAvailable,
55
- latestVersion,
56
- currentVersion,
57
- onShowVersionModal,
58
- isPWA,
59
- isMobile
60
- }) {
61
- const [expandedProjects, setExpandedProjects] = useState(new Set());
62
- const [editingProject, setEditingProject] = useState(null);
63
- const [showNewProject, setShowNewProject] = useState(false);
64
- const [editingName, setEditingName] = useState('');
65
- const [newProjectPath, setNewProjectPath] = useState('');
66
- const [creatingProject, setCreatingProject] = useState(false);
67
- const [loadingSessions, setLoadingSessions] = useState({});
68
- const [additionalSessions, setAdditionalSessions] = useState({});
69
- const [initialSessionsLoaded, setInitialSessionsLoaded] = useState(new Set());
70
- const [currentTime, setCurrentTime] = useState(new Date());
71
- const [projectSortOrder, setProjectSortOrder] = useState('name');
72
- const [isRefreshing, setIsRefreshing] = useState(false);
73
- const [editingSession, setEditingSession] = useState(null);
74
- const [editingSessionName, setEditingSessionName] = useState('');
75
- const [generatingSummary, setGeneratingSummary] = useState({});
76
- const [searchFilter, setSearchFilter] = useState('');
77
- const [showPathDropdown, setShowPathDropdown] = useState(false);
78
- const [pathList, setPathList] = useState([]);
79
- const [filteredPaths, setFilteredPaths] = useState([]);
80
- const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
81
-
82
- // TaskMaster context
83
- const { setCurrentProject, mcpServerStatus } = useTaskMaster();
84
- const { tasksEnabled } = useTasksSettings();
85
-
86
-
87
- // Starred projects state - persisted in localStorage
88
- const [starredProjects, setStarredProjects] = useState(() => {
89
- try {
90
- const saved = localStorage.getItem('starredProjects');
91
- return saved ? new Set(JSON.parse(saved)) : new Set();
92
- } catch (error) {
93
- console.error('Error loading starred projects:', error);
94
- return new Set();
95
- }
96
- });
97
-
98
- // Touch handler to prevent double-tap issues on iPad (only for buttons, not scroll areas)
99
- const handleTouchClick = (callback) => {
100
- return (e) => {
101
- // Only prevent default for buttons/clickable elements, not scrollable areas
102
- if (e.target.closest('.overflow-y-auto') || e.target.closest('[data-scroll-container]')) {
103
- return;
104
- }
105
- e.preventDefault();
106
- e.stopPropagation();
107
- callback();
108
- };
109
- };
110
-
111
- // Auto-update timestamps every minute
112
- useEffect(() => {
113
- const timer = setInterval(() => {
114
- setCurrentTime(new Date());
115
- }, 60000); // Update every 60 seconds
116
-
117
- return () => clearInterval(timer);
118
- }, []);
119
-
120
- // Clear additional sessions when projects list changes (e.g., after refresh)
121
- useEffect(() => {
122
- setAdditionalSessions({});
123
- setInitialSessionsLoaded(new Set());
124
- }, [projects]);
125
-
126
- // Auto-expand project folder when a session is selected
127
- useEffect(() => {
128
- if (selectedSession && selectedProject) {
129
- setExpandedProjects(prev => new Set([...prev, selectedProject.name]));
130
- }
131
- }, [selectedSession, selectedProject]);
132
-
133
- // Mark sessions as loaded when projects come in
134
- useEffect(() => {
135
- if (projects.length > 0 && !isLoading) {
136
- const newLoaded = new Set();
137
- projects.forEach(project => {
138
- if (project.sessions && project.sessions.length >= 0) {
139
- newLoaded.add(project.name);
140
- }
141
- });
142
- setInitialSessionsLoaded(newLoaded);
143
- }
144
- }, [projects, isLoading]);
145
-
146
- // Load project sort order from settings
147
- useEffect(() => {
148
- const loadSortOrder = () => {
149
- try {
150
- const savedSettings = localStorage.getItem('claude-settings');
151
- if (savedSettings) {
152
- const settings = JSON.parse(savedSettings);
153
- setProjectSortOrder(settings.projectSortOrder || 'name');
154
- }
155
- } catch (error) {
156
- console.error('Error loading sort order:', error);
157
- }
158
- };
159
-
160
- // Load initially
161
- loadSortOrder();
162
-
163
- // Listen for storage changes
164
- const handleStorageChange = (e) => {
165
- if (e.key === 'claude-settings') {
166
- loadSortOrder();
167
- }
168
- };
169
-
170
- window.addEventListener('storage', handleStorageChange);
171
-
172
- // Also check periodically when component is focused (for same-tab changes)
173
- const checkInterval = setInterval(() => {
174
- if (document.hasFocus()) {
175
- loadSortOrder();
176
- }
177
- }, 1000);
178
-
179
- return () => {
180
- window.removeEventListener('storage', handleStorageChange);
181
- clearInterval(checkInterval);
182
- };
183
- }, []);
184
-
185
- // Load available paths for suggestions
186
- useEffect(() => {
187
- const loadPaths = async () => {
188
- try {
189
- // Get recent paths from localStorage
190
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
191
-
192
- // Load common/home directory paths
193
- const response = await api.browseFilesystem();
194
- const data = await response.json();
195
-
196
- if (data.suggestions) {
197
- const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
198
- const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
199
- setPathList(allPaths);
200
- } else {
201
- setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
202
- }
203
- } catch (error) {
204
- console.error('Error loading paths:', error);
205
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
206
- setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
207
- }
208
- };
209
-
210
- loadPaths();
211
- }, []);
212
-
213
- // Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
214
- useEffect(() => {
215
- const inputValue = newProjectPath.trim();
216
-
217
- if (inputValue.length === 0) {
218
- setShowPathDropdown(false);
219
- return;
220
- }
221
-
222
- // Show dropdown when user starts typing
223
- setShowPathDropdown(true);
224
-
225
- const updateSuggestions = async () => {
226
- // First show filtered existing suggestions from pathList
227
- const staticFiltered = pathList.filter(pathItem =>
228
- pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
229
- pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
230
- );
231
-
232
- // Check if input looks like a directory path for dynamic browsing
233
- const isDirPath = inputValue.includes('/') && inputValue.length > 1;
234
-
235
- if (isDirPath) {
236
- try {
237
- let dirToSearch;
238
-
239
- // Determine which directory to search
240
- if (inputValue.endsWith('/')) {
241
- // User typed "/home/simos/" - search inside /home/simos
242
- dirToSearch = inputValue.slice(0, -1);
243
- } else {
244
- // User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
245
- const lastSlashIndex = inputValue.lastIndexOf('/');
246
- dirToSearch = inputValue.substring(0, lastSlashIndex);
247
- }
248
-
249
- // Only search if we have a valid directory path (not root only)
250
- if (dirToSearch && dirToSearch !== '') {
251
- const response = await api.browseFilesystem(dirToSearch);
252
- const data = await response.json();
253
-
254
- if (data.suggestions) {
255
- // Filter directories that match the current input
256
- const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
257
- const dynamicPaths = data.suggestions
258
- .filter(suggestion => {
259
- const dirName = suggestion.name;
260
- return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
261
- })
262
- .map(s => ({ name: s.name, path: s.path }))
263
- .slice(0, 8);
264
-
265
- // Combine static and dynamic suggestions, prioritize dynamic
266
- const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
267
- setFilteredPaths(combined);
268
- setSelectedPathIndex(-1);
269
- return;
270
- }
271
- }
272
- } catch (error) {
273
- console.debug('Dynamic browsing failed:', error.message);
274
- }
275
- }
276
-
277
- // Fallback to just static filtered suggestions
278
- setFilteredPaths(staticFiltered.slice(0, 8));
279
- setSelectedPathIndex(-1);
280
- };
281
-
282
- updateSuggestions();
283
- }, [newProjectPath, pathList]);
284
-
285
- // Select path from dropdown (ChatInterface pattern)
286
- const selectPath = (pathItem) => {
287
- setNewProjectPath(pathItem.path);
288
- setShowPathDropdown(false);
289
- setSelectedPathIndex(-1);
290
- };
291
-
292
- // Save path to recent paths
293
- const saveToRecentPaths = (path) => {
294
- try {
295
- const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
296
- const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
297
- localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
298
- } catch (error) {
299
- console.error('Error saving recent paths:', error);
300
- }
301
- };
302
-
303
- const toggleProject = (projectName) => {
304
- const newExpanded = new Set(expandedProjects);
305
- if (newExpanded.has(projectName)) {
306
- newExpanded.delete(projectName);
307
- } else {
308
- newExpanded.add(projectName);
309
- }
310
- setExpandedProjects(newExpanded);
311
- };
312
-
313
- // Starred projects utility functions
314
- const toggleStarProject = (projectName) => {
315
- const newStarred = new Set(starredProjects);
316
- if (newStarred.has(projectName)) {
317
- newStarred.delete(projectName);
318
- } else {
319
- newStarred.add(projectName);
320
- }
321
- setStarredProjects(newStarred);
322
-
323
- // Persist to localStorage
324
- try {
325
- localStorage.setItem('starredProjects', JSON.stringify([...newStarred]));
326
- } catch (error) {
327
- console.error('Error saving starred projects:', error);
328
- }
329
- };
330
-
331
- const isProjectStarred = (projectName) => {
332
- return starredProjects.has(projectName);
333
- };
334
-
335
- // Helper function to get all sessions for a project (initial + additional)
336
- const getAllSessions = (project) => {
337
- // Combine Claude and Cursor sessions; Sidebar will display icon per row
338
- const claudeSessions = [...(project.sessions || []), ...(additionalSessions[project.name] || [])].map(s => ({ ...s, __provider: 'claude' }));
339
- const cursorSessions = (project.cursorSessions || []).map(s => ({ ...s, __provider: 'cursor' }));
340
- // Sort by most recent activity/date
341
- const normalizeDate = (s) => new Date(s.__provider === 'cursor' ? s.createdAt : s.lastActivity);
342
- return [...claudeSessions, ...cursorSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a));
343
- };
344
-
345
- // Helper function to get the last activity date for a project
346
- const getProjectLastActivity = (project) => {
347
- const allSessions = getAllSessions(project);
348
- if (allSessions.length === 0) {
349
- return new Date(0); // Return epoch date for projects with no sessions
350
- }
351
-
352
- // Find the most recent session activity
353
- const mostRecentDate = allSessions.reduce((latest, session) => {
354
- const sessionDate = new Date(session.lastActivity);
355
- return sessionDate > latest ? sessionDate : latest;
356
- }, new Date(0));
357
-
358
- return mostRecentDate;
359
- };
360
-
361
- // Combined sorting: starred projects first, then by selected order
362
- const sortedProjects = [...projects].sort((a, b) => {
363
- const aStarred = isProjectStarred(a.name);
364
- const bStarred = isProjectStarred(b.name);
365
-
366
- // First, sort by starred status
367
- if (aStarred && !bStarred) return -1;
368
- if (!aStarred && bStarred) return 1;
369
-
370
- // For projects with same starred status, sort by selected order
371
- if (projectSortOrder === 'date') {
372
- // Sort by most recent activity (descending)
373
- return getProjectLastActivity(b) - getProjectLastActivity(a);
374
- } else {
375
- // Sort by display name (user-defined) or fallback to name (ascending)
376
- const nameA = a.displayName || a.name;
377
- const nameB = b.displayName || b.name;
378
- return nameA.localeCompare(nameB);
379
- }
380
- });
381
-
382
- const startEditing = (project) => {
383
- setEditingProject(project.name);
384
- setEditingName(project.displayName);
385
- };
386
-
387
- const cancelEditing = () => {
388
- setEditingProject(null);
389
- setEditingName('');
390
- };
391
-
392
- const saveProjectName = async (projectName) => {
393
- try {
394
- const response = await api.renameProject(projectName, editingName);
395
-
396
- if (response.ok) {
397
- // Refresh projects to get updated data
398
- if (window.refreshProjects) {
399
- window.refreshProjects();
400
- } else {
401
- window.location.reload();
402
- }
403
- } else {
404
- console.error('Failed to rename project');
405
- }
406
- } catch (error) {
407
- console.error('Error renaming project:', error);
408
- }
409
-
410
- setEditingProject(null);
411
- setEditingName('');
412
- };
413
-
414
- const deleteSession = async (projectName, sessionId) => {
415
- if (!confirm('Are you sure you want to delete this session? This action cannot be undone.')) {
416
- return;
417
- }
418
-
419
- try {
420
- const response = await api.deleteSession(projectName, sessionId);
421
-
422
- if (response.ok) {
423
- // Call parent callback if provided
424
- if (onSessionDelete) {
425
- onSessionDelete(sessionId);
426
- }
427
- } else {
428
- console.error('Failed to delete session');
429
- alert('Failed to delete session. Please try again.');
430
- }
431
- } catch (error) {
432
- console.error('Error deleting session:', error);
433
- alert('Error deleting session. Please try again.');
434
- }
435
- };
436
-
437
- const deleteProject = async (projectName) => {
438
- if (!confirm('Are you sure you want to delete this empty project? This action cannot be undone.')) {
439
- return;
440
- }
441
-
442
- try {
443
- const response = await api.deleteProject(projectName);
444
-
445
- if (response.ok) {
446
- // Call parent callback if provided
447
- if (onProjectDelete) {
448
- onProjectDelete(projectName);
449
- }
450
- } else {
451
- const error = await response.json();
452
- console.error('Failed to delete project');
453
- alert(error.error || 'Failed to delete project. Please try again.');
454
- }
455
- } catch (error) {
456
- console.error('Error deleting project:', error);
457
- alert('Error deleting project. Please try again.');
458
- }
459
- };
460
-
461
- const createNewProject = async () => {
462
- if (!newProjectPath.trim()) {
463
- alert('Please enter a project path');
464
- return;
465
- }
466
-
467
- setCreatingProject(true);
468
-
469
- try {
470
- const response = await api.createProject(newProjectPath.trim());
471
-
472
- if (response.ok) {
473
- const result = await response.json();
474
-
475
- // Save the path to recent paths before clearing
476
- saveToRecentPaths(newProjectPath.trim());
477
-
478
- setShowNewProject(false);
479
- setNewProjectPath('');
480
- setShowSuggestions(false);
481
-
482
- // Refresh projects to show the new one
483
- if (window.refreshProjects) {
484
- window.refreshProjects();
485
- } else {
486
- window.location.reload();
487
- }
488
- } else {
489
- const error = await response.json();
490
- alert(error.error || 'Failed to create project. Please try again.');
491
- }
492
- } catch (error) {
493
- console.error('Error creating project:', error);
494
- alert('Error creating project. Please try again.');
495
- } finally {
496
- setCreatingProject(false);
497
- }
498
- };
499
-
500
- const cancelNewProject = () => {
501
- setShowNewProject(false);
502
- setNewProjectPath('');
503
- setShowSuggestions(false);
504
- };
505
-
506
- const loadMoreSessions = async (project) => {
507
- // Check if we can load more sessions
508
- const canLoadMore = project.sessionMeta?.hasMore !== false;
509
-
510
- if (!canLoadMore || loadingSessions[project.name]) {
511
- return;
512
- }
513
-
514
- setLoadingSessions(prev => ({ ...prev, [project.name]: true }));
515
-
516
- try {
517
- const currentSessionCount = (project.sessions?.length || 0) + (additionalSessions[project.name]?.length || 0);
518
- const response = await api.sessions(project.name, 5, currentSessionCount);
519
-
520
- if (response.ok) {
521
- const result = await response.json();
522
-
523
- // Store additional sessions locally
524
- setAdditionalSessions(prev => ({
525
- ...prev,
526
- [project.name]: [
527
- ...(prev[project.name] || []),
528
- ...result.sessions
529
- ]
530
- }));
531
-
532
- // Update project metadata if needed
533
- if (result.hasMore === false) {
534
- // Mark that there are no more sessions to load
535
- project.sessionMeta = { ...project.sessionMeta, hasMore: false };
536
- }
537
- }
538
- } catch (error) {
539
- console.error('Error loading more sessions:', error);
540
- } finally {
541
- setLoadingSessions(prev => ({ ...prev, [project.name]: false }));
542
- }
543
- };
544
-
545
- // Filter projects based on search input
546
- const filteredProjects = sortedProjects.filter(project => {
547
- if (!searchFilter.trim()) return true;
548
-
549
- const searchLower = searchFilter.toLowerCase();
550
- const displayName = (project.displayName || project.name).toLowerCase();
551
- const projectName = project.name.toLowerCase();
552
-
553
- // Search in both display name and actual project name/path
554
- return displayName.includes(searchLower) || projectName.includes(searchLower);
555
- });
556
-
557
- // Enhanced project selection that updates both the main UI and TaskMaster context
558
- const handleProjectSelect = (project) => {
559
- // Call the original project select handler
560
- onProjectSelect(project);
561
-
562
- // Update TaskMaster context with the selected project
563
- setCurrentProject(project);
564
- };
565
-
566
- return (
567
- <div
568
- className="h-full flex flex-col bg-card md:select-none"
569
- style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
570
- >
571
- {/* Header */}
572
- <div className="md:p-4 md:border-b md:border-border">
573
- {/* Desktop Header */}
574
- <div className="hidden md:flex items-center justify-between">
575
- <div className="flex items-center gap-3">
576
- <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center shadow-sm">
577
- <MessageSquare className="w-4 h-4 text-primary-foreground" />
578
- </div>
579
- <div>
580
- <h1 className="text-lg font-bold text-foreground">Claude Code UI</h1>
581
- <p className="text-sm text-muted-foreground">AI coding assistant interface</p>
582
- </div>
583
- </div>
584
- <div className="flex gap-2">
585
- <Button
586
- variant="ghost"
587
- size="sm"
588
- className="h-9 w-9 px-0 hover:bg-accent transition-colors duration-200 group"
589
- onClick={async () => {
590
- setIsRefreshing(true);
591
- try {
592
- await onRefresh();
593
- } finally {
594
- setIsRefreshing(false);
595
- }
596
- }}
597
- disabled={isRefreshing}
598
- title="Refresh projects and sessions (Ctrl+R)"
599
- >
600
- <RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''} group-hover:rotate-180 transition-transform duration-300`} />
601
- </Button>
602
- <Button
603
- variant="default"
604
- size="sm"
605
- className="h-9 w-9 px-0 bg-primary hover:bg-primary/90 transition-all duration-200 shadow-sm hover:shadow-md"
606
- onClick={() => setShowNewProject(true)}
607
- title="Create new project (Ctrl+N)"
608
- >
609
- <FolderPlus className="w-4 h-4" />
610
- </Button>
611
- </div>
612
- </div>
613
-
614
- {/* Mobile Header */}
615
- <div
616
- className="md:hidden p-3 border-b border-border"
617
- style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
618
- >
619
- <div className="flex items-center justify-between">
620
- <div className="flex items-center gap-3">
621
- <div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
622
- <MessageSquare className="w-4 h-4 text-primary-foreground" />
623
- </div>
624
- <div>
625
- <h1 className="text-lg font-semibold text-foreground">Claude Code UI</h1>
626
- <p className="text-sm text-muted-foreground">Projects</p>
627
- </div>
628
- </div>
629
- <div className="flex gap-2">
630
- <button
631
- className="w-8 h-8 rounded-md bg-background border border-border flex items-center justify-center active:scale-95 transition-all duration-150"
632
- onClick={async () => {
633
- setIsRefreshing(true);
634
- try {
635
- await onRefresh();
636
- } finally {
637
- setIsRefreshing(false);
638
- }
639
- }}
640
- disabled={isRefreshing}
641
- >
642
- <RefreshCw className={`w-4 h-4 text-foreground ${isRefreshing ? 'animate-spin' : ''}`} />
643
- </button>
644
- <button
645
- className="w-8 h-8 rounded-md bg-primary text-primary-foreground flex items-center justify-center active:scale-95 transition-all duration-150"
646
- onClick={() => setShowNewProject(true)}
647
- >
648
- <FolderPlus className="w-4 h-4" />
649
- </button>
650
- </div>
651
- </div>
652
- </div>
653
- </div>
654
-
655
-
656
- {/* New Project Form */}
657
- {showNewProject && (
658
- <div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
659
- {/* Desktop Form */}
660
- <div className="hidden md:block space-y-2">
661
- <div className="flex items-center gap-2 text-sm font-medium text-foreground">
662
- <FolderPlus className="w-4 h-4" />
663
- Create New Project
664
- </div>
665
- <div className="relative">
666
- <Input
667
- value={newProjectPath}
668
- onChange={(e) => setNewProjectPath(e.target.value)}
669
- placeholder="/path/to/project or relative/path"
670
- className="text-sm focus:ring-2 focus:ring-primary/20"
671
- autoFocus
672
- onKeyDown={(e) => {
673
- // Handle path dropdown navigation (ChatInterface pattern)
674
- if (showPathDropdown && filteredPaths.length > 0) {
675
- if (e.key === 'ArrowDown') {
676
- e.preventDefault();
677
- setSelectedPathIndex(prev =>
678
- prev < filteredPaths.length - 1 ? prev + 1 : 0
679
- );
680
- } else if (e.key === 'ArrowUp') {
681
- e.preventDefault();
682
- setSelectedPathIndex(prev =>
683
- prev > 0 ? prev - 1 : filteredPaths.length - 1
684
- );
685
- } else if (e.key === 'Enter') {
686
- e.preventDefault();
687
- if (selectedPathIndex >= 0) {
688
- selectPath(filteredPaths[selectedPathIndex]);
689
- } else if (filteredPaths.length > 0) {
690
- selectPath(filteredPaths[0]);
691
- } else {
692
- createNewProject();
693
- }
694
- return;
695
- } else if (e.key === 'Escape') {
696
- e.preventDefault();
697
- setShowPathDropdown(false);
698
- return;
699
- } else if (e.key === 'Tab') {
700
- e.preventDefault();
701
- if (selectedPathIndex >= 0) {
702
- selectPath(filteredPaths[selectedPathIndex]);
703
- } else if (filteredPaths.length > 0) {
704
- selectPath(filteredPaths[0]);
705
- }
706
- return;
707
- }
708
- }
709
-
710
- // Regular input handling
711
- if (e.key === 'Enter') {
712
- createNewProject();
713
- }
714
- if (e.key === 'Escape') {
715
- cancelNewProject();
716
- }
717
- }}
718
- />
719
-
720
- {/* Path dropdown (ChatInterface pattern) */}
721
- {showPathDropdown && filteredPaths.length > 0 && (
722
- <div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
723
- {filteredPaths.map((pathItem, index) => (
724
- <div
725
- key={pathItem.path}
726
- className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
727
- index === selectedPathIndex
728
- ? 'bg-accent text-accent-foreground'
729
- : 'hover:bg-accent/50'
730
- }`}
731
- onMouseDown={(e) => {
732
- e.preventDefault();
733
- e.stopPropagation();
734
- }}
735
- onClick={(e) => {
736
- e.preventDefault();
737
- e.stopPropagation();
738
- selectPath(pathItem);
739
- }}
740
- >
741
- <div className="flex items-center gap-2">
742
- <Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
743
- <div>
744
- <div className="font-medium text-sm">{pathItem.name}</div>
745
- <div className="text-xs text-muted-foreground font-mono">
746
- {pathItem.path}
747
- </div>
748
- </div>
749
- </div>
750
- </div>
751
- ))}
752
- </div>
753
- )}
754
- </div>
755
- <div className="flex gap-2">
756
- <Button
757
- size="sm"
758
- onClick={createNewProject}
759
- disabled={!newProjectPath.trim() || creatingProject}
760
- className="flex-1 h-8 text-xs hover:bg-primary/90 transition-colors"
761
- >
762
- {creatingProject ? 'Creating...' : 'Create Project'}
763
- </Button>
764
- <Button
765
- size="sm"
766
- variant="outline"
767
- onClick={cancelNewProject}
768
- disabled={creatingProject}
769
- className="h-8 text-xs hover:bg-accent transition-colors"
770
- >
771
- Cancel
772
- </Button>
773
- </div>
774
- </div>
775
-
776
- {/* Mobile Form - Simple Overlay */}
777
- <div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
778
- <div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
779
- <div className="flex items-center justify-between">
780
- <div className="flex items-center gap-2">
781
- <div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
782
- <FolderPlus className="w-3 h-3 text-primary" />
783
- </div>
784
- <div>
785
- <h2 className="text-base font-semibold text-foreground">New Project</h2>
786
- </div>
787
- </div>
788
- <button
789
- onClick={cancelNewProject}
790
- disabled={creatingProject}
791
- className="w-6 h-6 rounded-md bg-muted flex items-center justify-center active:scale-95 transition-transform"
792
- >
793
- <X className="w-3 h-3" />
794
- </button>
795
- </div>
796
-
797
- <div className="space-y-3">
798
- <div className="relative">
799
- <Input
800
- value={newProjectPath}
801
- onChange={(e) => setNewProjectPath(e.target.value)}
802
- placeholder="/path/to/project or relative/path"
803
- className="text-sm h-10 rounded-md focus:border-primary transition-colors"
804
- autoFocus
805
- onKeyDown={(e) => {
806
- // Handle path dropdown navigation (same as desktop)
807
- if (showPathDropdown && filteredPaths.length > 0) {
808
- if (e.key === 'ArrowDown') {
809
- e.preventDefault();
810
- setSelectedPathIndex(prev =>
811
- prev < filteredPaths.length - 1 ? prev + 1 : 0
812
- );
813
- } else if (e.key === 'ArrowUp') {
814
- e.preventDefault();
815
- setSelectedPathIndex(prev =>
816
- prev > 0 ? prev - 1 : filteredPaths.length - 1
817
- );
818
- } else if (e.key === 'Enter') {
819
- e.preventDefault();
820
- if (selectedPathIndex >= 0) {
821
- selectPath(filteredPaths[selectedPathIndex]);
822
- } else if (filteredPaths.length > 0) {
823
- selectPath(filteredPaths[0]);
824
- } else {
825
- createNewProject();
826
- }
827
- return;
828
- } else if (e.key === 'Escape') {
829
- e.preventDefault();
830
- setShowPathDropdown(false);
831
- return;
832
- }
833
- }
834
-
835
- // Regular input handling
836
- if (e.key === 'Enter') {
837
- createNewProject();
838
- }
839
- if (e.key === 'Escape') {
840
- cancelNewProject();
841
- }
842
- }}
843
- style={{
844
- fontSize: '16px', // Prevents zoom on iOS
845
- WebkitAppearance: 'none'
846
- }}
847
- />
848
-
849
- {/* Mobile Path dropdown */}
850
- {showPathDropdown && filteredPaths.length > 0 && (
851
- <div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
852
- {filteredPaths.map((pathItem, index) => (
853
- <div
854
- key={pathItem.path}
855
- className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
856
- index === selectedPathIndex
857
- ? 'bg-accent text-accent-foreground'
858
- : 'hover:bg-accent/50'
859
- }`}
860
- onMouseDown={(e) => {
861
- e.preventDefault();
862
- e.stopPropagation();
863
- }}
864
- onClick={(e) => {
865
- e.preventDefault();
866
- e.stopPropagation();
867
- selectPath(pathItem);
868
- }}
869
- >
870
- <div className="flex items-center gap-2">
871
- <Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
872
- <div>
873
- <div className="font-medium text-sm">{pathItem.name}</div>
874
- <div className="text-xs text-muted-foreground font-mono">
875
- {pathItem.path}
876
- </div>
877
- </div>
878
- </div>
879
- </div>
880
- ))}
881
- </div>
882
- )}
883
- </div>
884
-
885
- <div className="flex gap-2">
886
- <Button
887
- onClick={cancelNewProject}
888
- disabled={creatingProject}
889
- variant="outline"
890
- className="flex-1 h-9 text-sm rounded-md active:scale-95 transition-transform"
891
- >
892
- Cancel
893
- </Button>
894
- <Button
895
- onClick={createNewProject}
896
- disabled={!newProjectPath.trim() || creatingProject}
897
- className="flex-1 h-9 text-sm rounded-md bg-primary hover:bg-primary/90 active:scale-95 transition-all"
898
- >
899
- {creatingProject ? 'Creating...' : 'Create'}
900
- </Button>
901
- </div>
902
- </div>
903
-
904
- {/* Safe area for mobile */}
905
- <div className="h-4" />
906
- </div>
907
- </div>
908
- </div>
909
- )}
910
-
911
- {/* Search Filter */}
912
- {projects.length > 0 && !isLoading && (
913
- <div className="px-3 md:px-4 py-2 border-b border-border">
914
- <div className="relative">
915
- <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
916
- <Input
917
- type="text"
918
- placeholder="Search projects..."
919
- value={searchFilter}
920
- onChange={(e) => setSearchFilter(e.target.value)}
921
- className="pl-9 h-9 text-sm bg-muted/50 border-0 focus:bg-background focus:ring-1 focus:ring-primary/20"
922
- />
923
- {searchFilter && (
924
- <button
925
- onClick={() => setSearchFilter('')}
926
- className="absolute right-2 top-1/2 transform -translate-y-1/2 p-1 hover:bg-accent rounded"
927
- >
928
- <X className="w-3 h-3 text-muted-foreground" />
929
- </button>
930
- )}
931
- </div>
932
- </div>
933
- )}
934
-
935
- {/* Projects List */}
936
- <ScrollArea className="flex-1 md:px-2 md:py-3 overflow-y-auto overscroll-contain">
937
- <div className="md:space-y-1 pb-safe-area-inset-bottom">
938
- {isLoading ? (
939
- <div className="text-center py-12 md:py-8 px-4">
940
- <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
941
- <div className="w-6 h-6 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
942
- </div>
943
- <h3 className="text-base font-medium text-foreground mb-2 md:mb-1">Loading projects...</h3>
944
- <p className="text-sm text-muted-foreground">
945
- Fetching your Claude projects and sessions
946
- </p>
947
- </div>
948
- ) : projects.length === 0 ? (
949
- <div className="text-center py-12 md:py-8 px-4">
950
- <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
951
- <Folder className="w-6 h-6 text-muted-foreground" />
952
- </div>
953
- <h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No projects found</h3>
954
- <p className="text-sm text-muted-foreground">
955
- Run Claude CLI in a project directory to get started
956
- </p>
957
- </div>
958
- ) : filteredProjects.length === 0 ? (
959
- <div className="text-center py-12 md:py-8 px-4">
960
- <div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-4 md:mb-3">
961
- <Search className="w-6 h-6 text-muted-foreground" />
962
- </div>
963
- <h3 className="text-base font-medium text-foreground mb-2 md:mb-1">No matching projects</h3>
964
- <p className="text-sm text-muted-foreground">
965
- Try adjusting your search term
966
- </p>
967
- </div>
968
- ) : (
969
- filteredProjects.map((project) => {
970
- const isExpanded = expandedProjects.has(project.name);
971
- const isSelected = selectedProject?.name === project.name;
972
- const isStarred = isProjectStarred(project.name);
973
-
974
- return (
975
- <div key={project.name} className="md:space-y-1">
976
- {/* Project Header */}
977
- <div className="group md:group">
978
- {/* Mobile Project Item */}
979
- <div className="md:hidden">
980
- <div
981
- className={cn(
982
- "p-3 mx-3 my-1 rounded-lg bg-card border border-border/50 active:scale-[0.98] transition-all duration-150",
983
- isSelected && "bg-primary/5 border-primary/20",
984
- isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/5 border-yellow-200/30 dark:border-yellow-800/30"
985
- )}
986
- onClick={() => {
987
- // On mobile, just toggle the folder - don't select the project
988
- toggleProject(project.name);
989
- }}
990
- onTouchEnd={handleTouchClick(() => toggleProject(project.name))}
991
- >
992
- <div className="flex items-center justify-between">
993
- <div className="flex items-center gap-3 min-w-0 flex-1">
994
- <div className={cn(
995
- "w-8 h-8 rounded-lg flex items-center justify-center transition-colors",
996
- isExpanded ? "bg-primary/10" : "bg-muted"
997
- )}>
998
- {isExpanded ? (
999
- <FolderOpen className="w-4 h-4 text-primary" />
1000
- ) : (
1001
- <Folder className="w-4 h-4 text-muted-foreground" />
1002
- )}
1003
- </div>
1004
- <div className="min-w-0 flex-1">
1005
- {editingProject === project.name ? (
1006
- <input
1007
- type="text"
1008
- value={editingName}
1009
- onChange={(e) => setEditingName(e.target.value)}
1010
- className="w-full px-3 py-2 text-sm border-2 border-primary/40 focus:border-primary rounded-lg bg-background text-foreground shadow-sm focus:shadow-md transition-all duration-200 focus:outline-none"
1011
- placeholder="Project name"
1012
- autoFocus
1013
- autoComplete="off"
1014
- onClick={(e) => e.stopPropagation()}
1015
- onKeyDown={(e) => {
1016
- if (e.key === 'Enter') saveProjectName(project.name);
1017
- if (e.key === 'Escape') cancelEditing();
1018
- }}
1019
- style={{
1020
- fontSize: '16px', // Prevents zoom on iOS
1021
- WebkitAppearance: 'none',
1022
- borderRadius: '8px'
1023
- }}
1024
- />
1025
- ) : (
1026
- <>
1027
- <div className="flex items-center justify-between min-w-0 flex-1">
1028
- <h3 className="text-sm font-medium text-foreground truncate">
1029
- {project.displayName}
1030
- </h3>
1031
- {tasksEnabled && (
1032
- <TaskIndicator
1033
- status={(() => {
1034
- const projectConfigured = project.taskmaster?.hasTaskmaster;
1035
- const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
1036
- if (projectConfigured && mcpConfigured) return 'fully-configured';
1037
- if (projectConfigured) return 'taskmaster-only';
1038
- if (mcpConfigured) return 'mcp-only';
1039
- return 'not-configured';
1040
- })()}
1041
- size="xs"
1042
- className="flex-shrink-0 ml-2"
1043
- />
1044
- )}
1045
- </div>
1046
- <p className="text-xs text-muted-foreground">
1047
- {(() => {
1048
- const sessionCount = getAllSessions(project).length;
1049
- const hasMore = project.sessionMeta?.hasMore !== false;
1050
- const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
1051
- return `${count} session${count === 1 ? '' : 's'}`;
1052
- })()}
1053
- </p>
1054
- </>
1055
- )}
1056
- </div>
1057
- </div>
1058
- <div className="flex items-center gap-1">
1059
- {editingProject === project.name ? (
1060
- <>
1061
- <button
1062
- className="w-8 h-8 rounded-lg bg-green-500 dark:bg-green-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
1063
- onClick={(e) => {
1064
- e.stopPropagation();
1065
- saveProjectName(project.name);
1066
- }}
1067
- >
1068
- <Check className="w-4 h-4 text-white" />
1069
- </button>
1070
- <button
1071
- className="w-8 h-8 rounded-lg bg-gray-500 dark:bg-gray-600 flex items-center justify-center active:scale-90 transition-all duration-150 shadow-sm active:shadow-none"
1072
- onClick={(e) => {
1073
- e.stopPropagation();
1074
- cancelEditing();
1075
- }}
1076
- >
1077
- <X className="w-4 h-4 text-white" />
1078
- </button>
1079
- </>
1080
- ) : (
1081
- <>
1082
- {/* Star button */}
1083
- <button
1084
- className={cn(
1085
- "w-8 h-8 rounded-lg flex items-center justify-center active:scale-90 transition-all duration-150 border",
1086
- isStarred
1087
- ? "bg-yellow-500/10 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800"
1088
- : "bg-gray-500/10 dark:bg-gray-900/30 border-gray-200 dark:border-gray-800"
1089
- )}
1090
- onClick={(e) => {
1091
- e.stopPropagation();
1092
- toggleStarProject(project.name);
1093
- }}
1094
- onTouchEnd={handleTouchClick(() => toggleStarProject(project.name))}
1095
- title={isStarred ? "Remove from favorites" : "Add to favorites"}
1096
- >
1097
- <Star className={cn(
1098
- "w-4 h-4 transition-colors",
1099
- isStarred
1100
- ? "text-yellow-600 dark:text-yellow-400 fill-current"
1101
- : "text-gray-600 dark:text-gray-400"
1102
- )} />
1103
- </button>
1104
- {getAllSessions(project).length === 0 && (
1105
- <button
1106
- className="w-8 h-8 rounded-lg bg-red-500/10 dark:bg-red-900/30 flex items-center justify-center active:scale-90 border border-red-200 dark:border-red-800"
1107
- onClick={(e) => {
1108
- e.stopPropagation();
1109
- deleteProject(project.name);
1110
- }}
1111
- onTouchEnd={handleTouchClick(() => deleteProject(project.name))}
1112
- >
1113
- <Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
1114
- </button>
1115
- )}
1116
- <button
1117
- className="w-8 h-8 rounded-lg bg-primary/10 dark:bg-primary/20 flex items-center justify-center active:scale-90 border border-primary/20 dark:border-primary/30"
1118
- onClick={(e) => {
1119
- e.stopPropagation();
1120
- startEditing(project);
1121
- }}
1122
- onTouchEnd={handleTouchClick(() => startEditing(project))}
1123
- >
1124
- <Edit3 className="w-4 h-4 text-primary" />
1125
- </button>
1126
- <div className="w-6 h-6 rounded-md bg-muted/30 flex items-center justify-center">
1127
- {isExpanded ? (
1128
- <ChevronDown className="w-3 h-3 text-muted-foreground" />
1129
- ) : (
1130
- <ChevronRight className="w-3 h-3 text-muted-foreground" />
1131
- )}
1132
- </div>
1133
- </>
1134
- )}
1135
- </div>
1136
- </div>
1137
- </div>
1138
- </div>
1139
-
1140
- {/* Desktop Project Item */}
1141
- <Button
1142
- variant="ghost"
1143
- className={cn(
1144
- "hidden md:flex w-full justify-between p-2 h-auto font-normal hover:bg-accent/50",
1145
- isSelected && "bg-accent text-accent-foreground",
1146
- isStarred && !isSelected && "bg-yellow-50/50 dark:bg-yellow-900/10 hover:bg-yellow-100/50 dark:hover:bg-yellow-900/20"
1147
- )}
1148
- onClick={() => {
1149
- // Desktop behavior: select project and toggle
1150
- if (selectedProject?.name !== project.name) {
1151
- handleProjectSelect(project);
1152
- }
1153
- toggleProject(project.name);
1154
- }}
1155
- onTouchEnd={handleTouchClick(() => {
1156
- if (selectedProject?.name !== project.name) {
1157
- handleProjectSelect(project);
1158
- }
1159
- toggleProject(project.name);
1160
- })}
1161
- >
1162
- <div className="flex items-center gap-3 min-w-0 flex-1">
1163
- {isExpanded ? (
1164
- <FolderOpen className="w-4 h-4 text-primary flex-shrink-0" />
1165
- ) : (
1166
- <Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
1167
- )}
1168
- <div className="min-w-0 flex-1 text-left">
1169
- {editingProject === project.name ? (
1170
- <div className="space-y-1">
1171
- <input
1172
- type="text"
1173
- value={editingName}
1174
- onChange={(e) => setEditingName(e.target.value)}
1175
- className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:ring-2 focus:ring-primary/20"
1176
- placeholder="Project name"
1177
- autoFocus
1178
- onKeyDown={(e) => {
1179
- if (e.key === 'Enter') saveProjectName(project.name);
1180
- if (e.key === 'Escape') cancelEditing();
1181
- }}
1182
- />
1183
- <div className="text-xs text-muted-foreground truncate" title={project.fullPath}>
1184
- {project.fullPath}
1185
- </div>
1186
- </div>
1187
- ) : (
1188
- <div>
1189
- <div className="text-sm font-semibold truncate text-foreground" title={project.displayName}>
1190
- {project.displayName}
1191
- </div>
1192
- <div className="text-xs text-muted-foreground">
1193
- {(() => {
1194
- const sessionCount = getAllSessions(project).length;
1195
- const hasMore = project.sessionMeta?.hasMore !== false;
1196
- return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
1197
- })()}
1198
- {project.fullPath !== project.displayName && (
1199
- <span className="ml-1 opacity-60" title={project.fullPath}>
1200
- • {project.fullPath.length > 25 ? '...' + project.fullPath.slice(-22) : project.fullPath}
1201
- </span>
1202
- )}
1203
- </div>
1204
- </div>
1205
- )}
1206
- </div>
1207
- </div>
1208
-
1209
- <div className="flex items-center gap-1 flex-shrink-0">
1210
- {editingProject === project.name ? (
1211
- <>
1212
- <div
1213
- className="w-6 h-6 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 flex items-center justify-center rounded cursor-pointer transition-colors"
1214
- onClick={(e) => {
1215
- e.stopPropagation();
1216
- saveProjectName(project.name);
1217
- }}
1218
- >
1219
- <Check className="w-3 h-3" />
1220
- </div>
1221
- <div
1222
- className="w-6 h-6 text-gray-500 hover:text-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 flex items-center justify-center rounded cursor-pointer transition-colors"
1223
- onClick={(e) => {
1224
- e.stopPropagation();
1225
- cancelEditing();
1226
- }}
1227
- >
1228
- <X className="w-3 h-3" />
1229
- </div>
1230
- </>
1231
- ) : (
1232
- <>
1233
- {/* Star button */}
1234
- <div
1235
- className={cn(
1236
- "w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 flex items-center justify-center rounded cursor-pointer touch:opacity-100",
1237
- isStarred
1238
- ? "hover:bg-yellow-50 dark:hover:bg-yellow-900/20 opacity-100"
1239
- : "hover:bg-accent"
1240
- )}
1241
- onClick={(e) => {
1242
- e.stopPropagation();
1243
- toggleStarProject(project.name);
1244
- }}
1245
- title={isStarred ? "Remove from favorites" : "Add to favorites"}
1246
- >
1247
- <Star className={cn(
1248
- "w-3 h-3 transition-colors",
1249
- isStarred
1250
- ? "text-yellow-600 dark:text-yellow-400 fill-current"
1251
- : "text-muted-foreground"
1252
- )} />
1253
- </div>
1254
- <div
1255
- className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-accent flex items-center justify-center rounded cursor-pointer touch:opacity-100"
1256
- onClick={(e) => {
1257
- e.stopPropagation();
1258
- startEditing(project);
1259
- }}
1260
- title="Rename project (F2)"
1261
- >
1262
- <Edit3 className="w-3 h-3" />
1263
- </div>
1264
- {getAllSessions(project).length === 0 && (
1265
- <div
1266
- className="w-6 h-6 opacity-0 group-hover:opacity-100 transition-all duration-200 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center justify-center rounded cursor-pointer touch:opacity-100"
1267
- onClick={(e) => {
1268
- e.stopPropagation();
1269
- deleteProject(project.name);
1270
- }}
1271
- title="Delete empty project (Delete)"
1272
- >
1273
- <Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
1274
- </div>
1275
- )}
1276
- {isExpanded ? (
1277
- <ChevronDown className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
1278
- ) : (
1279
- <ChevronRight className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" />
1280
- )}
1281
- </>
1282
- )}
1283
- </div>
1284
- </Button>
1285
- </div>
1286
-
1287
- {/* Sessions List */}
1288
- {isExpanded && (
1289
- <div className="ml-3 space-y-1 border-l border-border pl-3">
1290
- {!initialSessionsLoaded.has(project.name) ? (
1291
- // Loading skeleton for sessions
1292
- Array.from({ length: 3 }).map((_, i) => (
1293
- <div key={i} className="p-2 rounded-md">
1294
- <div className="flex items-start gap-2">
1295
- <div className="w-3 h-3 bg-muted rounded-full animate-pulse mt-0.5" />
1296
- <div className="flex-1 space-y-1">
1297
- <div className="h-3 bg-muted rounded animate-pulse" style={{ width: `${60 + i * 15}%` }} />
1298
- <div className="h-2 bg-muted rounded animate-pulse w-1/2" />
1299
- </div>
1300
- </div>
1301
- </div>
1302
- ))
1303
- ) : getAllSessions(project).length === 0 && !loadingSessions[project.name] ? (
1304
- <div className="py-2 px-3 text-left">
1305
- <p className="text-xs text-muted-foreground">No sessions yet</p>
1306
- </div>
1307
- ) : (
1308
- getAllSessions(project).map((session) => {
1309
- // Handle both Claude and Cursor session formats
1310
- const isCursorSession = session.__provider === 'cursor';
1311
-
1312
- // Calculate if session is active (within last 10 minutes)
1313
- const sessionDate = new Date(isCursorSession ? session.createdAt : session.lastActivity);
1314
- const diffInMinutes = Math.floor((currentTime - sessionDate) / (1000 * 60));
1315
- const isActive = diffInMinutes < 10;
1316
-
1317
- // Get session display values
1318
- const sessionName = isCursorSession ? (session.name || 'Untitled Session') : (session.summary || 'New Session');
1319
- const sessionTime = isCursorSession ? session.createdAt : session.lastActivity;
1320
- const messageCount = session.messageCount || 0;
1321
-
1322
- return (
1323
- <div key={session.id} className="group relative">
1324
- {/* Active session indicator dot */}
1325
- {isActive && (
1326
- <div className="absolute left-0 top-1/2 transform -translate-y-1/2 -translate-x-1">
1327
- <div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
1328
- </div>
1329
- )}
1330
- {/* Mobile Session Item */}
1331
- <div className="md:hidden">
1332
- <div
1333
- className={cn(
1334
- "p-2 mx-3 my-0.5 rounded-md bg-card border active:scale-[0.98] transition-all duration-150 relative",
1335
- selectedSession?.id === session.id ? "bg-primary/5 border-primary/20" :
1336
- isActive ? "border-green-500/30 bg-green-50/5 dark:bg-green-900/5" : "border-border/30"
1337
- )}
1338
- onClick={() => {
1339
- handleProjectSelect(project);
1340
- onSessionSelect(session);
1341
- }}
1342
- onTouchEnd={handleTouchClick(() => {
1343
- handleProjectSelect(project);
1344
- onSessionSelect(session);
1345
- })}
1346
- >
1347
- <div className="flex items-center gap-2">
1348
- <div className={cn(
1349
- "w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0",
1350
- selectedSession?.id === session.id ? "bg-primary/10" : "bg-muted/50"
1351
- )}>
1352
- {isCursorSession ? (
1353
- <CursorLogo className="w-3 h-3" />
1354
- ) : (
1355
- <ClaudeLogo className="w-3 h-3" />
1356
- )}
1357
- </div>
1358
- <div className="min-w-0 flex-1">
1359
- <div className="text-xs font-medium truncate text-foreground">
1360
- {sessionName}
1361
- </div>
1362
- <div className="flex items-center gap-1 mt-0.5">
1363
- <Clock className="w-2.5 h-2.5 text-muted-foreground" />
1364
- <span className="text-xs text-muted-foreground">
1365
- {formatTimeAgo(sessionTime, currentTime)}
1366
- </span>
1367
- {messageCount > 0 && (
1368
- <Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
1369
- {messageCount}
1370
- </Badge>
1371
- )}
1372
- {/* Provider tiny icon */}
1373
- <span className="ml-1 opacity-70">
1374
- {isCursorSession ? (
1375
- <CursorLogo className="w-3 h-3" />
1376
- ) : (
1377
- <ClaudeLogo className="w-3 h-3" />
1378
- )}
1379
- </span>
1380
- </div>
1381
- </div>
1382
- {/* Mobile delete button - only for Claude sessions */}
1383
- {!isCursorSession && (
1384
- <button
1385
- className="w-5 h-5 rounded-md bg-red-50 dark:bg-red-900/20 flex items-center justify-center active:scale-95 transition-transform opacity-70 ml-1"
1386
- onClick={(e) => {
1387
- e.stopPropagation();
1388
- deleteSession(project.name, session.id);
1389
- }}
1390
- onTouchEnd={handleTouchClick(() => deleteSession(project.name, session.id))}
1391
- >
1392
- <Trash2 className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
1393
- </button>
1394
- )}
1395
- </div>
1396
- </div>
1397
- </div>
1398
-
1399
- {/* Desktop Session Item */}
1400
- <div className="hidden md:block">
1401
- <Button
1402
- variant="ghost"
1403
- className={cn(
1404
- "w-full justify-start p-2 h-auto font-normal text-left hover:bg-accent/50 transition-colors duration-200",
1405
- selectedSession?.id === session.id && "bg-accent text-accent-foreground"
1406
- )}
1407
- onClick={() => onSessionSelect(session)}
1408
- onTouchEnd={handleTouchClick(() => onSessionSelect(session))}
1409
- >
1410
- <div className="flex items-start gap-2 min-w-0 w-full">
1411
- {isCursorSession ? (
1412
- <CursorLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
1413
- ) : (
1414
- <ClaudeLogo className="w-3 h-3 mt-0.5 flex-shrink-0" />
1415
- )}
1416
- <div className="min-w-0 flex-1">
1417
- <div className="text-xs font-medium truncate text-foreground">
1418
- {sessionName}
1419
- </div>
1420
- <div className="flex items-center gap-1 mt-0.5">
1421
- <Clock className="w-2.5 h-2.5 text-muted-foreground" />
1422
- <span className="text-xs text-muted-foreground">
1423
- {formatTimeAgo(sessionTime, currentTime)}
1424
- </span>
1425
- {messageCount > 0 && (
1426
- <Badge variant="secondary" className="text-xs px-1 py-0 ml-auto">
1427
- {messageCount}
1428
- </Badge>
1429
- )}
1430
- {/* Provider tiny icon */}
1431
- <span className="ml-1 opacity-70">
1432
- {isCursorSession ? (
1433
- <CursorLogo className="w-3 h-3" />
1434
- ) : (
1435
- <ClaudeLogo className="w-3 h-3" />
1436
- )}
1437
- </span>
1438
- </div>
1439
- </div>
1440
- </div>
1441
- </Button>
1442
- {/* Desktop hover buttons - only for Claude sessions */}
1443
- {!isCursorSession && (
1444
- <div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all duration-200">
1445
- {editingSession === session.id ? (
1446
- <>
1447
- <input
1448
- type="text"
1449
- value={editingSessionName}
1450
- onChange={(e) => setEditingSessionName(e.target.value)}
1451
- onKeyDown={(e) => {
1452
- e.stopPropagation();
1453
- if (e.key === 'Enter') {
1454
- updateSessionSummary(project.name, session.id, editingSessionName);
1455
- } else if (e.key === 'Escape') {
1456
- setEditingSession(null);
1457
- setEditingSessionName('');
1458
- }
1459
- }}
1460
- onClick={(e) => e.stopPropagation()}
1461
- className="w-32 px-2 py-1 text-xs border border-border rounded bg-background focus:outline-none focus:ring-1 focus:ring-primary"
1462
- autoFocus
1463
- />
1464
- <button
1465
- className="w-6 h-6 bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40 rounded flex items-center justify-center"
1466
- onClick={(e) => {
1467
- e.stopPropagation();
1468
- updateSessionSummary(project.name, session.id, editingSessionName);
1469
- }}
1470
- title="Save"
1471
- >
1472
- <Check className="w-3 h-3 text-green-600 dark:text-green-400" />
1473
- </button>
1474
- <button
1475
- className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
1476
- onClick={(e) => {
1477
- e.stopPropagation();
1478
- setEditingSession(null);
1479
- setEditingSessionName('');
1480
- }}
1481
- title="Cancel"
1482
- >
1483
- <X className="w-3 h-3 text-gray-600 dark:text-gray-400" />
1484
- </button>
1485
- </>
1486
- ) : (
1487
- <>
1488
- {/* Generate summary button */}
1489
- {/* <button
1490
- className="w-6 h-6 bg-blue-50 hover:bg-blue-100 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded flex items-center justify-center"
1491
- onClick={(e) => {
1492
- e.stopPropagation();
1493
- generateSessionSummary(project.name, session.id);
1494
- }}
1495
- title="Generate AI summary for this session"
1496
- disabled={generatingSummary[`${project.name}-${session.id}`]}
1497
- >
1498
- {generatingSummary[`${project.name}-${session.id}`] ? (
1499
- <div className="w-3 h-3 animate-spin rounded-full border border-blue-600 dark:border-blue-400 border-t-transparent" />
1500
- ) : (
1501
- <Sparkles className="w-3 h-3 text-blue-600 dark:text-blue-400" />
1502
- )}
1503
- </button> */}
1504
- {/* Edit button */}
1505
- <button
1506
- className="w-6 h-6 bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40 rounded flex items-center justify-center"
1507
- onClick={(e) => {
1508
- e.stopPropagation();
1509
- setEditingSession(session.id);
1510
- setEditingSessionName(session.summary || 'New Session');
1511
- }}
1512
- title="Manually edit session name"
1513
- >
1514
- <Edit2 className="w-3 h-3 text-gray-600 dark:text-gray-400" />
1515
- </button>
1516
- {/* Delete button */}
1517
- <button
1518
- className="w-6 h-6 bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40 rounded flex items-center justify-center"
1519
- onClick={(e) => {
1520
- e.stopPropagation();
1521
- deleteSession(project.name, session.id);
1522
- }}
1523
- title="Delete this session permanently"
1524
- >
1525
- <Trash2 className="w-3 h-3 text-red-600 dark:text-red-400" />
1526
- </button>
1527
- </>
1528
- )}
1529
- </div>
1530
- )}
1531
- </div>
1532
- </div>
1533
- );
1534
- })
1535
- )}
1536
-
1537
- {/* Show More Sessions Button */}
1538
- {getAllSessions(project).length > 0 && project.sessionMeta?.hasMore !== false && (
1539
- <Button
1540
- variant="ghost"
1541
- size="sm"
1542
- className="w-full justify-center gap-2 mt-2 text-muted-foreground"
1543
- onClick={() => loadMoreSessions(project)}
1544
- disabled={loadingSessions[project.name]}
1545
- >
1546
- {loadingSessions[project.name] ? (
1547
- <>
1548
- <div className="w-3 h-3 animate-spin rounded-full border border-muted-foreground border-t-transparent" />
1549
- Loading...
1550
- </>
1551
- ) : (
1552
- <>
1553
- <ChevronDown className="w-3 h-3" />
1554
- Show more sessions
1555
- </>
1556
- )}
1557
- </Button>
1558
- )}
1559
-
1560
- {/* New Session Button */}
1561
- <div className="md:hidden px-3 pb-2">
1562
- <button
1563
- className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
1564
- onClick={() => {
1565
- handleProjectSelect(project);
1566
- onNewSession(project);
1567
- }}
1568
- >
1569
- <Plus className="w-3 h-3" />
1570
- New Session
1571
- </button>
1572
- </div>
1573
-
1574
- <Button
1575
- variant="default"
1576
- size="sm"
1577
- className="hidden md:flex w-full justify-start gap-2 mt-1 h-8 text-xs font-medium bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
1578
- onClick={() => onNewSession(project)}
1579
- >
1580
- <Plus className="w-3 h-3" />
1581
- New Session
1582
- </Button>
1583
- </div>
1584
- )}
1585
- </div>
1586
- );
1587
- })
1588
- )}
1589
- </div>
1590
- </ScrollArea>
1591
-
1592
- {/* Version Update Notification */}
1593
- {updateAvailable && (
1594
- <div className="md:p-2 border-t border-border/50 flex-shrink-0">
1595
- {/* Desktop Version Notification */}
1596
- <div className="hidden md:block">
1597
- <Button
1598
- variant="ghost"
1599
- className="w-full justify-start gap-3 p-3 h-auto font-normal text-left hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors duration-200 border border-blue-200 dark:border-blue-700 rounded-lg mb-2"
1600
- onClick={onShowVersionModal}
1601
- >
1602
- <div className="relative">
1603
- <svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1604
- <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.9M9 19l3 3m0 0l3-3m-3 3V10" />
1605
- </svg>
1606
- <div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
1607
- </div>
1608
- <div className="min-w-0 flex-1">
1609
- <div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
1610
- <div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
1611
- </div>
1612
- </Button>
1613
- </div>
1614
-
1615
- {/* Mobile Version Notification */}
1616
- <div className="md:hidden p-3 pb-2">
1617
- <button
1618
- className="w-full h-12 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-xl flex items-center justify-start gap-3 px-4 active:scale-[0.98] transition-all duration-150"
1619
- onClick={onShowVersionModal}
1620
- >
1621
- <div className="relative">
1622
- <svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1623
- <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.9M9 19l3 3m0 0l3-3m-3 3V10" />
1624
- </svg>
1625
- <div className="absolute -top-1 -right-1 w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
1626
- </div>
1627
- <div className="min-w-0 flex-1 text-left">
1628
- <div className="text-sm font-medium text-blue-700 dark:text-blue-300">Update Available</div>
1629
- <div className="text-xs text-blue-600 dark:text-blue-400">Version {latestVersion} is ready</div>
1630
- </div>
1631
- </button>
1632
- </div>
1633
- </div>
1634
- )}
1635
-
1636
- {/* Settings Section */}
1637
- <div className="md:p-2 md:border-t md:border-border flex-shrink-0">
1638
- {/* Mobile Settings */}
1639
- <div className="md:hidden p-4 pb-20 border-t border-border/50">
1640
- <button
1641
- className="w-full h-14 bg-muted/50 hover:bg-muted/70 rounded-2xl flex items-center justify-start gap-4 px-4 active:scale-[0.98] transition-all duration-150"
1642
- onClick={onShowSettings}
1643
- >
1644
- <div className="w-10 h-10 rounded-2xl bg-background/80 flex items-center justify-center">
1645
- <Settings className="w-5 h-5 text-muted-foreground" />
1646
- </div>
1647
- <span className="text-lg font-medium text-foreground">Settings</span>
1648
- </button>
1649
- </div>
1650
-
1651
- {/* Desktop Settings */}
1652
- <Button
1653
- variant="ghost"
1654
- className="hidden md:flex w-full justify-start gap-2 p-2 h-auto font-normal text-muted-foreground hover:text-foreground hover:bg-accent transition-colors duration-200"
1655
- onClick={onShowSettings}
1656
- >
1657
- <Settings className="w-3 h-3" />
1658
- <span className="text-xs">Settings</span>
1659
- </Button>
1660
- </div>
1661
- </div>
1662
- );
1663
- }
1664
-
1665
- export default Sidebar;