Skip to content

Commit b9b69ef

Browse files
committed
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
1 parent 4e8b174 commit b9b69ef

File tree

9 files changed

+936
-2
lines changed

9 files changed

+936
-2
lines changed

src/core/webview/ClineProvider.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1666,7 +1666,17 @@ export class ClineProvider
16661666
currentTaskItem: this.getCurrentCline()?.taskId
16671667
? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentCline()?.taskId)
16681668
: undefined,
1669-
clineMessages: this.getCurrentCline()?.clineMessages || [],
1669+
// Send only initial batch of messages for lazy loading
1670+
clineMessages: (() => {
1671+
const allMessages = this.getCurrentCline()?.clineMessages || []
1672+
const INITIAL_MESSAGE_COUNT = 50
1673+
// If we have more messages than the initial count, send only the last N messages
1674+
if (allMessages.length > INITIAL_MESSAGE_COUNT) {
1675+
return allMessages.slice(-INITIAL_MESSAGE_COUNT)
1676+
}
1677+
return allMessages
1678+
})(),
1679+
totalClineMessages: this.getCurrentCline()?.clineMessages.length || 0,
16701680
taskHistory: (taskHistory || [])
16711681
.filter((item: HistoryItem) => item.ts && item.task)
16721682
.sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts),

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,3 +576,243 @@ describe("webviewMessageHandler - message dialog preferences", () => {
576576
})
577577
})
578578
})
579+
580+
describe("webviewMessageHandler - requestTaskMessages", () => {
581+
beforeEach(() => {
582+
vi.clearAllMocks()
583+
// Mock a current Cline instance with many messages
584+
const mockCline = {
585+
taskId: "test-task-id",
586+
apiConversationHistory: [],
587+
clineMessages: Array.from({ length: 150 }, (_, i) => ({
588+
ts: i + 1000,
589+
type: "say",
590+
say: "assistant",
591+
text: `Message ${i + 1}`,
592+
partial: false,
593+
})),
594+
}
595+
vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any)
596+
})
597+
598+
it("should return paginated messages with correct offset and limit", async () => {
599+
await webviewMessageHandler(mockClineProvider, {
600+
type: "requestTaskMessages",
601+
offset: 0,
602+
limit: 50,
603+
})
604+
605+
expect(mockClineProvider.getCurrentCline).toHaveBeenCalled()
606+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
607+
type: "taskMessagesResponse",
608+
messages: expect.arrayContaining([
609+
expect.objectContaining({ text: "Message 101" }), // 150 - 50 + 1
610+
expect.objectContaining({ text: "Message 102" }),
611+
// ... up to Message 150
612+
]),
613+
totalMessages: 150,
614+
hasMore: true,
615+
})
616+
617+
// Verify we got exactly 50 messages
618+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
619+
const response = call?.[0] as any
620+
expect(response.messages).toHaveLength(50)
621+
expect(response.messages[0].text).toBe("Message 101")
622+
expect(response.messages[49].text).toBe("Message 150")
623+
})
624+
625+
it("should return older messages when offset is increased", async () => {
626+
await webviewMessageHandler(mockClineProvider, {
627+
type: "requestTaskMessages",
628+
offset: 50,
629+
limit: 50,
630+
})
631+
632+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
633+
type: "taskMessagesResponse",
634+
messages: expect.arrayContaining([
635+
expect.objectContaining({ text: "Message 51" }), // 150 - 50 - 50 + 1
636+
expect.objectContaining({ text: "Message 52" }),
637+
// ... up to Message 100
638+
]),
639+
totalMessages: 150,
640+
hasMore: true,
641+
})
642+
643+
// Verify we got exactly 50 messages
644+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
645+
const response = call?.[0] as any
646+
expect(response.messages).toHaveLength(50)
647+
expect(response.messages[0].text).toBe("Message 51")
648+
expect(response.messages[49].text).toBe("Message 100")
649+
})
650+
651+
it("should set hasMore to false when all messages are loaded", async () => {
652+
await webviewMessageHandler(mockClineProvider, {
653+
type: "requestTaskMessages",
654+
offset: 100,
655+
limit: 50,
656+
})
657+
658+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
659+
type: "taskMessagesResponse",
660+
messages: expect.arrayContaining([
661+
expect.objectContaining({ text: "Message 1" }),
662+
expect.objectContaining({ text: "Message 2" }),
663+
// ... up to Message 50
664+
]),
665+
totalMessages: 150,
666+
hasMore: false, // No more messages to load
667+
})
668+
669+
// Verify we got exactly 50 messages
670+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
671+
const response = call?.[0] as any
672+
expect(response.messages).toHaveLength(50)
673+
expect(response.messages[0].text).toBe("Message 1")
674+
expect(response.messages[49].text).toBe("Message 50")
675+
})
676+
677+
it("should handle partial page at the beginning", async () => {
678+
await webviewMessageHandler(mockClineProvider, {
679+
type: "requestTaskMessages",
680+
offset: 140,
681+
limit: 50,
682+
})
683+
684+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
685+
type: "taskMessagesResponse",
686+
messages: expect.arrayContaining([
687+
expect.objectContaining({ text: "Message 1" }),
688+
expect.objectContaining({ text: "Message 2" }),
689+
// ... up to Message 10
690+
]),
691+
totalMessages: 150,
692+
hasMore: false,
693+
})
694+
695+
// Verify we got only 10 messages (the remaining ones)
696+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
697+
const response = call?.[0] as any
698+
expect(response.messages).toHaveLength(10)
699+
expect(response.messages[0].text).toBe("Message 1")
700+
expect(response.messages[9].text).toBe("Message 10")
701+
})
702+
703+
it("should handle task with fewer messages than limit", async () => {
704+
// Mock a current Cline with only 30 messages
705+
const mockCline = {
706+
taskId: "test-task-id",
707+
apiConversationHistory: [],
708+
clineMessages: Array.from({ length: 30 }, (_, i) => ({
709+
ts: i + 1000,
710+
type: "say",
711+
say: "assistant",
712+
text: `Message ${i + 1}`,
713+
partial: false,
714+
})),
715+
}
716+
vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any)
717+
718+
await webviewMessageHandler(mockClineProvider, {
719+
type: "requestTaskMessages",
720+
offset: 0,
721+
limit: 50,
722+
})
723+
724+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
725+
type: "taskMessagesResponse",
726+
messages: expect.arrayContaining([
727+
expect.objectContaining({ text: "Message 1" }),
728+
expect.objectContaining({ text: "Message 30" }),
729+
]),
730+
totalMessages: 30,
731+
hasMore: false, // All messages loaded in first request
732+
})
733+
734+
// Verify we got all 30 messages
735+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
736+
const response = call?.[0] as any
737+
expect(response.messages).toHaveLength(30)
738+
})
739+
740+
it("should handle no current Cline instance", async () => {
741+
vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(undefined)
742+
743+
await webviewMessageHandler(mockClineProvider, {
744+
type: "requestTaskMessages",
745+
offset: 0,
746+
limit: 50,
747+
})
748+
749+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
750+
type: "taskMessagesResponse",
751+
messages: [],
752+
totalMessages: 0,
753+
hasMore: false,
754+
})
755+
})
756+
757+
it("should handle Cline with no messages", async () => {
758+
const mockCline = {
759+
taskId: "test-task-id",
760+
apiConversationHistory: [],
761+
clineMessages: [],
762+
}
763+
vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any)
764+
765+
await webviewMessageHandler(mockClineProvider, {
766+
type: "requestTaskMessages",
767+
offset: 0,
768+
limit: 50,
769+
})
770+
771+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
772+
type: "taskMessagesResponse",
773+
messages: [],
774+
totalMessages: 0,
775+
hasMore: false,
776+
})
777+
})
778+
779+
it("should handle offset beyond message count", async () => {
780+
await webviewMessageHandler(mockClineProvider, {
781+
type: "requestTaskMessages",
782+
offset: 200, // Beyond 150 messages
783+
limit: 50,
784+
})
785+
786+
const call = vi.mocked(mockClineProvider.postMessageToWebview).mock.calls[0]
787+
const response = call?.[0] as any
788+
789+
expect(response).toEqual({
790+
type: "taskMessagesResponse",
791+
messages: [],
792+
totalMessages: 150,
793+
hasMore: false,
794+
})
795+
})
796+
797+
it("should handle undefined clineMessages", async () => {
798+
const mockCline = {
799+
taskId: "test-task-id",
800+
apiConversationHistory: [],
801+
clineMessages: undefined,
802+
}
803+
vi.mocked(mockClineProvider.getCurrentCline).mockReturnValue(mockCline as any)
804+
805+
await webviewMessageHandler(mockClineProvider, {
806+
type: "requestTaskMessages",
807+
offset: 0,
808+
limit: 50,
809+
})
810+
811+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
812+
type: "taskMessagesResponse",
813+
messages: [],
814+
totalMessages: 0,
815+
hasMore: false,
816+
})
817+
})
818+
})

