Skip to content
Closed
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
229 changes: 148 additions & 81 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -560,59 +560,62 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const newCursorPosition = e.target.selectionStart
setCursorPosition(newCursorPosition)

const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
setShowContextMenu(showMenu)

if (showMenu) {
if (newValue.startsWith("/") && !newValue.includes(" ")) {
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
// Set to first selectable item (skip section headers)
setSelectedMenuIndex(1) // Section header is at 0, first command is at 1
// Request commands fresh each time slash menu is shown
vscode.postMessage({ type: "requestCommands" })
} else {
// Existing @ mention handling.
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
setSearchQuery(query)
// Defer context menu logic to avoid blocking input
requestAnimationFrame(() => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P3] Consider cancelling requestAnimationFrame on unmount to avoid any stray work if the component is torn down.

const showMenu = shouldShowContextMenu(newValue, newCursorPosition)
setShowContextMenu(showMenu)

if (showMenu) {
if (newValue.startsWith("/") && !newValue.includes(" ")) {
// Handle slash command - request fresh commands
const query = newValue
setSearchQuery(query)
// Set to first selectable item (skip section headers)
setSelectedMenuIndex(1) // Section header is at 0, first command is at 1
// Request commands fresh each time slash menu is shown
vscode.postMessage({ type: "requestCommands" })
} else {
// Existing @ mention handling.
const lastAtIndex = newValue.lastIndexOf("@", newCursorPosition - 1)
const query = newValue.slice(lastAtIndex + 1, newCursorPosition)
setSearchQuery(query)

// Send file search request if query is not empty.
if (query.length > 0) {
setSelectedMenuIndex(0)
// Send file search request if query is not empty.
if (query.length > 0) {
setSelectedMenuIndex(0)

// Don't clear results until we have new ones. This
// prevents flickering.
// Don't clear results until we have new ones. This
// prevents flickering.

// Clear any existing timeout.
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}
// Clear any existing timeout.
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current)
}

// Set a timeout to debounce the search requests.
searchTimeoutRef.current = setTimeout(() => {
// Generate a request ID for this search.
const reqId = Math.random().toString(36).substring(2, 9)
setSearchRequestId(reqId)
setSearchLoading(true)

// Send message to extension to search files.
vscode.postMessage({
type: "searchFiles",
query: unescapeSpaces(query),
requestId: reqId,
})
}, 200) // 200ms debounce.
} else {
setSelectedMenuIndex(3) // Set to "File" option by default.
// Set a timeout to debounce the search requests.
searchTimeoutRef.current = setTimeout(() => {
// Generate a request ID for this search.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P2] Debounce timer may fire after unmount. Add a cleanup to clear searchTimeoutRef on unmount to avoid stray work and potential setState after unmount.

const reqId = Math.random().toString(36).substring(2, 9)
setSearchRequestId(reqId)
setSearchLoading(true)

// Send message to extension to search files.
vscode.postMessage({
type: "searchFiles",
query: unescapeSpaces(query),
requestId: reqId,
})
}, 200) // 200ms debounce.
} else {
setSelectedMenuIndex(3) // Set to "File" option by default.
}
}
} else {
setSearchQuery("")
setSelectedMenuIndex(-1)
setFileSearchResults([]) // Clear file search results.
}
} else {
setSearchQuery("")
setSelectedMenuIndex(-1)
setFileSearchResults([]) // Clear file search results.
}
})
},
[setInputValue, setSearchRequestId, setFileSearchResults, setSearchLoading, resetOnInputChange],
)
Expand Down Expand Up @@ -714,51 +717,114 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
setIsMouseDownOnMenu(true)
}, [])

// Debounce highlight updates for better performance
const updateHighlightsDebounced = useRef<NodeJS.Timeout | null>(null)

