diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a18b622c..db3cd6d86f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [v4.205.0] - 2025-12-26 + +### Added + +- New chat renderer utility for improved message rendering +- Retry button functionality for API streaming failures +- Enhanced checkpoint handling system + +### Changed + +- Migrated KiloTaskHeader and TaskItem components to use ReadOnlyChatText +- Refactored ChatTextArea component with code cleanup +- Improved ChatRow component with better checkpoint integration + +### Fixed + +- Fixed duplicate code removal in ChatTextArea +- Resolved race condition with isUserInput flag +- Fixed previous commands still showing run/reject buttons +- Improved UI consistency and functionality + +--- + ## [v4.204.1] - 2025-12-20 ### Added diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 08dd14134e..cbcd5fe9f7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3198,7 +3198,10 @@ export class Task extends EventEmitter implements TaskLike { errorMsg = "Unknown error" } - const baseDelay = requestDelaySeconds || 5 + await this.ask("api_req_failed", errorMsg) + + // Wait for the delay before retrying + const baseDelay = requestDelaySeconds || 0 let exponentialDelay = Math.min( Math.ceil(baseDelay * Math.pow(2, retryAttempt)), MAX_EXPONENTIAL_BACKOFF_SECONDS, @@ -3212,21 +3215,16 @@ export class Task extends EventEmitter implements TaskLike { if (geminiRetryDetails) { const match = geminiRetryDetails?.retryDelay?.match(/^(\d+)s$/) if (match) { - exponentialDelay = Number(match[1]) + 1 + exponentialDelay = parseInt(match[1], 10) } } } - // Wait for the greater of the exponential delay or the rate limit delay - const finalDelay = Math.max(exponentialDelay, rateLimitDelay) - - // Show countdown timer with exponential backoff - for (let i = finalDelay; i > 0; i--) { + for (let i = exponentialDelay; i > 0; i--) { await this.say( "api_req_retry_delayed", `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying in ${i} seconds...`, undefined, - true, ) await delay(1000) } @@ -3235,7 +3233,6 @@ export class Task extends EventEmitter implements TaskLike { "api_req_retry_delayed", `${errorMsg}\n\nRetry attempt ${retryAttempt + 1}\nRetrying now...`, undefined, - false, ) // Delegate generator output from the recursive call with @@ -3263,15 +3260,65 @@ export class Task extends EventEmitter implements TaskLike { } } - // No error, so we can continue to yield all remaining chunks. - // (Needs to be placed outside of try/catch since it we want caller to - // handle errors not with api_req_failed as that is reserved for first - // chunk failures only.) - // This delegates to another generator or iterable object. In this case, - // it's saying "yield all remaining values from this iterator". This - // effectively passes along all subsequent chunks from the original - // stream. - yield* iterator + // No error on first chunk, so we can continue to yield all remaining chunks. + // Wrap in try/catch to handle mid-stream errors and allow retry. + try { + yield* iterator + } catch (error) { + // Reset streaming state since we encountered an error + this.isStreaming = false + + // kilocode_change start + if (apiConfiguration?.apiProvider === "kilocode" && isAnyRecognizedKiloCodeError(error)) { + const { response } = await (isPaymentRequiredError(error) + ? this.ask( + "payment_required_prompt", + JSON.stringify({ + title: error.error?.title ?? t("kilocode:lowCreditWarning.title"), + message: error.error?.message ?? t("kilocode:lowCreditWarning.message"), + balance: error.error?.balance ?? "0.00", + buyCreditsUrl: error.error?.buyCreditsUrl ?? getAppUrl("/profile"), + }), + ) + : this.ask( + "invalid_model", + JSON.stringify({ + modelId: apiConfiguration.kilocodeModel, + error: { + status: error.status, + message: error.message, + }, + }), + )) + + if (response === "retry_clicked") { + yield* this.attemptApiRequest(retryAttempt + 1) + } else { + // Handle other responses or cancellations if necessary + throw error // Rethrow to signal failure upwards + } + return + } + // kilocode_change end + + // For mid-stream failures, show the retry dialog to allow user to retry + const { response } = await this.ask( + "api_req_failed", + error.message ?? JSON.stringify(serializeError(error), null, 2), + ) + + if (response !== "yesButtonClicked") { + // This will never happen since if noButtonClicked, we will + // clear current task, aborting this instance. + throw new Error("API request failed") + } + + await this.say("api_req_retried") + + // Delegate generator output from the recursive call. + yield* this.attemptApiRequest() + return + } } // Checkpoints diff --git a/src/package.json b/src/package.json index 20310db709..cc6d6ae047 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "matterai", - "version": "4.204.1", + "version": "4.205.0", "icon": "assets/icons/matterai-ic.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index b985378098..51fcb9da5d 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -2,6 +2,7 @@ import { VSCodeBadge, VSCodeButton } from "@vscode/webview-ui-toolkit/react" import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { Trans, useTranslation } from "react-i18next" import { useSize } from "react-use" +import { Undo2 } from "lucide-react" import type { ClineMessage, FollowUpData, SuggestionItem } from "@roo-code/types" import { Mode } from "@roo/modes" @@ -34,9 +35,9 @@ import { CommandExecution } from "./CommandExecution" import { CommandExecutionError } from "./CommandExecutionError" import { FollowUpSuggest } from "./FollowUpSuggest" import { Markdown } from "./Markdown" -import { Mention } from "./Mention" import { ProgressIndicator } from "./ProgressIndicator" import ReportBugPreview from "./ReportBugPreview" +import { ReadOnlyChatText } from "./ReadOnlyChatText" import { cn } from "@/lib/utils" import { appendImages } from "@src/utils/imageUtils" @@ -1323,7 +1324,9 @@ export const ChatRowContent = ({
@@ -1347,18 +1350,32 @@ export const ChatRowContent = ({ />
) : ( -
-
{ - e.stopPropagation() - if (!isStreaming) { - handleEditClick() - } - }} - title={t("chat:queuedMessages.clickToEdit")}> - +
+
+ { + if (!isStreaming) { + handleEditClick() + } + }} + title={t("chat:queuedMessages.clickToEdit")} + />
+ + +
{ + e.stopPropagation() + handleEditClick() + }} + title={t("chat:checkpoint.restore")}> + +
+
( const [imageWarning, setImageWarning] = useState(null) // kilocode_change const [materialIconsBaseUri, setMaterialIconsBaseUri] = useState("") + // const [isUserInput, setIsUserInput] = useState(false) + const isUserInputRef = useRef(false) // Use ref to avoid re-renders + // get the icons base uri on mount useEffect(() => { const w = window as any @@ -566,97 +569,11 @@ export const ChatTextArea = forwardRef( return map[char] || char }) - const formatMentionChipParts = useCallback((rawMention: string) => { - const mention = unescapeSpaces(rawMention) - - if (/^\w+:\/\/\S+/.test(mention)) { - try { - const url = new URL(mention) - const meta = url.pathname.replace(/^\/+/, "") - return { - primary: url.hostname || mention, - meta: meta ? [meta] : [], - } - } catch { - return { primary: mention, meta: [] } - } - } - - if (mention === "problems" || mention === "terminal") { - return { primary: mention, meta: [] } - } - - if (/^[a-f0-9]{7,40}$/i.test(mention)) { - return { primary: mention.slice(0, 7), meta: ["commit"] } - } - - if (!mention.startsWith("/")) { - return { primary: mention, meta: [] } - } - - let pathPart = mention - let lineInfo: string | undefined - - const hashMatch = mention.match(/^(.*)#L(\d+(?:-\d+)?)/) - if (hashMatch) { - pathPart = hashMatch[1] - lineInfo = `L${hashMatch[2]}` - } else { - const lastColonIndex = mention.lastIndexOf(":") - if (lastColonIndex > mention.lastIndexOf("/")) { - const maybeRange = mention.slice(lastColonIndex + 1) - if (/^\d+(?:-\d+)?$/.test(maybeRange)) { - pathPart = mention.slice(0, lastColonIndex) - lineInfo = `L${maybeRange}` - } - } - } - - const segments = pathPart.split("/").filter(Boolean) - const primary = segments.pop() || "/" - const parent = segments.length ? segments[segments.length - 1] : "" - - const metaParts = [] - if (parent) metaParts.push(parent) - if (lineInfo) metaParts.push(lineInfo) - - return { primary, meta: metaParts } - }, []) - - const getFileIconForMention = useCallback( - (rawMention: string): string => { - // Only show icons for file mentions (those with extensions) - const mention = unescapeSpaces(rawMention) - const filename = mention.split("/").pop() || "" - - // Check if it's a file with an extension - if (filename.includes(".")) { - const iconName = getIconForFilePath(filename) - return getIconUrlByName(iconName, materialIconsBaseUri) - } - - // For folders or other mentions, return empty string - return "" - }, - [materialIconsBaseUri], - ) - - const renderMentionChip = useCallback( + const renderMentionChipLocal = useCallback( (rawMention: string, isCompactFile: boolean = false) => { - const displayText = isCompactFile - ? rawMention - : formatMentionChipParts(rawMention).primary || rawMention - const escapedPrimary = escapeHtml(displayText) - const label = escapeHtml(`${isCompactFile ? rawMention : unescapeSpaces(rawMention)}`) - const mentionValue = escapeHtml(`@${isCompactFile ? rawMention : unescapeSpaces(rawMention)}`) - - // Get file icon for file mentions - const fileIconUrl = getFileIconForMention(rawMention) - const iconHtml = fileIconUrl ? `` : "" - - return `${iconHtml}${escapedPrimary}` + return renderMentionChip(rawMention, materialIconsBaseUri, isCompactFile) }, - [formatMentionChipParts, getFileIconForMention], + [materialIconsBaseUri], ) const valueToHtml = useCallback( @@ -667,11 +584,11 @@ export const ChatTextArea = forwardRef( .replace(/\n/g, '
') .replace(/@([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)(?=\s|$)/g, (_match, filename) => { if (mentionMapRef.current.has(filename)) { - return renderMentionChip(filename, true) + return renderMentionChipLocal(filename, true) } return _match }) - .replace(mentionRegexGlobal, (_match, mention) => renderMentionChip(mention, false)) + .replace(mentionRegexGlobal, (_match, mention) => renderMentionChipLocal(mention, false)) if (/^\s*\//.test(processedText)) { const slashIndex = processedText.indexOf("/") @@ -690,7 +607,7 @@ export const ChatTextArea = forwardRef( return processedText || '
' }, - [customModes, renderMentionChip], + [customModes, renderMentionChipLocal], ) const getNodeTextLength = useCallback((node: Node): number => { @@ -823,17 +740,26 @@ export const ChatTextArea = forwardRef( if (!parent) return null const siblings = Array.from(parent.childNodes) const index = siblings.indexOf(elNode) - const targetIndex = remaining === 0 ? index : index + 1 - remaining = Math.max(remaining - 1, 0) - return createRangeAt(parent, targetIndex) + + if (remaining === 0) { + return createRangeAt(parent, index) + } else if (remaining === 1) { + return createRangeAt(parent, index + 1) + } else { + remaining -= 1 + return null // Continue to next sibling + } } for (const child of Array.from(elNode.childNodes)) { const childLength = getNodeTextLength(child) if (remaining <= childLength) { - return walk(child) + const result = walk(child) + if (result) return result + // If walk returns null, continue to next child + } else { + remaining -= childLength } - remaining -= childLength } } @@ -853,6 +779,14 @@ export const ChatTextArea = forwardRef( useLayoutEffect(() => { if (!textAreaRef.current) return + + // Only update innerHTML if the change is not from user input + // This prevents destroying the selection when user is typing or pressing Enter + if (isUserInputRef.current) { + isUserInputRef.current = false // Reset flag + return // Skip innerHTML update to preserve selection + } + const html = valueToHtml(inputValue) if (textAreaRef.current.innerHTML !== html) { textAreaRef.current.innerHTML = html @@ -990,6 +924,7 @@ export const ChatTextArea = forwardRef( resetHistoryNavigation() handleSend() + return } if (handleHistoryNavigation(event, showContextMenu, isComposing)) { @@ -1065,8 +1000,11 @@ export const ChatTextArea = forwardRef( useLayoutEffect(() => { if (intendedCursorPosition !== null) { - setCaretPosition(intendedCursorPosition) - setIntendedCursorPosition(null) + // Use setTimeout to ensure this runs after the DOM is fully updated + setTimeout(() => { + setCaretPosition(intendedCursorPosition) + setIntendedCursorPosition(null) + }, 0) } }, [inputValue, intendedCursorPosition, setCaretPosition]) @@ -1075,6 +1013,7 @@ export const ChatTextArea = forwardRef( const handleInputChange = useCallback(() => { const newValue = getPlainTextFromInput() setInputValue(newValue) + isUserInputRef.current = true // Mark this as user input using ref resetOnInputChange() const newCursorPosition = getCaretPosition() diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index b01cea95b6..d9f6e51dbd 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1686,7 +1686,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction @@ -2062,7 +2062,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction +
{/* Top section: Title/Subtitle left, Icons right */}
diff --git a/webview-ui/src/components/chat/CommandExecution.tsx b/webview-ui/src/components/chat/CommandExecution.tsx index 7d0d0bdfec..bb1119403a 100644 --- a/webview-ui/src/components/chat/CommandExecution.tsx +++ b/webview-ui/src/components/chat/CommandExecution.tsx @@ -194,7 +194,7 @@ export const CommandExecution = ({
)} - {onPrimaryButtonClick && onSecondaryButtonClick && ( + {onPrimaryButtonClick && onSecondaryButtonClick && enableButtons && (
{task.images && task.images.length > 0 && } @@ -256,88 +253,4 @@ const KiloTaskHeader = ({ ) } -/** - * Highlights slash-command in this text if it exists - */ -const highlightSlashCommands = (text: string, withShadow = true, customModes?: any[]) => { - const match = text.match(/^\s*\/([a-zA-Z0-9_-]+)(\s*|$)/) - if (!match) { - return text - } - - const commandName = match[1] - const validationResult = validateSlashCommand(commandName, customModes) - - if (!validationResult || validationResult !== "full") { - return text - } - - const commandEndIndex = match[0].length - const beforeCommand = text.substring(0, text.indexOf("/")) - const afterCommand = match[2] + text.substring(commandEndIndex) - - return [ - beforeCommand, - - /{commandName} - , - afterCommand, - ] -} - -/** - * Highlights & formats all mentions inside this text - */ -export const highlightMentions = (text: string, withShadow = true) => { - const parts = text.split(mentionRegexGlobal) - - return parts.map((part, index) => { - if (index % 2 === 0) { - // This is regular text - return part - } else { - // This is a mention - return ( - vscode.postMessage({ type: "openMention", text: part })}> - @{part} - - ) - } - }) -} - -/** - * Handles parsing both mentions and slash-commands - */ -export const highlightText = (text?: string, withShadow = true, customModes?: any[]) => { - if (!text) { - return text - } - - const resultWithSlashHighlighting = highlightSlashCommands(text, withShadow, customModes) - - if (resultWithSlashHighlighting === text) { - // no highlighting done - return highlightMentions(resultWithSlashHighlighting, withShadow) - } - - if (Array.isArray(resultWithSlashHighlighting) && resultWithSlashHighlighting.length === 3) { - const [beforeCommand, commandElement, afterCommand] = resultWithSlashHighlighting as [ - string, - JSX.Element, - string, - ] - - return [beforeCommand, commandElement, ...highlightMentions(afterCommand, withShadow)] - } - - return [text] -} - export default memo(KiloTaskHeader) diff --git a/webview-ui/src/utils/chat-render.ts b/webview-ui/src/utils/chat-render.ts new file mode 100644 index 0000000000..ec907d62ae --- /dev/null +++ b/webview-ui/src/utils/chat-render.ts @@ -0,0 +1,146 @@ +import { mentionRegexGlobal, unescapeSpaces } from "@roo/context-mentions" +import { getIconForFilePath, getIconUrlByName } from "vscode-material-icons" + +export interface MentionChipParts { + primary: string + meta: string[] +} + +export const formatMentionChipParts = (rawMention: string): MentionChipParts => { + const mention = unescapeSpaces(rawMention) + + if (/^\w+:\/\/\S+/.test(mention)) { + try { + const url = new URL(mention) + const meta = url.pathname.replace(/^\/+/, "") + return { + primary: url.hostname || mention, + meta: meta ? [meta] : [], + } + } catch { + return { primary: mention, meta: [] } + } + } + + if (mention === "problems" || mention === "terminal") { + return { primary: mention, meta: [] } + } + + if (/^[a-f0-9]{7,40}$/i.test(mention)) { + return { primary: mention.slice(0, 7), meta: ["commit"] } + } + + if (!mention.startsWith("/")) { + return { primary: mention, meta: [] } + } + + let pathPart = mention + let lineInfo: string | undefined + + const hashMatch = mention.match(/^(.*)#L(\d+(?:-\d+)?)/) + if (hashMatch) { + pathPart = hashMatch[1] + lineInfo = `L${hashMatch[2]}` + } else { + const lastColonIndex = mention.lastIndexOf(":") + if (lastColonIndex > mention.lastIndexOf("/")) { + const maybeRange = mention.slice(lastColonIndex + 1) + if (/^\d+(?:-\d+)?$/.test(maybeRange)) { + pathPart = mention.slice(0, lastColonIndex) + lineInfo = `L${maybeRange}` + } + } + } + + const segments = pathPart.split("/").filter(Boolean) + const primary = segments.pop() || "/" + const parent = segments.length ? segments[segments.length - 1] : "" + + const metaParts = [] + if (parent) metaParts.push(parent) + if (lineInfo) metaParts.push(lineInfo) + + return { primary, meta: metaParts } +} + +export const escapeHtml = (value: string): string => + value.replace(/[&<>"']/g, (char) => { + const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + } + return map[char] || char + }) + +export const getFileIconForMention = (rawMention: string, materialIconsBaseUri: string): string => { + const mention = unescapeSpaces(rawMention) + const filename = mention.split("/").pop() || "" + + if (filename.includes(".")) { + const iconName = getIconForFilePath(filename) + return getIconUrlByName(iconName, materialIconsBaseUri) + } + + return "" +} + +export const renderMentionChip = ( + rawMention: string, + materialIconsBaseUri: string, + isCompactFile: boolean = false, +): string => { + const displayText = isCompactFile ? rawMention : formatMentionChipParts(rawMention).primary || rawMention + const escapedPrimary = escapeHtml(displayText) + const label = escapeHtml(`${isCompactFile ? rawMention : unescapeSpaces(rawMention)}`) + const mentionValue = escapeHtml(`@${isCompactFile ? rawMention : unescapeSpaces(rawMention)}`) + + const fileIconUrl = getFileIconForMention(rawMention, materialIconsBaseUri) + const iconHtml = fileIconUrl ? `` : "" + + return `${iconHtml}${escapedPrimary}` +} + +export const valueToHtml = ( + value: string, + materialIconsBaseUri: string, + mentionMap: Map, + customModes: any[] = [], +): string => { + let processedText = escapeHtml(value || "") + + processedText = processedText + .replace(/\n/g, '
') + .replace(/@([a-zA-Z0-9_.-]+\.[a-zA-Z0-9]+)(?=\s|$)/g, (_match, filename) => { + if (mentionMap.has(filename)) { + return renderMentionChip(filename, materialIconsBaseUri, true) + } + return _match + }) + .replace(mentionRegexGlobal, (_match, mention) => renderMentionChip(mention, materialIconsBaseUri, false)) + + if (/^\s*\//.test(processedText)) { + const slashIndex = processedText.indexOf("/") + const spaceIndex = processedText.indexOf(" ", slashIndex) + const endIndex = spaceIndex > -1 ? spaceIndex : processedText.length + const commandText = processedText.substring(slashIndex + 1, endIndex) + + const isValidCommand = validateSlashCommand(commandText, customModes) + + if (isValidCommand) { + const fullCommand = processedText.substring(slashIndex, endIndex) + const highlighted = `${fullCommand}` + processedText = processedText.substring(0, slashIndex) + highlighted + processedText.substring(endIndex) + } + } + + return processedText || '
' +} + +const validateSlashCommand = (commandText: string, customModes: any[]): boolean => { + const validCommands = ["newtask", "loadtask", "mode", "settings", "help"] + const modeCommands = customModes?.map((mode) => mode.slug) || [] + return [...validCommands, ...modeCommands].includes(commandText) +}