src/core/webview/webviewMessageHandler.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2568,5 +2568,52 @@ export const webviewMessageHandler = async (
25682568
}
25692569
break
25702570
}
2571+
case "requestTaskMessages": {
2572+
// Handle lazy loading of task messages
2573+
const currentCline = provider.getCurrentCline()
2574+
if (!currentCline) {
2575+
// No active task, send empty response
2576+
await provider.postMessageToWebview({
2577+
type: "taskMessagesResponse",
2578+
messages: [],
2579+
totalMessages: 0,
2580+
hasMore: false,
2581+
})
2582+
break
2583+
}
2584+
2585+
const allMessages = currentCline.clineMessages || []
2586+
const offset = message.offset || 0
2587+
const limit = message.limit || 50
2588+
2589+
// Calculate the range of messages to send
2590+
// We want to get messages from the end, working backwards
2591+
const totalMessages = allMessages.length
2592+
2593+
// If offset is beyond the total messages, return empty array
2594+
if (offset >= totalMessages) {
2595+
await provider.postMessageToWebview({
2596+
type: "taskMessagesResponse",
2597+
messages: [],
2598+
totalMessages: totalMessages,
2599+
hasMore: false,
2600+
})
2601+
break
2602+
}
2603+
2604+
const startIndex = Math.max(0, totalMessages - offset - limit)
2605+
const endIndex = totalMessages - offset
2606+
2607+
const messages = allMessages.slice(startIndex, endIndex)
2608+
const hasMore = startIndex > 0
2609+
2610+
await provider.postMessageToWebview({
2611+
type: "taskMessagesResponse",
2612+
messages: messages,
2613+
totalMessages: totalMessages,
2614+
hasMore: hasMore,
2615+
})
2616+
break
2617+
}
25712618
}
25722619
}

