Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 75 additions & 43 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { QueuedMessages } from "./QueuedMessages"
import DismissibleUpsell from "../common/DismissibleUpsell"
import { useCloudUpsell } from "@src/hooks/useCloudUpsell"
import { Cloud } from "lucide-react"
import { UserPromptNavigation } from "./UserPromptNavigation"

export interface ChatViewProps {
isHidden: boolean
Expand Down Expand Up @@ -1244,40 +1245,44 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return <BrowserSessionStatusRow key={messageOrGroup.ts} message={messageOrGroup} />
}

// regular message
// regular message - add data attribute for navigation
return (
<ChatRow
key={messageOrGroup.ts}
message={messageOrGroup}
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={toggleRowExpansion} // This was already stabilized
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
isLast={index === groupedMessages.length - 1} // Original direct access
onHeightChange={handleRowHeightChange}
isStreaming={isStreaming}
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
(() => {
let tool: any = {}
try {
tool = JSON.parse(messageOrGroup.text || "{}")
} catch (_) {
if (messageOrGroup.text?.includes("updateTodoList")) {
tool = { tool: "updateTodoList" }
<div data-message-index={index}>
<ChatRow
key={messageOrGroup.ts}
message={messageOrGroup}
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={toggleRowExpansion} // This was already stabilized
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
isLast={index === groupedMessages.length - 1} // Original direct access
onHeightChange={handleRowHeightChange}
isStreaming={isStreaming}
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onBatchFileResponse={handleBatchFileResponse}
isFollowUpAnswered={
messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs
}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
(() => {
let tool: any = {}
try {
tool = JSON.parse(messageOrGroup.text || "{}")
} catch (_) {
if (messageOrGroup.text?.includes("updateTodoList")) {
tool = { tool: "updateTodoList" }
}
}
}
if (tool.tool === "updateTodoList" && alwaysAllowUpdateTodoList) {
return false
}
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
hasCheckpoint={hasCheckpoint}
/>
if (tool.tool === "updateTodoList" && alwaysAllowUpdateTodoList) {
return false
}
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
hasCheckpoint={hasCheckpoint}
/>
</div>
)
},
[
Expand Down Expand Up @@ -1330,6 +1335,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
switchToNextMode()
}
}

// Check for Alt + Arrow keys for prompt navigation
if (event.altKey && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
event.preventDefault() // Prevent default scrolling

// Find the UserPromptNavigation component and trigger navigation
const promptNavButtons = document.querySelectorAll("[data-prompt-nav]")
if (promptNavButtons.length > 0) {
if (event.key === "ArrowUp") {
// Trigger previous prompt navigation
const prevButton = document.querySelector('[data-prompt-nav="prev"]') as HTMLButtonElement
prevButton?.click()
} else if (event.key === "ArrowDown") {
// Trigger next prompt navigation
const nextButton = document.querySelector('[data-prompt-nav="next"]') as HTMLButtonElement
nextButton?.click()
}
}
}
},
[switchToNextMode, switchToPreviousMode],
)
Expand Down Expand Up @@ -1474,17 +1498,25 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
: "opacity-50"
}`}>
{showScrollToBottom ? (
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-[2]"
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
<div className="flex items-center gap-2 w-full">
<UserPromptNavigation
messages={messages}
virtuosoRef={virtuosoRef}
visibleMessages={groupedMessages}
className="mr-2"
/>
<StandardTooltip content={t("chat:scrollToBottom")}>
<Button
variant="secondary"
className="flex-1"
onClick={() => {
scrollToBottomSmooth()
disableAutoScrollRef.current = false
}}>
<span className="codicon codicon-chevron-down"></span>
</Button>
</StandardTooltip>
</div>
) : (
<>
{primaryButtonText && !isStreaming && (
Expand Down
21 changes: 21 additions & 0 deletions webview-ui/src/components/chat/UserPromptNavigation.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
@keyframes prompt-highlight {
0% {
background-color: var(--vscode-editor-findMatchHighlightBackground);
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
background-color: transparent;
opacity: 0;
}
}

.prompt-highlight {
animation: prompt-highlight 1.5s ease-in-out;
border-radius: 4px;
}
181 changes: 181 additions & 0 deletions webview-ui/src/components/chat/UserPromptNavigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import React, { useMemo, useCallback, useState, useEffect } from "react"
import { ChevronUp, ChevronDown } from "lucide-react"
import { StandardTooltip } from "@src/components/ui"
import { LucideIconButton } from "./LucideIconButton"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import type { ClineMessage } from "@roo-code/types"
import type { VirtuosoHandle } from "react-virtuoso"
import "./UserPromptNavigation.css"

interface UserPromptNavigationProps {
messages: ClineMessage[]
virtuosoRef: React.RefObject<VirtuosoHandle>
visibleMessages: ClineMessage[]
className?: string
}

export const UserPromptNavigation: React.FC<UserPromptNavigationProps> = ({
messages,
virtuosoRef,
visibleMessages,
className = "",
}) => {
const { t } = useAppTranslation()
const [currentPromptIndex, setCurrentPromptIndex] = useState<number>(-1)
const [isNavigating, setIsNavigating] = useState(false)

// Find all user prompts in the visible messages
const userPromptIndices = useMemo(() => {
const indices: number[] = []
visibleMessages.forEach((msg, index) => {
if (msg.say === "user_feedback" && msg.text && msg.text.trim() !== "") {
indices.push(index)
}
})
return indices
}, [visibleMessages])

// Reset navigation when messages change significantly
useEffect(() => {
if (!isNavigating) {
setCurrentPromptIndex(-1)
}
}, [messages.length, isNavigating])
Comment on lines +38 to +43
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The messages prop is received but only used in a useEffect dependency to reset navigation state. The actual filtering and navigation logic uses visibleMessages. This creates ambiguity about which array is the source of truth. Consider removing the messages prop if it's not needed, or document why both are necessary. If messages.length changes but visibleMessages doesn't (or vice versa), the reset logic might not work as expected.

Fix it with Roo Code or mention @roomote and request a fix.


// Navigate to previous user prompt
const navigateToPreviousPrompt = useCallback(() => {
if (userPromptIndices.length === 0) return

setIsNavigating(true)

let targetIndex: number
if (currentPromptIndex === -1) {
// If not currently navigating, jump to the last prompt
targetIndex = userPromptIndices.length - 1
} else if (currentPromptIndex > 0) {
// Navigate to previous prompt
targetIndex = currentPromptIndex - 1
} else {
// Wrap around to the last prompt
targetIndex = userPromptIndices.length - 1
}

setCurrentPromptIndex(targetIndex)
const messageIndex = userPromptIndices[targetIndex]

// Scroll to the message with smooth animation
virtuosoRef.current?.scrollToIndex({
index: messageIndex,
behavior: "smooth",
align: "center",
})

// Briefly highlight the message after scrolling
setTimeout(() => {
const element = document.querySelector(`[data-message-index="${messageIndex}"]`)
if (element) {
element.classList.add("prompt-highlight")
setTimeout(() => {
element.classList.remove("prompt-highlight")
}, 1500)
}
}, 500)

// Clear navigation state after a delay
setTimeout(() => {
setIsNavigating(false)
}, 3000)
Comment on lines +73 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential memory leak: The setTimeout calls for highlighting and clearing navigation state are not cleaned up if the component unmounts. If a user navigates and then quickly switches tasks or closes the view, these timeouts will still fire and attempt to access DOM elements that may no longer exist, potentially causing errors or memory leaks. Consider storing timeout IDs in refs and cleaning them up in a useEffect cleanup function.

Fix it with Roo Code or mention @roomote and request a fix.

Comment on lines +84 to +87
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition risk: If users click navigation buttons rapidly, multiple setTimeout callbacks will be scheduled to set isNavigating to false after 3 seconds. This could cause the navigation state to be cleared prematurely or behave unpredictably. Consider using a ref to track the timeout ID and clearing it before scheduling a new one, similar to how the highlight timeouts should be managed.

Fix it with Roo Code or mention @roomote and request a fix.

}, [userPromptIndices, currentPromptIndex, virtuosoRef])

// Navigate to next user prompt
const navigateToNextPrompt = useCallback(() => {
if (userPromptIndices.length === 0) return

setIsNavigating(true)

let targetIndex: number
if (currentPromptIndex === -1) {
// If not currently navigating, jump to the first prompt
targetIndex = 0
} else if (currentPromptIndex < userPromptIndices.length - 1) {
// Navigate to next prompt
targetIndex = currentPromptIndex + 1
} else {
// Wrap around to the first prompt
targetIndex = 0
}

setCurrentPromptIndex(targetIndex)
const messageIndex = userPromptIndices[targetIndex]

// Scroll to the message with smooth animation
virtuosoRef.current?.scrollToIndex({
index: messageIndex,
behavior: "smooth",
align: "center",
})

// Briefly highlight the message after scrolling
setTimeout(() => {
const element = document.querySelector(`[data-message-index="${messageIndex}"]`)
if (element) {
element.classList.add("prompt-highlight")
setTimeout(() => {
element.classList.remove("prompt-highlight")
}, 1500)
}
}, 500)

// Clear navigation state after a delay
setTimeout(() => {
setIsNavigating(false)
}, 3000)
}, [userPromptIndices, currentPromptIndex, virtuosoRef])

// Don't show navigation if there are no user prompts
if (userPromptIndices.length === 0) {
return null
}

const navigationInfo =
currentPromptIndex !== -1
? t("chat:promptNavigation.position", {
current: currentPromptIndex + 1,
total: userPromptIndices.length,
})
: t("chat:promptNavigation.total", { total: userPromptIndices.length })

return (
<div className={`flex items-center gap-1 ${className}`}>
<StandardTooltip content={`${t("chat:promptNavigation.previousTooltip")} (Alt+↑)`}>
<div data-prompt-nav="prev">
<LucideIconButton
icon={ChevronUp}
onClick={navigateToPreviousPrompt}
disabled={userPromptIndices.length === 0}
className="h-7 w-7"
title={t("chat:promptNavigation.previous")}
/>
</div>
</StandardTooltip>

{isNavigating && (
<span className="text-xs text-vscode-descriptionForeground px-1 min-w-[60px] text-center">
{navigationInfo}
</span>
)}

<StandardTooltip content={`${t("chat:promptNavigation.nextTooltip")} (Alt+↓)`}>
<div data-prompt-nav="next">
<LucideIconButton
icon={ChevronDown}
onClick={navigateToNextPrompt}
disabled={userPromptIndices.length === 0}
className="h-7 w-7"
title={t("chat:promptNavigation.next")}
/>
</div>
</StandardTooltip>
</div>
)
}
Loading
Loading