diff --git a/.changeset/new-ads-yell.md b/.changeset/new-ads-yell.md new file mode 100644 index 0000000000..d2d66203c8 --- /dev/null +++ b/.changeset/new-ads-yell.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +scroll to task timeline diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 2cf3e64037..a83114d8b7 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -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(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} /> ) : (
void + onScrollToMessage?: (messageIndex: number) => void } const TaskHeader: React.FC = ({ @@ -36,6 +37,7 @@ const TaskHeader: React.FC = ({ totalCost, lastApiReqTotalTokens, onClose, + onScrollToMessage, }) => { const { apiConfiguration, currentTaskItem, checkpointTrackerErrorMessage, clineMessages, navigateToSettings } = useExtensionState() @@ -469,7 +471,7 @@ const TaskHeader: React.FC = ({
)}
- + {ContextWindowComponent}
{checkpointTrackerErrorMessage && ( diff --git a/webview-ui/src/components/chat/TaskTimeline.tsx b/webview-ui/src/components/chat/TaskTimeline.tsx index aa046af1cd..ce584d9d53 100644 --- a/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/webview-ui/src/components/chat/TaskTimeline.tsx @@ -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 = ({ messages }) => { +const TaskTimeline: React.FC = ({ messages, onBlockClick }) => { const containerRef = useRef(null) const scrollableRef = useRef(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 = ({ 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 = ({ messages }) => { const TimelineBlock = useCallback( (index: number) => { const message = taskTimelinePropsMessages[index] + const originalMessageIndex = messageIndexMap[index] + + const handleClick = () => { + if (onBlockClick && originalMessageIndex !== undefined) { + onBlockClick(originalMessageIndex) + } + } + return (
= ({ messages }) => { ) }, - [taskTimelinePropsMessages], + [taskTimelinePropsMessages, messageIndexMap, onBlockClick], ) // Scroll to the end when messages change