Skip to content

Commit 6892427

Browse files
xyOz-devdaniel-lxs
andauthored
Fix: Resolve Memory Leak in ChatView Virtual Scrolling Implementation (#6697)
Co-authored-by: Daniel Riccio <[email protected]>
1 parent 2e77ce1 commit 6892427

File tree

1 file changed

+93
-48
lines changed

1 file changed

+93
-48
lines changed

webview-ui/src/components/chat/ChatView.tsx

Lines changed: 93 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
1+
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
22
import { useDeepCompareEffect, useEvent, useMount } from "react-use"
33
import debounce from "debounce"
44
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"
@@ -181,8 +181,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
181181
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
182182
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
183183
new LRUCache({
184-
max: 250,
185-
ttl: 1000 * 60 * 15, // 15 minutes TTL for long-running tasks
184+
max: 100,
185+
ttl: 1000 * 60 * 5,
186186
}),
187187
)
188188
const autoApproveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
@@ -458,7 +458,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
458458
}
459459
}, [isHidden])
460460

461-
useEffect(() => () => everVisibleMessagesTsRef.current.clear(), [])
461+
useEffect(() => {
462+
const cache = everVisibleMessagesTsRef.current
463+
return () => {
464+
cache.clear()
465+
}
466+
}, [])
462467

