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:
Roo Code 2025-08-04 18:17:40 +00:00
parent 4e8b17486b
commit b9b69ef9c5
9 changed files with 936 additions and 2 deletions

View file

@ -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),

View file

@ -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,
})
})
})

View file

@ -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
}
}
}

View file

@ -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<

View file

@ -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({

View file

@ -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" : ""}`}>

View file

@ -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)
})
})
})

View file

@ -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,

View file

@ -377,5 +377,7 @@
"queuedMessages": {
"title": "Queued Messages:",
"clickToEdit": "Click to edit message"
}
},
"loadingMoreMessages": "Loading more messages...",
"scrollUpForMore": "Scroll up to load more messages"
}