From b9b69ef9c595194a4e92163c87854cee841de66a Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 4 Aug 2025 18:17:40 +0000 Subject: [PATCH] feat: implement lazy loading for task messages - Add WebviewMessage types for requesting task messages with offset/limit - Add ExtensionMessage types for task messages response - Modify ClineProvider to send only initial 50 messages - Implement requestTaskMessages handler in webviewMessageHandler - Update ExtensionStateContext to handle lazy loading responses - Modify ChatView to detect scroll-to-top and request more messages - Add loading indicators and proper state management - Add comprehensive tests for both backend and frontend - Fix pagination edge cases and duplicate filtering This improves performance and memory usage for tasks with many messages by loading messages on-demand as the user scrolls. Fixes #6673 --- src/core/webview/ClineProvider.ts | 12 +- .../__tests__/webviewMessageHandler.spec.ts | 240 ++++++++ src/core/webview/webviewMessageHandler.ts | 47 ++ src/shared/ExtensionMessage.ts | 5 + src/shared/WebviewMessage.ts | 4 + webview-ui/src/components/chat/ChatView.tsx | 44 ++ .../__tests__/ChatView.lazy-loading.spec.tsx | 544 ++++++++++++++++++ .../src/context/ExtensionStateContext.tsx | 38 ++ webview-ui/src/i18n/locales/en/chat.json | 4 +- 9 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/ChatView.lazy-loading.spec.tsx diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ed8f8a27d1..eb91ff4760 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1666,7 +1666,17 @@ export class ClineProvider currentTaskItem: this.getCurrentCline()?.taskId ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId) : undefined, - clineMessages: this.getCurrentCline()?.clineMessages || [], + // Send only initial batch of messages for lazy loading + clineMessages: (() => { + const allMessages = this.getCurrentCline()?.clineMessages || [] + const INITIAL_MESSAGE_COUNT = 50 + // If we have more messages than the initial count, send only the last N messages + if (allMessages.length > INITIAL_MESSAGE_COUNT) { + return allMessages.slice(-INITIAL_MESSAGE_COUNT) + } + return allMessages + })(), + totalClineMessages: this.getCurrentCline()?.clineMessages.length || 0, taskHistory: (taskHistory || []) .filter((item: HistoryItem) => item.ts && item.task) .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 9a1683e464..114caff3a4 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -576,3 +576,243 @@ describe("webviewMessageHandler - message dialog preferences", () => { }) }) }) + +describe("webviewMessageHandler - requestTaskMessages", () => { + beforeEach(() => { + vi.clearAllMocks() + // Mock a current Cline instance with many messages + const mockCline = { + taskId: "test-task-id", + apiConversationHistory: [], + clineMessages: Array.from({ length: 150 }, (_, i) => ({ + ts: i + 1000, + type: "say", + say: "assistant", + text: `Message ${i + 1}`, + partial: false, + })), + } + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any) + }) + + it("should return paginated messages with correct offset and limit", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 0, + limit: 50, + }) + + expect(mockClineProvider.getCurrentCline).toHaveBeenCalled() + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: expect.arrayContaining([ + expect.objectContaining({ text: "Message 101" }), // 150 - 50 + 1 + expect.objectContaining({ text: "Message 102" }), + // ... up to Message 150 + ]), + totalMessages: 150, + hasMore: true, + }) + + // Verify we got exactly 50 messages + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + expect(response.messages).toHaveLength(50) + expect(response.messages[0].text).toBe("Message 101") + expect(response.messages[49].text).toBe("Message 150") + }) + + it("should return older messages when offset is increased", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 50, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: expect.arrayContaining([ + expect.objectContaining({ text: "Message 51" }), // 150 - 50 - 50 + 1 + expect.objectContaining({ text: "Message 52" }), + // ... up to Message 100 + ]), + totalMessages: 150, + hasMore: true, + }) + + // Verify we got exactly 50 messages + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + expect(response.messages).toHaveLength(50) + expect(response.messages[0].text).toBe("Message 51") + expect(response.messages[49].text).toBe("Message 100") + }) + + it("should set hasMore to false when all messages are loaded", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 100, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: expect.arrayContaining([ + expect.objectContaining({ text: "Message 1" }), + expect.objectContaining({ text: "Message 2" }), + // ... up to Message 50 + ]), + totalMessages: 150, + hasMore: false, // No more messages to load + }) + + // Verify we got exactly 50 messages + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + expect(response.messages).toHaveLength(50) + expect(response.messages[0].text).toBe("Message 1") + expect(response.messages[49].text).toBe("Message 50") + }) + + it("should handle partial page at the beginning", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 140, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: expect.arrayContaining([ + expect.objectContaining({ text: "Message 1" }), + expect.objectContaining({ text: "Message 2" }), + // ... up to Message 10 + ]), + totalMessages: 150, + hasMore: false, + }) + + // Verify we got only 10 messages (the remaining ones) + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + expect(response.messages).toHaveLength(10) + expect(response.messages[0].text).toBe("Message 1") + expect(response.messages[9].text).toBe("Message 10") + }) + + it("should handle task with fewer messages than limit", async () => { + // Mock a current Cline with only 30 messages + const mockCline = { + taskId: "test-task-id", + apiConversationHistory: [], + clineMessages: Array.from({ length: 30 }, (_, i) => ({ + ts: i + 1000, + type: "say", + say: "assistant", + text: `Message ${i + 1}`, + partial: false, + })), + } + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 0, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: expect.arrayContaining([ + expect.objectContaining({ text: "Message 1" }), + expect.objectContaining({ text: "Message 30" }), + ]), + totalMessages: 30, + hasMore: false, // All messages loaded in first request + }) + + // Verify we got all 30 messages + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + expect(response.messages).toHaveLength(30) + }) + + it("should handle no current Cline instance", async () => { + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(undefined) + + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 0, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: [], + totalMessages: 0, + hasMore: false, + }) + }) + + it("should handle Cline with no messages", async () => { + const mockCline = { + taskId: "test-task-id", + apiConversationHistory: [], + clineMessages: [], + } + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 0, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: [], + totalMessages: 0, + hasMore: false, + }) + }) + + it("should handle offset beyond message count", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 200, // Beyond 150 messages + limit: 50, + }) + + const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0] + const response = call?.[0] as any + + expect(response).toEqual({ + type: "taskMessagesResponse", + messages: [], + totalMessages: 150, + hasMore: false, + }) + }) + + it("should handle undefined clineMessages", async () => { + const mockCline = { + taskId: "test-task-id", + apiConversationHistory: [], + clineMessages: undefined, + } + vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any) + + await webviewMessageHandler(mockClineProvider, { + type: "requestTaskMessages", + offset: 0, + limit: 50, + }) + + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "taskMessagesResponse", + messages: [], + totalMessages: 0, + hasMore: false, + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index fdb7e90425..512e216d59 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2568,5 +2568,52 @@ export const webviewMessageHandler = async ( } break } + case "requestTaskMessages": { + // Handle lazy loading of task messages + const currentCline = provider.getCurrentCline() + if (!currentCline) { + // No active task, send empty response + await provider.postMessageToWebview({ + type: "taskMessagesResponse", + messages: [], + totalMessages: 0, + hasMore: false, + }) + break + } + + const allMessages = currentCline.clineMessages || [] + const offset = message.offset || 0 + const limit = message.limit || 50 + + // Calculate the range of messages to send + // We want to get messages from the end, working backwards + const totalMessages = allMessages.length + + // If offset is beyond the total messages, return empty array + if (offset >= totalMessages) { + await provider.postMessageToWebview({ + type: "taskMessagesResponse", + messages: [], + totalMessages: totalMessages, + hasMore: false, + }) + break + } + + const startIndex = Math.max(0, totalMessages - offset - limit) + const endIndex = totalMessages - offset + + const messages = allMessages.slice(startIndex, endIndex) + const hasMore = startIndex > 0 + + await provider.postMessageToWebview({ + type: "taskMessagesResponse", + messages: messages, + totalMessages: totalMessages, + hasMore: hasMore, + }) + break + } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 930edeac73..22c06cab24 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -120,6 +120,7 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "taskMessagesResponse" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -194,6 +195,10 @@ export interface ExtensionMessage { messageTs?: number context?: string commands?: Command[] + // For lazy loading messages + messages?: ClineMessage[] + totalMessages?: number + hasMore?: boolean } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index cb8759d851..3a9e9e9113 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -210,6 +210,7 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "requestTaskMessages" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" @@ -272,6 +273,9 @@ export interface WebviewMessage { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string } + // For lazy loading messages + offset?: number + limit?: number } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 1fe93eb470..1f6ed2559c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -117,6 +117,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const userRespondedRef = useRef(false) const [currentFollowUpTs, setCurrentFollowUpTs] = useState(null) + const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false) + const loadingMoreRef = useRef(false) const clineAskRef = useRef(clineAsk) useEffect(() => { @@ -866,6 +870,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Load more messages when scrolled to top + if (hasMoreMessages && !loadingMoreRef.current && messages.length > 0) { + loadingMoreRef.current = true + setIsLoadingMoreMessages(true) + + // Request more messages from the extension + const offset = messages.length + const limit = 50 + vscode.postMessage({ + type: "requestTaskMessages", + offset: offset, + limit: limit, + }) + + // Reset loading state after a timeout to prevent stuck state + setTimeout(() => { + loadingMoreRef.current = false + setIsLoadingMoreMessages(false) + }, 5000) + } + }} + components={{ + Header: () => + isLoadingMoreMessages ? ( +
+ + {t("chat:loadingMoreMessages")} +
+ ) : hasMoreMessages ? ( +
+ {t("chat:scrollUpForMore")} +
+ ) : null, + }} />
diff --git a/webview-ui/src/components/chat/__tests__/ChatView.lazy-loading.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.lazy-loading.spec.tsx new file mode 100644 index 0000000000..fd33462084 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.lazy-loading.spec.tsx @@ -0,0 +1,544 @@ +// npx vitest run src/components/chat/__tests__/ChatView.lazy-loading.spec.tsx + +import React from "react" +import { render, waitFor, fireEvent } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import ChatView, { ChatViewProps } from "../ChatView" + +// Define minimal types needed for testing +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +interface ExtensionState { + version: string + clineMessages: ClineMessage[] + totalClineMessages?: number + taskHistory: any[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + [key: string]: any +} + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound hook +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => { + return [vi.fn()] + }), +})) + +// Mock components that use ESM dependencies +vi.mock("../BrowserSessionRow", () => ({ + default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { + return
{JSON.stringify(messages)}
+ }, +})) + +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: ClineMessage }) { + return
{JSON.stringify(message)}
+ }, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +vi.mock("../../common/VersionIndicator", () => ({ + default: vi.fn(() => null), +})) + +vi.mock("../Announcement", () => ({ + default: function MockAnnouncement() { + return null + }, +})) + +vi.mock("@src/components/welcome/RooCloudCTA", () => ({ + default: function MockRooCloudCTA() { + return null + }, +})) + +vi.mock("../QueuedMessages", () => ({ + default: function MockQueuedMessages() { + return null + }, +})) + +vi.mock("@src/components/welcome/RooTips", () => ({ + default: function MockRooTips() { + return null + }, +})) + +vi.mock("@src/components/welcome/RooHero", () => ({ + default: function MockRooHero() { + return null + }, +})) + +vi.mock("../common/TelemetryBanner", () => ({ + default: function MockTelemetryBanner() { + return null + }, +})) + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ i18nKey }: { i18nKey: string }) => { + return <>{i18nKey} + }, +})) + +// Mock ChatTextArea +vi.mock("../ChatTextArea", () => ({ + default: React.forwardRef(function MockChatTextArea(_props: any, ref: React.ForwardedRef<{ focus: () => void }>) { + React.useImperativeHandle(ref, () => ({ + focus: vi.fn(), + })) + + return
+ }), +})) + +// Mock VSCode components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: function MockVSCodeButton({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) { + return + }, + VSCodeTextField: function MockVSCodeTextField() { + return + }, + VSCodeLink: function MockVSCodeLink({ children }: { children: React.ReactNode }) { + return {children} + }, +})) + +// Mock react-virtuoso to simulate scroll events +vi.mock("react-virtuoso", () => ({ + Virtuoso: function MockVirtuoso({ + data, + itemContent, + startReached, + components, + }: { + data: any[] + itemContent: (index: number, item: any) => React.ReactNode + startReached?: () => void + components?: any + }) { + const handleScroll = (e: React.UIEvent) => { + const element = e.currentTarget + // Simulate reaching the top when scrollTop is 0 + if (element.scrollTop === 0 && startReached) { + startReached() + } + } + + return ( +
+ {components?.Header &&
{components.Header()}
} + {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }, +})) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: Partial) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) +} + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const renderChatView = (props: Partial = {}) => { + return render( + + + + + , + ) +} + +describe("ChatView - Lazy Loading Tests", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("initially loads only the last 50 messages", async () => { + const { getAllByTestId } = renderChatView() + + // Create 101 messages (first one is the task message) + const messages = Array.from({ length: 101 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 1000, + text: `Message ${i + 1}`, + partial: false, + })) + + // Hydrate state with last 51 messages (first is task, next 50 are chat messages) + mockPostMessage({ + clineMessages: messages.slice(-51), // Last 51 messages (1 task + 50 chat) + totalClineMessages: 101, + }) + + // Wait for messages to render + await waitFor(() => { + const renderedMessages = getAllByTestId(/^message-/) + expect(renderedMessages).toHaveLength(50) + // Should show messages 52-101 (check the JSON contains the text) + expect(renderedMessages[0]).toHaveTextContent('"text":"Message 52"') + expect(renderedMessages[49]).toHaveTextContent('"text":"Message 101"') + }) + }) + + it("shows loading indicator when fetching more messages", async () => { + const { getByTestId, queryByTestId } = renderChatView() + + // Initial state with 51 messages (1 task + 50 chat) + const initialMessages = Array.from({ length: 51 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 51000, + text: `Message ${i + 51}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: initialMessages, + totalClineMessages: 101, + }) + + // Wait for initial render + await waitFor(() => { + expect(getByTestId("virtuoso-container")).toBeInTheDocument() + }) + + // Clear previous calls + vi.mocked(vscode.postMessage).mockClear() + + // Simulate scrolling to top + const container = getByTestId("virtuoso-container") + fireEvent.scroll(container, { target: { scrollTop: 0 } }) + + // Should request more messages + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestTaskMessages", + offset: 51, + limit: 50, + }) + }) + + // Check for loading indicator in header + expect(queryByTestId("virtuoso-header")).toBeInTheDocument() + }) + + it("appends older messages when received", async () => { + const { getAllByTestId } = renderChatView() + + // Initial state with messages 51-101 (1 task + 50 chat) + const initialMessages = Array.from({ length: 51 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: (i + 50) * 1000 + 1000, + text: `Message ${i + 51}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: initialMessages, + totalClineMessages: 101, + }) + + // Wait for initial render + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(50) + }) + + // Simulate receiving older messages (2-51) - remember first message is task + const olderMessages = Array.from({ length: 50 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 2000, + text: `Message ${i + 2}`, + partial: false, + })) + + // Send taskMessagesResponse + window.postMessage( + { + type: "taskMessagesResponse", + messages: olderMessages, + totalMessages: 100, + hasMore: false, + }, + "*", + ) + + // Wait for all messages to render + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(100) + // Verify we have 100 messages total (excluding the task message) + }) + }) + + it("does not request more messages when hasMore is false", async () => { + const { getByTestId } = renderChatView() + + // Initial state with all messages loaded (1 task + 50 chat) + const messages = Array.from({ length: 51 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 1000, + text: `Message ${i + 1}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: messages, + totalClineMessages: 51, + }) + + // Wait for initial render + await waitFor(() => { + expect(getByTestId("virtuoso-container")).toBeInTheDocument() + }) + + // Set hasMore to false in state + window.postMessage( + { + type: "taskMessagesResponse", + messages: [], + totalMessages: 51, + hasMore: false, + }, + "*", + ) + + // Clear previous calls + vi.mocked(vscode.postMessage).mockClear() + + // Simulate scrolling to top + const container = getByTestId("virtuoso-container") + fireEvent.scroll(container, { target: { scrollTop: 0 } }) + + // Should NOT request more messages + await waitFor(() => { + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestTaskMessages", + }), + ) + }) + }) + + it("prevents duplicate requests while loading", async () => { + const { getByTestId } = renderChatView() + + // Initial state (1 task + 50 chat) + const initialMessages = Array.from({ length: 51 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 51000, + text: `Message ${i + 51}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: initialMessages, + totalClineMessages: 101, + }) + + // Wait for initial render + await waitFor(() => { + expect(getByTestId("virtuoso-container")).toBeInTheDocument() + }) + + // Clear previous calls + vi.mocked(vscode.postMessage).mockClear() + + // Simulate multiple rapid scroll events + const container = getByTestId("virtuoso-container") + fireEvent.scroll(container, { target: { scrollTop: 0 } }) + fireEvent.scroll(container, { target: { scrollTop: 0 } }) + fireEvent.scroll(container, { target: { scrollTop: 0 } }) + + // Should only send one request + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestTaskMessages", + offset: 51, + limit: 50, + }) + }) + }) + + it("handles empty message responses gracefully", async () => { + const { getAllByTestId } = renderChatView() + + // Initial state (1 task + 50 chat) + const initialMessages = Array.from({ length: 51 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i + 1000, + text: `Message ${i + 1}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: initialMessages, + totalClineMessages: 51, + }) + + // Wait for initial render + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(50) + }) + + // Send empty response + window.postMessage( + { + type: "taskMessagesResponse", + messages: [], + totalMessages: 51, + hasMore: false, + }, + "*", + ) + + // Should still show the same messages + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(50) + }) + }) + + it("filters out duplicate messages by timestamp", async () => { + const { getAllByTestId } = renderChatView() + + // Initial state with messages (1 task + 30 chat) + const initialMessages = Array.from({ length: 31 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: (i + 20) * 1000, + text: `Message ${i + 21}`, + partial: false, + })) + + mockPostMessage({ + clineMessages: initialMessages, + totalClineMessages: 51, + }) + + // Wait for initial render + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(30) + }) + + // Send response with some new messages and duplicates + const newMessages = Array.from({ length: 20 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: i * 1000, + text: `Message ${i + 1}`, + partial: false, + })) + + // Include some duplicates (messages 21-25 which already exist) + const duplicateMessages = Array.from({ length: 5 }, (_, i) => ({ + type: "say" as const, + say: "assistant" as const, + ts: (i + 20) * 1000, + text: `Message ${i + 21}`, + partial: false, + })) + + window.postMessage( + { + type: "taskMessagesResponse", + messages: [...newMessages, ...duplicateMessages], + totalMessages: 51, + hasMore: false, + }, + "*", + ) + + // Should filter out duplicates and show 50 unique messages + await waitFor(() => { + const messages = getAllByTestId(/^message-/) + expect(messages).toHaveLength(50) + // We should have 50 unique messages (20 new + 30 existing) + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index da7ab63358..3602369205 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -38,6 +38,8 @@ export interface ExtensionStateContextType extends ExtensionState { organizationSettingsVersion: number cloudIsAuthenticated: boolean sharingEnabled: boolean + totalClineMessages?: number + hasMoreMessages?: boolean maxConcurrentFileReads?: number mdmCompliant?: boolean hasOpenedModeSelector: boolean // New property to track if user has opened mode selector @@ -266,6 +268,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode global: {}, }) const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(false) + const [totalClineMessages, setTotalClineMessages] = useState(0) + const [hasMoreMessages, setHasMoreMessages] = useState(false) const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -310,6 +314,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode if (newState.marketplaceInstalledMetadata !== undefined) { setMarketplaceInstalledMetadata(newState.marketplaceInstalledMetadata) } + // Handle totalClineMessages if present + if ((newState as any).totalClineMessages !== undefined) { + setTotalClineMessages((newState as any).totalClineMessages) + // If we have more messages than what's loaded, we can load more + const loadedMessages = newState.clineMessages?.length || 0 + setHasMoreMessages((newState as any).totalClineMessages > loadedMessages) + } break } case "theme": { @@ -369,6 +380,31 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode } break } + case "taskMessagesResponse": { + // Handle lazy loading response - append older messages to the beginning + if (message.messages && message.messages.length > 0) { + setState((prevState) => { + // Filter out any messages that might already exist (by timestamp) + const newMessages = message.messages!.filter( + (msg: any) => !prevState.clineMessages.some((existing) => existing.ts === msg.ts), + ) + + // Prepend new messages to the beginning (they are older messages) + return { + ...prevState, + clineMessages: [...newMessages, ...prevState.clineMessages], + } + }) + } + // Update total messages and hasMore flag + if (message.totalMessages !== undefined) { + setTotalClineMessages(message.totalMessages) + } + if (message.hasMore !== undefined) { + setHasMoreMessages(message.hasMore) + } + break + } } }, [setListApiConfigMeta], @@ -408,6 +444,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode profileThresholds: state.profileThresholds ?? {}, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, + totalClineMessages, + hasMoreMessages, setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration, diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 07bcd770d7..294e9c9759 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -377,5 +377,7 @@ "queuedMessages": { "title": "Queued Messages:", "clickToEdit": "Click to edit message" - } + }, + "loadingMoreMessages": "Loading more messages...", + "scrollUpForMore": "Scroll up to load more messages" }