463468
useEffect(() => {
464469
const prev = prevExpandedRowsRef.current
@@ -502,7 +507,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
502507
if (isLastMessagePartial) {
503508
return true
504509
} else {
505-
const lastApiReqStarted = findLast(modifiedMessages, (message) => message.say === "api_req_started")
510+
const lastApiReqStarted = findLast(
511+
modifiedMessages,
512+
(message: ClineMessage) => message.say === "api_req_started",
513+
)
506514

507515
if (
508516
lastApiReqStarted &&
@@ -522,7 +530,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
522530
}, [modifiedMessages, clineAsk, enableButtons, primaryButtonText])
523531

524532
const markFollowUpAsAnswered = useCallback(() => {
525-
const lastFollowUpMessage = messagesRef.current.findLast((msg) => msg.ask === "followup")
533+
const lastFollowUpMessage = messagesRef.current.findLast((msg: ClineMessage) => msg.ask === "followup")
526534
if (lastFollowUpMessage) {
527535
setCurrentFollowUpTs(lastFollowUpMessage.ts)
528536
}
@@ -564,7 +572,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
564572
if (sendingDisabled && !fromQueue) {
565573
// Generate a more unique ID using timestamp + random component
566574
const messageId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
567-
setMessageQueue((prev) => [...prev, { id: messageId, text, images }])
575+
setMessageQueue((prev: QueuedMessage[]) => [...prev, { id: messageId, text, images }])
568576
setInputValue("")
569577
setSelectedImages([])
570578
return
@@ -660,7 +668,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
660668
if (retryCount < MAX_RETRY_ATTEMPTS) {
661669
retryCountRef.current.set(nextMessage.id, retryCount + 1)
662670
// Re-add the message to the end of the queue
663-
setMessageQueue((current) => [...current, nextMessage])
671+
setMessageQueue((current: QueuedMessage[]) => [...current, nextMessage])
664672
} else {
665673
console.error(`Message ${nextMessage.id} failed after ${MAX_RETRY_ATTEMPTS} attempts, discarding`)
666674
retryCountRef.current.delete(nextMessage.id)
@@ -832,7 +840,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
832840
// Only handle selectedImages if it's not for editing context
833841
// When context is "edit", ChatRow will handle the images
834842
if (message.context !== "edit") {
835-
setSelectedImages((prevImages) =>
843+
setSelectedImages((prevImages: string[]) =>
836844
appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE),
837845
)
838846
}
@@ -888,21 +896,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
888896
// NOTE: the VSCode window needs to be focused for this to work.
889897
useMount(() => textAreaRef.current?.focus())
890898

891-
useDebounceEffect(
892-
() => {
893-
if (!isHidden && !sendingDisabled && !enableButtons) {
894-
textAreaRef.current?.focus()
895-
}
896-
},
897-
50,
898-
[isHidden, sendingDisabled, enableButtons],
899-
)
900-
901899
const visibleMessages = useMemo(() => {
902-
const newVisibleMessages = modifiedMessages.filter((message) => {
900+
const currentMessageCount = modifiedMessages.length
901+
const startIndex = Math.max(0, currentMessageCount - 500)
902+
const recentMessages = modifiedMessages.slice(startIndex)
903+
904+
const newVisibleMessages = recentMessages.filter((message: ClineMessage) => {
903905
if (everVisibleMessagesTsRef.current.has(message.ts)) {
904-
// If it was ever visible, and it's not one of the types that should always be hidden once processed, keep it.
905-
// This helps prevent flickering for messages like 'api_req_retry_delayed' if they are no longer the absolute last.
906906
const alwaysHiddenOnceProcessedAsk: ClineAsk[] = [
907907
"api_req_failed",
908908
"resume_task",
@@ -916,14 +916,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
916916
]
917917
if (message.ask && alwaysHiddenOnceProcessedAsk.includes(message.ask)) return false
918918
if (message.say && alwaysHiddenOnceProcessedSay.includes(message.say)) return false
919-
// Also, re-evaluate empty text messages if they were previously visible but now empty (e.g. partial stream ended)
920919
if (message.say === "text" && (message.text ?? "") === "" && (message.images?.length ?? 0) === 0) {
921920
return false
922921
}
923922
return true
924923
}
925924

926-
// Original filter logic
927925
switch (message.ask) {
928926
case "completion_result":
929927
if (message.text === "") return false
@@ -942,9 +940,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
942940
const last1 = modifiedMessages.at(-1)
943941
const last2 = modifiedMessages.at(-2)
944942
if (last1?.ask === "resume_task" && last2 === message) {
945-
// This specific sequence should be visible
943+
return true
946944
} else if (message !== last1) {
947-
// If not the specific sequence above, and not the last message, hide it.
948945
return false
949946
}
950947
break
@@ -957,12 +954,41 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
957954
return true
958955
})
959956

960-
// Update the set of ever-visible messages (LRUCache automatically handles cleanup)
961-
newVisibleMessages.forEach((msg) => everVisibleMessagesTsRef.current.set(msg.ts, true))
957+
const viewportStart = Math.max(0, newVisibleMessages.length - 100)
958+
newVisibleMessages
959+
.slice(viewportStart)
960+
.forEach((msg: ClineMessage) => everVisibleMessagesTsRef.current.set(msg.ts, true))
962961

963962
return newVisibleMessages
964963
}, [modifiedMessages])
965964

965+
useEffect(() => {
966+
const cleanupInterval = setInterval(() => {
967+
const cache = everVisibleMessagesTsRef.current
968+
const currentMessageIds = new Set(modifiedMessages.map((m: ClineMessage) => m.ts))
969+
const viewportMessages = visibleMessages.slice(Math.max(0, visibleMessages.length - 100))
970+
const viewportMessageIds = new Set(viewportMessages.map((m: ClineMessage) => m.ts))
971+
972+
cache.forEach((_value: boolean, key: number) => {
973+
if (!currentMessageIds.has(key) && !viewportMessageIds.has(key)) {
974+
cache.delete(key)
975+
}
976+
})
977+
}, 60000)
978+
979+
return () => clearInterval(cleanupInterval)
980+
}, [modifiedMessages, visibleMessages])
981+
982+
useDebounceEffect(
983+
() => {
984+
if (!isHidden && !sendingDisabled && !enableButtons) {
985+
textAreaRef.current?.focus()
986+
}
987+
},
988+
50,
989+
[isHidden, sendingDisabled, enableButtons],
990+
)
991+
966992
const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
967993
if (message?.type === "ask") {
968994
if (!message.text) {
@@ -1238,7 +1264,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
12381264
}
12391265
}
12401266

1241-
visibleMessages.forEach((message) => {
1267+
visibleMessages.forEach((message: ClineMessage) => {
12421268
if (message.ask === "browser_action_launch") {
12431269
// Complete existing browser session if any.
12441270
endBrowserSession()
@@ -1308,10 +1334,23 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13081334

13091335
const scrollToBottomSmooth = useMemo(
13101336
() =>
1311-
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
1312-
immediate: true,
1313-
}),
1314-
[],
1337+
debounce(
1338+
() => {
1339+
const lastIndex = groupedMessages.length - 1
1340+
if (lastIndex >= 0) {
1341+
virtuosoRef.current?.scrollToIndex({
1342+
index: lastIndex,
1343+
behavior: "smooth",
1344+
align: "end",
1345+
})
1346+
}
1347+
},
1348+
10,
1349+
{
1350+
immediate: true,
1351+
},
1352+
),
1353+
[groupedMessages.length],
13151354
)
13161355

13171356
useEffect(() => {
@@ -1323,15 +1362,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13231362
}, [scrollToBottomSmooth])
13241363

13251364
const scrollToBottomAuto = useCallback(() => {
1326-
virtuosoRef.current?.scrollTo({
1327-
top: Number.MAX_SAFE_INTEGER,
1328-
behavior: "auto", // Instant causes crash.
1329-
})
1330-
}, [])
1365+
const lastIndex = groupedMessages.length - 1
1366+
if (lastIndex >= 0) {
1367+
virtuosoRef.current?.scrollToIndex({
1368+
index: lastIndex,
1369+
behavior: "auto", // Instant causes crash.
1370+
align: "end",
1371+
})
1372+
}
1373+
}, [groupedMessages.length])
13311374

13321375
const handleSetExpandedRow = useCallback(
13331376
(ts: number, expand?: boolean) => {
1334-
setExpandedRows((prev) => ({ ...prev, [ts]: expand === undefined ? !prev[ts] : expand }))
1377+
setExpandedRows((prev: Record<number, boolean>) => ({
1378+
...prev,
1379+
[ts]: expand === undefined ? !prev[ts] : expand,
1380+
}))
13351381
},
13361382
[setExpandedRows], // setExpandedRows is stable
13371383
)
@@ -1360,7 +1406,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
13601406
)
13611407

13621408
useEffect(() => {
1363-
let timer: NodeJS.Timeout | undefined
1409+
let timer: ReturnType<typeof setTimeout> | undefined
13641410
if (!disableAutoScrollRef.current) {
13651411
timer = setTimeout(() => scrollToBottomSmooth(), 50)
13661412
}
@@ -1446,7 +1492,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14461492

14471493
if (event?.shiftKey) {
14481494
// Always append to existing text, don't overwrite
1449-
setInputValue((currentValue) => {
1495+
setInputValue((currentValue: string) => {
14501496
return currentValue !== "" ? `${currentValue} \n${suggestion.answer}` : suggestion.answer
14511497
})
14521498
} else {
@@ -1480,7 +1526,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
14801526
isStreaming={isStreaming}
14811527
isExpanded={(messageTs: number) => expandedRows[messageTs] ?? false}
14821528
onToggleExpand={(messageTs: number) => {
1483-
setExpandedRows((prev) => ({
1529+
setExpandedRows((prev: Record<number, boolean>) => ({
14841530
...prev,
14851531
[messageTs]: !prev[messageTs],
14861532
}))
@@ -1839,20 +1885,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
18391885
<div className="grow flex" ref={scrollContainerRef}>
18401886
<Virtuoso
18411887
ref={virtuosoRef}
1842-
key={task.ts} // trick to make sure virtuoso re-renders when task changes, and we use initialTopMostItemIndex to start at the bottom
1888+
key={task.ts}
18431889
className="scrollable grow overflow-y-scroll mb-1"
1844-
// increasing top by 3_000 to prevent jumping around when user collapses a row
1845-
increaseViewportBy={{ top: 3_000, bottom: Number.MAX_SAFE_INTEGER }} // hack to make sure the last message is always rendered to get truly perfect scroll to bottom animation when new messages are added (Number.MAX_SAFE_INTEGER is safe for arithmetic operations, which is all virtuoso uses this value for in src/sizeRangeSystem.ts)
1846-
data={groupedMessages} // messages is the raw format returned by extension, modifiedMessages is the manipulated structure that combines certain messages of related type, and visibleMessages is the filtered structure that removes messages that should not be rendered
1890+
increaseViewportBy={{ top: 3_000, bottom: 1000 }}
1891+
data={groupedMessages}
18471892
itemContent={itemContent}
1848-
atBottomStateChange={(isAtBottom) => {
1893+
atBottomStateChange={(isAtBottom: boolean) => {
18491894
setIsAtBottom(isAtBottom)
18501895
if (isAtBottom) {
18511896
disableAutoScrollRef.current = false
18521897
}
18531898
setShowScrollToBottom(disableAutoScrollRef.current && !isAtBottom)
18541899
}}
1855-
atBottomThreshold={10} // anything lower causes issues with followOutput
1900+
atBottomThreshold={10}
18561901
initialTopMostItemIndex={groupedMessages.length - 1}
18571902
/>
18581903
</div>

0 commit comments

Comments
 (0)