Scroll to message onclick from task timeline (#3890)

* Scroll to message onclick from task timeline

* version

* fixing ellipsis-dev's suggestion on potential infinite loop
This commit is contained in:
Caleb Eom
2025-05-29 00:49:08 -07:00
committed by GitHub
parent d6ccbcdf22
commit 5c3e7a38d4
4 changed files with 93 additions and 6 deletions
@@ -135,6 +135,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
const disableAutoScrollRef = useRef(false)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [isAtBottom, setIsAtBottom] = useState(false)
const [pendingScrollToMessage, setPendingScrollToMessage] = useState<number | null>(null)
useEffect(() => {
const handleCopy = async (e: ClipboardEvent) => {
@@ -887,6 +888,61 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
})
}, [])
const scrollToMessage = useCallback(
(messageIndex: number) => {
setPendingScrollToMessage(messageIndex)
const targetMessage = messages[messageIndex]
if (!targetMessage) {
setPendingScrollToMessage(null)
return
}
const visibleIndex = visibleMessages.findIndex((msg) => msg.ts === targetMessage.ts)
if (visibleIndex === -1) {
setPendingScrollToMessage(null)
return
}
let groupIndex = -1
let currentVisibleIndex = 0
for (let i = 0; i < groupedMessages.length; i++) {
const group = groupedMessages[i]
if (Array.isArray(group)) {
const groupSize = group.length
const messageInGroup = group.some((msg) => msg.ts === targetMessage.ts)
if (messageInGroup) {
groupIndex = i
break
}
currentVisibleIndex += groupSize
} else {
if (group.ts === targetMessage.ts) {
groupIndex = i
break
}
currentVisibleIndex++
}
}
if (groupIndex !== -1) {
setPendingScrollToMessage(null)
disableAutoScrollRef.current = true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: groupIndex,
align: "start",
behavior: "smooth",
})
})
})
}
},
[messages, visibleMessages, groupedMessages],
)
// scroll when user toggles certain rows
const toggleRowExpansion = useCallback(
(ts: number) => {
@@ -966,6 +1022,12 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
}
}, [groupedMessages.length, scrollToBottomSmooth])
useEffect(() => {
if (pendingScrollToMessage !== null) {
scrollToMessage(pendingScrollToMessage)
}
}, [pendingScrollToMessage, groupedMessages, scrollToMessage])
const handleWheel = useCallback((event: Event) => {
const wheelEvent = event as WheelEvent
if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
@@ -1063,6 +1125,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
totalCost={apiMetrics.totalCost}
lastApiReqTotalTokens={lastApiReqTotalTokens}
onClose={handleTaskCloseButtonClick}
onScrollToMessage={scrollToMessage}
/>
) : (
<div
@@ -24,6 +24,7 @@ interface TaskHeaderProps {
totalCost: number
lastApiReqTotalTokens?: number
onClose: () => void
onScrollToMessage?: (messageIndex: number) => void
}
const TaskHeader: React.FC<TaskHeaderProps> = ({
@@ -36,6 +37,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
totalCost,
lastApiReqTotalTokens,
onClose,
onScrollToMessage,
}) => {
const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage, clineMessages, navigateToSettings } =
useExtensionState()
@@ -469,7 +471,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</div>
)}
<div className="flex flex-col">
<TaskTimeline messages={clineMessages} />
<TaskTimeline messages={clineMessages} onBlockClick={onScrollToMessage} />
{ContextWindowComponent}
</div>
{checkpointTrackerErrorMessage && (
@@ -14,6 +14,7 @@ const TOOLTIP_MARGIN = 32 // 32px margin on each side
interface TaskTimelineProps {
messages: ClineMessage[]
onBlockClick?: (messageIndex: number) => void
}
const getBlockColor = (message: ClineMessage): string => {
@@ -94,16 +95,19 @@ const getBlockColor = (message: ClineMessage): string => {
return COLOR_WHITE // Default color
}
const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages }) => {
const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages, onBlockClick }) => {
const containerRef = useRef<HTMLDivElement>(null)
const scrollableRef = useRef<HTMLDivElement>(null)
const taskTimelinePropsMessages = useMemo(() => {
if (messages.length <= 1) return []
const { taskTimelinePropsMessages, messageIndexMap } = useMemo(() => {
if (messages.length <= 1) return { taskTimelinePropsMessages: [], messageIndexMap: [] }
const processed = combineApiRequests(combineCommandSequences(messages.slice(1)))
const indexMap: number[] = []
const filtered = processed.filter((msg, processedIndex) => {
const originalIndex = messages.findIndex((originalMsg, idx) => idx > 0 && originalMsg.ts === msg.ts)
return processed.filter((msg) => {
// Filter out standard "say" events we don't want to show
if (
msg.type === "say" &&
@@ -124,9 +128,13 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages }) => {
) {
return false
}
if (originalIndex !== -1) {
indexMap.push(originalIndex)
}
return true
})
return { taskTimelinePropsMessages: filtered, messageIndexMap: indexMap }
}, [messages])
useEffect(() => {
@@ -145,9 +153,18 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages }) => {
const TimelineBlock = useCallback(
(index: number) => {
const message = taskTimelinePropsMessages[index]
const originalMessageIndex = messageIndexMap[index]
const handleClick = () => {
if (onBlockClick && originalMessageIndex !== undefined) {
onBlockClick(originalMessageIndex)
}
}
return (
<TaskTimelineTooltip message={message}>
<div
onClick={handleClick}
style={{
width: BLOCK_WIDTH,
height: "100%",
@@ -160,7 +177,7 @@ const TaskTimeline: React.FC<TaskTimelineProps> = ({ messages }) => {
</TaskTimelineTooltip>
)
},
[taskTimelinePropsMessages],
[taskTimelinePropsMessages, messageIndexMap, onBlockClick],
)
// Scroll to the end when messages change