src/shared/ExtensionMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export interface ExtensionMessage {
120120
| "showEditMessageDialog"
121121
| "commands"
122122
| "insertTextIntoTextarea"
123+
| "taskMessagesResponse"
123124
text?: string
124125
payload?: any // Add a generic payload for now, can refine later
125126
action?:
@@ -194,6 +195,10 @@ export interface ExtensionMessage {
194195
messageTs?: number
195196
context?: string
196197
commands?: Command[]
198+
// For lazy loading messages
199+
messages?: ClineMessage[]
200+
totalMessages?: number
201+
hasMore?: boolean
197202
}
198203

199204
export type ExtensionState = Pick<

src/shared/WebviewMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ export interface WebviewMessage {
210210
| "deleteCommand"
211211
| "createCommand"
212212
| "insertTextIntoTextarea"
213+
| "requestTaskMessages"
213214
text?: string
214215
editedMessageContent?: string
215216
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
@@ -272,6 +273,9 @@ export interface WebviewMessage {
272273
codebaseIndexGeminiApiKey?: string
273274
codebaseIndexMistralApiKey?: string
274275
}
276+
// For lazy loading messages
277+
offset?: number
278+
limit?: number
275279
}
276280

277281
export const checkoutDiffPayloadSchema = z.object({

0 commit comments

Comments
 (0)