mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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
This commit is contained in:
parent
4e8b17486b
commit
b9b69ef9c5
9 changed files with 936 additions and 2 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
soundEnabled,
|
||||
soundVolume,
|
||||
cloudIsAuthenticated,
|
||||
totalClineMessages: _totalClineMessages,
|
||||
hasMoreMessages,
|
||||
} = useExtensionState()
|
||||
|
||||
const messagesRef = useRef(messages)
|
||||
|
|
@ -188,6 +190,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const userRespondedRef = useRef<boolean>(false)
|
||||
const [currentFollowUpTs, setCurrentFollowUpTs] = useState<number | null>(null)
|
||||
const [isLoadingMoreMessages, setIsLoadingMoreMessages] = useState(false)
|
||||
const loadingMoreRef = useRef(false)
|
||||
|
||||
const clineAskRef = useRef(clineAsk)
|
||||
useEffect(() => {
|
||||
|
|
@ -866,6 +870,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
setIsCondensing(false)
|
||||
}
|
||||
break
|
||||
case "taskMessagesResponse":
|
||||
// Reset loading state when messages are received
|
||||
loadingMoreRef.current = false
|
||||
setIsLoadingMoreMessages(false)
|
||||
break
|
||||
}
|
||||
// textAreaRef.current is not explicitly required here since React
|
||||
// guarantees that ref will be stable across re-renders, and we're
|
||||
|
|
@ -1857,6 +1866,41 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
|
|||
}}
|
||||
atBottomThreshold={10} // anything lower causes issues with followOutput
|
||||
initialTopMostItemIndex={groupedMessages.length - 1}
|
||||
startReached={() => {
|
||||
// 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 ? (
|
||||
<div className="flex justify-center items-center py-2 text-vscode-descriptionForeground">
|
||||
<span className="codicon codicon-loading codicon-modifier-spin mr-2"></span>
|
||||
{t("chat:loadingMoreMessages")}
|
||||
</div>
|
||||
) : hasMoreMessages ? (
|
||||
<div className="flex justify-center items-center py-2 text-vscode-descriptionForeground text-xs">
|
||||
{t("chat:scrollUpForMore")}
|
||||
</div>
|
||||
) : null,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`flex-initial min-h-0 ${!areButtonsVisible ? "mb-1" : ""}`}>
|
||||
|
|
|
|||
|
|
@ -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 <div data-testid="browser-session">{JSON.stringify(messages)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("../ChatRow", () => ({
|
||||
default: function MockChatRow({ message }: { message: ClineMessage }) {
|
||||
return <div data-testid="chat-row">{JSON.stringify(message)}</div>
|
||||
},
|
||||
}))
|
||||
|
||||
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 <div data-testid="chat-textarea" />
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock VSCode components
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeButton: function MockVSCodeButton({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return <button onClick={onClick}>{children}</button>
|
||||
},
|
||||
VSCodeTextField: function MockVSCodeTextField() {
|
||||
return <input type="text" />
|
||||
},
|
||||
VSCodeLink: function MockVSCodeLink({ children }: { children: React.ReactNode }) {
|
||||
return <a>{children}</a>
|
||||
},
|
||||
}))
|
||||
|
||||
// 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<HTMLDivElement>) => {
|
||||
const element = e.currentTarget
|
||||
// Simulate reaching the top when scrollTop is 0
|
||||
if (element.scrollTop === 0 && startReached) {
|
||||
startReached()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="virtuoso-container" onScroll={handleScroll} style={{ height: "400px", overflow: "auto" }}>
|
||||
{components?.Header && <div data-testid="virtuoso-header">{components.Header()}</div>}
|
||||
{data.map((item, index) => (
|
||||
<div key={index} data-testid={`message-${index}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
// Mock window.postMessage to trigger state hydration
|
||||
const mockPostMessage = (state: Partial<ExtensionState>) => {
|
||||
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<ChatViewProps> = {}) => {
|
||||
return render(
|
||||
<ExtensionStateContextProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ChatView {...defaultProps} {...props} />
|
||||
</QueryClientProvider>
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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<number>(0)
|
||||
const [hasMoreMessages, setHasMoreMessages] = useState<boolean>(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,
|
||||
|
|
|
|||
|
|
@ -377,5 +377,7 @@
|
|||
"queuedMessages": {
|
||||
"title": "Queued Messages:",
|
||||
"clickToEdit": "Click to edit message"
|
||||
}
|
||||
},
|
||||
"loadingMoreMessages": "Loading more messages...",
|
||||
"scrollUpForMore": "Scroll up to load more messages"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue