-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix: improve UI performance and reduce delays in message rendering #8365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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(() => { | ||
| 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. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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], | ||
| ) | ||
|
|
@@ -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) => ({ "<": "<", ">": ">", "&": "&" })[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) => ({ "<": "<", ">": ">", "&": "&" })[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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) => ({ "<": "<", ">": ">", "&": "&" })[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) | ||
|
|
@@ -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) => { | ||
|
|
@@ -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"> | ||
|
|
||
There was a problem hiding this comment.
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.