const updateHighlights = useCallback(() => {
if (!textAreaRef.current || !highlightLayerRef.current) return
// Check if we're in a test environment
const isTestEnvironment = typeof process !== "undefined" && process.env.NODE_ENV === "test"

const text = textAreaRef.current.value
const performUpdate = () => {
if (!textAreaRef.current || !highlightLayerRef.current) return

// Helper function to check if a command is valid
const isValidCommand = (commandName: string): boolean => {
return commands?.some((cmd) => cmd.name === commandName) || false
}
const text = textAreaRef.current.value

// Process the text to highlight mentions and valid commands
let processedText = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')

// Custom replacement for commands - only highlight valid ones
processedText = processedText.replace(commandRegexGlobal, (match, commandName) => {
// Only highlight if the command exists in the valid commands list
if (isValidCommand(commandName)) {
// Check if the match starts with a space
const startsWithSpace = match.startsWith(" ")
const commandPart = `/${commandName}`

if (startsWithSpace) {
// Keep the space but only highlight the command part
return ` <mark class="mention-context-textarea-highlight">${commandPart}</mark>`
} else {
// Highlight the entire command (starts at beginning of line)
return `<mark class="mention-context-textarea-highlight">${commandPart}</mark>`
}
// Helper function to check if a command is valid
const isValidCommand = (commandName: string): boolean => {
return commands?.some((cmd) => cmd.name === commandName) || false
}
return match // Return unhighlighted if command is not valid
})

highlightLayerRef.current.innerHTML = processedText
// Process the text to highlight mentions and valid commands
let processedText = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')

// Custom replacement for commands - only highlight valid ones
processedText = processedText.replace(commandRegexGlobal, (match, commandName) => {
// Only highlight if the command exists in the valid commands list
if (isValidCommand(commandName)) {
// Check if the match starts with a space
const startsWithSpace = match.startsWith(" ")
const commandPart = `/${commandName}`

if (startsWithSpace) {
// Keep the space but only highlight the command part
return ` <mark class="mention-context-textarea-highlight">${commandPart}</mark>`
} else {
// Highlight the entire command (starts at beginning of line)
return `<mark class="mention-context-textarea-highlight">${commandPart}</mark>`
}
}
return match // Return unhighlighted if command is not valid
})

highlightLayerRef.current.innerHTML = processedText

highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
}

// In test environment, execute immediately
if (isTestEnvironment) {
performUpdate()
} else {
// Clear existing timeout
if (updateHighlightsDebounced.current) {
clearTimeout(updateHighlightsDebounced.current)
}

highlightLayerRef.current.scrollTop = textAreaRef.current.scrollTop
highlightLayerRef.current.scrollLeft = textAreaRef.current.scrollLeft
// Debounce the highlight update
updateHighlightsDebounced.current = setTimeout(performUpdate, 50) // 50ms debounce for highlight updates
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P2] Clear the highlight debounce timer on unmount so no pending work runs after the component is removed.

}
}, [commands])

useLayoutEffect(() => {
updateHighlights()
}, [inputValue, updateHighlights])

// Force immediate highlight update in test environment on mount
Copy link
Contributor Author

Choose a reason for hiding this comment

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

[P2] Test-only highlight initialization duplicates the logic in updateHighlights(). Prefer reusing updateHighlights() here to keep one source of truth.

useLayoutEffect(() => {
if (typeof process !== "undefined" && process.env.NODE_ENV === "test") {
// In test environment, ensure highlight layer gets initial content
if (highlightLayerRef.current && textAreaRef.current) {
const text = textAreaRef.current.value || inputValue

// Helper function to check if a command is valid
const isValidCommand = (commandName: string): boolean => {
return commands?.some((cmd) => cmd.name === commandName) || false
}

// Process the text to highlight mentions and valid commands
let processedText = text
.replace(/\n$/, "\n\n")
.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c] || c)
.replace(mentionRegexGlobal, '<mark class="mention-context-textarea-highlight">$&</mark>')

// Custom replacement for commands - only highlight valid ones
processedText = processedText.replace(commandRegexGlobal, (match, commandName) => {
// Only highlight if the command exists in the valid commands list
if (isValidCommand(commandName)) {
// Check if the match starts with a space
const startsWithSpace = match.startsWith(" ")
const commandPart = `/${commandName}`

if (startsWithSpace) {
// Keep the space but only highlight the command part
return ` <mark class="mention-context-textarea-highlight">${commandPart}</mark>`
} else {
// Highlight the entire command (starts at beginning of line)
return `<mark class="mention-context-textarea-highlight">${commandPart}</mark>`
}
}
return match // Return unhighlighted if command is not valid
})

highlightLayerRef.current.innerHTML = processedText
}
}
}, [inputValue, commands])

const updateCursorPosition = useCallback(() => {
if (textAreaRef.current) {
setCursorPosition(textAreaRef.current.selectionStart)
Expand Down Expand Up @@ -1023,7 +1089,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
value={inputValue}
onChange={(e) => {
handleInputChange(e)
updateHighlights()
// Defer highlight update to not block typing
requestAnimationFrame(() => updateHighlights())
}}
onFocus={() => setIsFocused(true)}
onKeyDown={(e) => {
Expand Down Expand Up @@ -1081,7 +1148,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"scrollbar-none",
"scrollbar-hide",
)}
onScroll={() => updateHighlights()}
onScroll={() => requestAnimationFrame(() => updateHighlights())}
/>

<div className="absolute bottom-2 right-1 z-30 flex flex-col items-center gap-0">
Expand Down
7 changes: 4 additions & 3 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1337,11 +1337,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return result
}, [isCondensing, visibleMessages])

// scrolling
// scrolling - increase debounce delay for smoother performance

const scrollToBottomSmooth = useMemo(
() =>
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, {
debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 50, {
immediate: true,
}),
[],
Expand Down Expand Up @@ -1398,7 +1398,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined
if (!disableAutoScrollRef.current) {
timer = setTimeout(() => scrollToBottomSmooth(), 50)
// Increase delay for better performance
timer = setTimeout(() => scrollToBottomSmooth(), 100)
}
return () => {
if (timer) {
Expand Down
Loading
Loading