From 4e5aa50505999be8955cf5c2f7902c5dfb545923 Mon Sep 17 00:00:00 2001 From: simos Date: Tue, 12 Aug 2025 12:10:23 +0300 Subject: [PATCH] feat: Add pagination support for session messages and enhance loading logic in ChatInterface --- package.json | 2 +- server/index.js | 18 ++++- server/projects.js | 32 +++++++-- server/routes/cursor.js | 47 ++++++++++++- src/components/ChatInterface.jsx | 116 +++++++++++++++++++++++++++---- src/utils/api.js | 12 +++- 6 files changed, 202 insertions(+), 25 deletions(-) diff --git a/package.json b/package.json index 58df385..ea084d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-ui", - "version": "1.6.0", + "version": "1.6.1", "description": "A web-based UI for Claude Code CLI", "type": "module", "main": "server/index.js", diff --git a/server/index.js b/server/index.js index eca0f98..727a5ce 100755 --- a/server/index.js +++ b/server/index.js @@ -219,8 +219,22 @@ app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, re app.get('/api/projects/:projectName/sessions/:sessionId/messages', authenticateToken, async (req, res) => { try { const { projectName, sessionId } = req.params; - const messages = await getSessionMessages(projectName, sessionId); - res.json({ messages }); + const { limit, offset } = req.query; + + // Parse limit and offset if provided + const parsedLimit = limit ? parseInt(limit, 10) : null; + const parsedOffset = offset ? parseInt(offset, 10) : 0; + + const result = await getSessionMessages(projectName, sessionId, parsedLimit, parsedOffset); + + // Handle both old and new response formats + if (Array.isArray(result)) { + // Backward compatibility: no pagination parameters were provided + res.json({ messages: result }); + } else { + // New format with pagination info + res.json(result); + } } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/server/projects.js b/server/projects.js index 23ee462..b7189d3 100755 --- a/server/projects.js +++ b/server/projects.js @@ -385,8 +385,8 @@ async function parseJsonlSessions(filePath) { ); } -// Get messages for a specific session -async function getSessionMessages(projectName, sessionId) { +// Get messages for a specific session with pagination support +async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName); try { @@ -394,7 +394,7 @@ async function getSessionMessages(projectName, sessionId) { const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); if (jsonlFiles.length === 0) { - return []; + return { messages: [], total: 0, hasMore: false }; } const messages = []; @@ -423,12 +423,34 @@ async function getSessionMessages(projectName, sessionId) { } // Sort messages by timestamp - return messages.sort((a, b) => + const sortedMessages = messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0) ); + + const total = sortedMessages.length; + + // If no limit is specified, return all messages (backward compatibility) + if (limit === null) { + return sortedMessages; + } + + // Apply pagination - for recent messages, we need to slice from the end + // offset 0 should give us the most recent messages + const startIndex = Math.max(0, total - offset - limit); + const endIndex = total - offset; + const paginatedMessages = sortedMessages.slice(startIndex, endIndex); + const hasMore = startIndex > 0; + + return { + messages: paginatedMessages, + total, + hasMore, + offset, + limit + }; } catch (error) { console.error(`Error reading messages for session ${sessionId}:`, error); - return []; + return limit === null ? [] : { messages: [], total: 0, hasMore: false }; } } diff --git a/server/routes/cursor.js b/server/routes/cursor.js index 6950f4a..61d201e 100644 --- a/server/routes/cursor.js +++ b/server/routes/cursor.js @@ -373,11 +373,18 @@ router.get('/sessions', async (req, res) => { for (const sessionId of sessionDirs) { const sessionPath = path.join(cursorChatsPath, sessionId); const storeDbPath = path.join(sessionPath, 'store.db'); + let dbStatMtimeMs = null; try { // Check if store.db exists await fs.access(storeDbPath); + // Capture store.db mtime as a reliable fallback timestamp (last activity) + try { + const stat = await fs.stat(storeDbPath); + dbStatMtimeMs = stat.mtimeMs; + } catch (_) {} + // Open SQLite database const db = await open({ filename: storeDbPath, @@ -412,7 +419,26 @@ router.get('/sessions', async (req, res) => { if (row.key === 'agent') { sessionData.name = data.name || sessionData.name; - sessionData.createdAt = data.createdAt; + // Normalize createdAt to ISO string in milliseconds + let createdAt = data.createdAt; + if (typeof createdAt === 'number') { + if (createdAt < 1e12) { + createdAt = createdAt * 1000; // seconds -> ms + } + sessionData.createdAt = new Date(createdAt).toISOString(); + } else if (typeof createdAt === 'string') { + const n = Number(createdAt); + if (!Number.isNaN(n)) { + const ms = n < 1e12 ? n * 1000 : n; + sessionData.createdAt = new Date(ms).toISOString(); + } else { + // Assume it's already an ISO/date string + const d = new Date(createdAt); + sessionData.createdAt = isNaN(d.getTime()) ? null : d.toISOString(); + } + } else { + sessionData.createdAt = sessionData.createdAt || null; + } sessionData.mode = data.mode; sessionData.agentId = data.agentId; sessionData.latestRootBlobId = data.latestRootBlobId; @@ -497,6 +523,13 @@ router.get('/sessions', async (req, res) => { } await db.close(); + + // Finalize createdAt: use parsed meta value when valid, else fall back to store.db mtime + if (!sessionData.createdAt) { + if (dbStatMtimeMs && Number.isFinite(dbStatMtimeMs)) { + sessionData.createdAt = new Date(dbStatMtimeMs).toISOString(); + } + } sessions.push(sessionData); @@ -505,6 +538,18 @@ router.get('/sessions', async (req, res) => { } } + // Fallback: ensure createdAt is a valid ISO string (use session directory mtime as last resort) + for (const s of sessions) { + if (!s.createdAt) { + try { + const sessionDir = path.join(cursorChatsPath, s.id); + const st = await fs.stat(sessionDir); + s.createdAt = new Date(st.mtimeMs).toISOString(); + } catch { + s.createdAt = new Date().toISOString(); + } + } + } // Sort sessions by creation date (newest first) sessions.sort((a, b) => { if (!a.createdAt) return 1; diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index a7587b0..85fcbc0 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1122,6 +1122,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess const [isInputFocused, setIsInputFocused] = useState(false); const [sessionMessages, setSessionMessages] = useState([]); const [isLoadingSessionMessages, setIsLoadingSessionMessages] = useState(false); + const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false); + const [messagesOffset, setMessagesOffset] = useState(0); + const [hasMoreMessages, setHasMoreMessages] = useState(false); + const [totalMessages, setTotalMessages] = useState(0); + const MESSAGES_PER_PAGE = 20; const [isSystemSessionChange, setIsSystemSessionChange] = useState(false); const [permissionMode, setPermissionMode] = useState('default'); const [attachedImages, setAttachedImages] = useState([]); @@ -1211,25 +1216,49 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess }; }, []); - // Load session messages from API - const loadSessionMessages = useCallback(async (projectName, sessionId) => { + // Load session messages from API with pagination + const loadSessionMessages = useCallback(async (projectName, sessionId, loadMore = false) => { if (!projectName || !sessionId) return []; - setIsLoadingSessionMessages(true); + const isInitialLoad = !loadMore; + if (isInitialLoad) { + setIsLoadingSessionMessages(true); + } else { + setIsLoadingMoreMessages(true); + } + try { - const response = await api.sessionMessages(projectName, sessionId); + const currentOffset = loadMore ? messagesOffset : 0; + const response = await api.sessionMessages(projectName, sessionId, MESSAGES_PER_PAGE, currentOffset); if (!response.ok) { throw new Error('Failed to load session messages'); } const data = await response.json(); - return data.messages || []; + + // Handle paginated response + if (data.hasMore !== undefined) { + setHasMoreMessages(data.hasMore); + setTotalMessages(data.total); + setMessagesOffset(currentOffset + (data.messages?.length || 0)); + return data.messages || []; + } else { + // Backward compatibility for non-paginated response + const messages = data.messages || []; + setHasMoreMessages(false); + setTotalMessages(messages.length); + return messages; + } } catch (error) { console.error('Error loading session messages:', error); return []; } finally { - setIsLoadingSessionMessages(false); + if (isInitialLoad) { + setIsLoadingSessionMessages(false); + } else { + setIsLoadingMoreMessages(false); + } } - }, []); + }, [messagesOffset]); // Load Cursor session messages from SQLite via backend const loadCursorSessionMessages = useCallback(async (projectPath, sessionId) => { @@ -1475,13 +1504,41 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess return scrollHeight - scrollTop - clientHeight < 50; }, []); - // Handle scroll events to detect when user manually scrolls up - const handleScroll = useCallback(() => { + // Handle scroll events to detect when user manually scrolls up and load more messages + const handleScroll = useCallback(async () => { if (scrollContainerRef.current) { + const container = scrollContainerRef.current; const nearBottom = isNearBottom(); setIsUserScrolledUp(!nearBottom); + + // Check if we should load more messages (scrolled near top) + const scrolledNearTop = container.scrollTop < 100; + const provider = localStorage.getItem('selected-provider') || 'claude'; + + if (scrolledNearTop && hasMoreMessages && !isLoadingMoreMessages && selectedSession && selectedProject && provider !== 'cursor') { + // Save current scroll position + const previousScrollHeight = container.scrollHeight; + const previousScrollTop = container.scrollTop; + + // Load more messages + const moreMessages = await loadSessionMessages(selectedProject.name, selectedSession.id, true); + + if (moreMessages.length > 0) { + // Prepend new messages to the existing ones + setSessionMessages(prev => [...moreMessages, ...prev]); + + // Restore scroll position after DOM update + setTimeout(() => { + if (scrollContainerRef.current) { + const newScrollHeight = scrollContainerRef.current.scrollHeight; + const scrollDiff = newScrollHeight - previousScrollHeight; + scrollContainerRef.current.scrollTop = previousScrollTop + scrollDiff; + } + }, 0); + } + } } - }, [isNearBottom]); + }, [isNearBottom, hasMoreMessages, isLoadingMoreMessages, selectedSession, selectedProject, loadSessionMessages]); useEffect(() => { // Load session messages when session changes @@ -1489,6 +1546,11 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess if (selectedSession && selectedProject) { const provider = localStorage.getItem('selected-provider') || 'claude'; + // Reset pagination state when switching sessions + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); + if (provider === 'cursor') { // For Cursor, set the session ID for resuming setCurrentSessionId(selectedSession.id); @@ -1500,13 +1562,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setSessionMessages([]); setChatMessages(converted); } else { - // For Claude, load messages normally + // For Claude, load messages normally with pagination setCurrentSessionId(selectedSession.id); // Only load messages from API if this is a user-initiated session change // For system-initiated changes, preserve existing messages and rely on WebSocket if (!isSystemSessionChange) { - const messages = await loadSessionMessages(selectedProject.name, selectedSession.id); + const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false); setSessionMessages(messages); // convertedMessages will be automatically updated via useMemo // Scroll to bottom after loading session messages if auto-scroll is enabled @@ -1523,11 +1585,14 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess setSessionMessages([]); setCurrentSessionId(null); sessionStorage.removeItem('cursorSessionId'); + setMessagesOffset(0); + setHasMoreMessages(false); + setTotalMessages(0); } }; loadMessages(); - }, [selectedSession, selectedProject, loadSessionMessages, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); + }, [selectedSession, selectedProject, loadCursorSessionMessages, scrollToBottom, isSystemSessionChange]); // Update chatMessages when convertedMessages changes useEffect(() => { @@ -2566,7 +2631,30 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess ) : ( <> - {chatMessages.length > visibleMessageCount && ( + {/* Loading indicator for older messages */} + {isLoadingMoreMessages && ( +
+
+
+

Loading older messages...

+
+
+ )} + + {/* Indicator showing there are more messages to load */} + {hasMoreMessages && !isLoadingMoreMessages && ( +
+ {totalMessages > 0 && ( + + Showing {sessionMessages.length} of {totalMessages} messages • + Scroll up to load more + + )} +
+ )} + + {/* Legacy message count indicator (for non-paginated view) */} + {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
Showing last {visibleMessageCount} messages ({chatMessages.length} total) •