From 7922443e94159da907511074e290b2b433ba97b8 Mon Sep 17 00:00:00 2001 From: NaccOll Date: Thu, 4 Sep 2025 11:03:53 +0800 Subject: [PATCH] feat: edit/delete user message --- .../checkpoints/__tests__/checkpoint.test.ts | 2 - src/core/checkpoints/index.ts | 17 +- src/core/webview/webviewMessageHandler.ts | 36 +- webview-ui/src/components/chat/ChatRow.tsx | 7 +- .../src/components/chat/ChatTextArea.tsx | 531 +++++++++--------- .../src/components/chat/EditModeControls.tsx | 115 ---- .../chat/__tests__/EditModeControls.spec.tsx | 138 ----- 7 files changed, 290 insertions(+), 556 deletions(-) delete mode 100644 webview-ui/src/components/chat/EditModeControls.tsx delete mode 100644 webview-ui/src/components/chat/__tests__/EditModeControls.spec.tsx diff --git a/src/core/checkpoints/__tests__/checkpoint.test.ts b/src/core/checkpoints/__tests__/checkpoint.test.ts index 0ff228aa45..80b30756b9 100644 --- a/src/core/checkpoints/__tests__/checkpoint.test.ts +++ b/src/core/checkpoints/__tests__/checkpoint.test.ts @@ -317,7 +317,6 @@ describe("Checkpoint functionality", () => { }, ] mockCheckpointService.getDiff.mockResolvedValue(mockChanges) - mockCheckpointService.getCheckpoints = vi.fn(() => ["commit1", "commit2"]) await checkpointDiff(mockTask, { ts: 4, commitHash: "commit1", @@ -343,7 +342,6 @@ describe("Checkpoint functionality", () => { }, ] mockCheckpointService.getDiff.mockResolvedValue(mockChanges) - mockCheckpointService.getCheckpoints = vi.fn(() => ["commit1", "commit2"]) await checkpointDiff(mockTask, { ts: 4, diff --git a/src/core/checkpoints/index.ts b/src/core/checkpoints/index.ts index 5056cf53ee..e6bbc09eb5 100644 --- a/src/core/checkpoints/index.ts +++ b/src/core/checkpoints/index.ts @@ -271,15 +271,16 @@ export async function checkpointDiff(task: Task, { ts, previousCommitHash, commi TelemetryService.instance.captureCheckpointDiffed(task.taskId) let prevHash = commitHash - let nextHash: string | undefined + let nextHash: string | undefined = undefined - const checkpoints = typeof service.getCheckpoints === "function" ? service.getCheckpoints() : [] - const idx = checkpoints.indexOf(commitHash) - - if (idx !== -1 && idx < checkpoints.length - 1) { - nextHash = checkpoints[idx + 1] - } else { - nextHash = undefined + if (mode !== "full") { + const checkpoints = task.clineMessages.filter(({ say }) => say === "checkpoint_saved").map(({ text }) => text!) + const idx = checkpoints.indexOf(commitHash) + if (idx !== -1 && idx < checkpoints.length - 1) { + nextHash = checkpoints[idx + 1] + } else { + nextHash = undefined + } } try { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index d37cad37da..3bc43257a0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -110,9 +110,9 @@ export const webviewMessageHandler = async ( const { messageIndex } = findMessageIndices(messageTs, currentCline) if (messageIndex !== -1) { // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages - .filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs) - .sort((a, b) => b.ts - a.ts) + const checkpoints = currentCline.clineMessages.filter( + (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, + ) hasCheckpoint = checkpoints.length > 0 } else { @@ -153,19 +153,19 @@ export const webviewMessageHandler = async ( // If checkpoint restoration is requested, find and restore to the last checkpoint before this message if (restoreCheckpoint) { // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages - .filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs) - .sort((a, b) => b.ts - a.ts) + const checkpoints = currentCline.clineMessages.filter( + (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, + ) - const lastCheckpoint = checkpoints[0] + const nextCheckpoint = checkpoints[0] - if (lastCheckpoint && lastCheckpoint.text) { + if (nextCheckpoint && nextCheckpoint.text) { await handleCheckpointRestoreOperation({ provider, currentCline, messageTs: targetMessage.ts!, messageIndex, - checkpoint: { hash: lastCheckpoint.text }, + checkpoint: { hash: nextCheckpoint.text }, operation: "delete", }) } else { @@ -221,9 +221,9 @@ export const webviewMessageHandler = async ( const { messageIndex } = findMessageIndices(messageTs, currentCline) if (messageIndex !== -1) { // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages - .filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs) - .sort((a, b) => b.ts - a.ts) + const checkpoints = currentCline.clineMessages.filter( + (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, + ) hasCheckpoint = checkpoints.length > 0 } else { @@ -274,19 +274,19 @@ export const webviewMessageHandler = async ( // If checkpoint restoration is requested, find and restore to the last checkpoint before this message if (restoreCheckpoint) { // Find the last checkpoint before this message - const checkpoints = currentCline.clineMessages - .filter((msg) => msg.say === "checkpoint_saved" && msg.ts < messageTs) - .sort((a, b) => b.ts - a.ts) + const checkpoints = currentCline.clineMessages.filter( + (msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs, + ) - const lastCheckpoint = checkpoints[0] + const nextCheckpoint = checkpoints[0] - if (lastCheckpoint && lastCheckpoint.text) { + if (nextCheckpoint && nextCheckpoint.text) { await handleCheckpointRestoreOperation({ provider, currentCline, messageTs: targetMessage.ts!, messageIndex, - checkpoint: { hash: lastCheckpoint.text }, + checkpoint: { hash: nextCheckpoint.text }, operation: "edit", editData: { editedContent, diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 51cd4bb021..7b3107a2be 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -46,6 +46,7 @@ import { appendImages } from "@src/utils/imageUtils" import { McpExecution } from "./McpExecution" import { ChatTextArea } from "./ChatTextArea" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" +import { useSelectedModel } from "../ui/hooks/useSelectedModel" interface ChatRowProps { message: ClineMessage @@ -115,8 +116,8 @@ export const ChatRowContent = ({ }: ChatRowContentProps) => { const { t } = useTranslation() - const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode } = useExtensionState() - + const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration } = useExtensionState() + const { info: model } = useSelectedModel(apiConfiguration) const [reasoningCollapsed, setReasoningCollapsed] = useState(true) const [isDiffErrorExpanded, setIsDiffErrorExpanded] = useState(false) const [showCopySuccess, setShowCopySuccess] = useState(false) @@ -1184,7 +1185,7 @@ export const ChatRowContent = ({ setSelectedImages={setEditImages} onSend={handleSaveEdit} onSelectImages={handleSelectImages} - shouldDisableImages={false} + shouldDisableImages={!model?.supportsImages} mode={editMode} setMode={setEditMode} modeShortcutText="" diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 8432a8db52..c917797283 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" import { useEvent } from "react-use" import DynamicTextArea from "react-textarea-autosize" -import { VolumeX, Image, WandSparkles, SendHorizontal } from "lucide-react" +import { VolumeX, Image, WandSparkles, SendHorizontal, MessageSquareX } from "lucide-react" import { mentionRegex, mentionRegexGlobal, commandRegexGlobal, unescapeSpaces } from "@roo/context-mentions" import { WebviewMessage } from "@roo/WebviewMessage" @@ -31,7 +31,6 @@ import ContextMenu from "./ContextMenu" import { IndexingStatusBadge } from "./IndexingStatusBadge" import { SlashCommandsPopover } from "./SlashCommandsPopover" import { usePromptHistory } from "./hooks/usePromptHistory" -import { EditModeControls } from "./EditModeControls" interface ChatTextAreaProps { inputValue: string @@ -58,7 +57,6 @@ export const ChatTextArea = forwardRef( { inputValue, setInputValue, - sendingDisabled, selectApiConfigDisabled, placeholderText, selectedImages, @@ -897,261 +895,11 @@ export const ChatTextArea = forwardRef( [setMode], ) - // Helper function to render mode selector - const renderModeSelector = () => ( - - ) - // Helper function to handle API config change const handleApiConfigChange = useCallback((value: string) => { vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) - // Helper function to render non-edit mode controls - const renderNonEditModeControls = () => ( -
-
-
{renderModeSelector()}
- -
- -
-
- -
- {isTtsPlaying && ( - - - - )} - - - - - -
-
- ) - - // Helper function to render the text area section - const renderTextAreaSection = () => ( -
-
- { - if (typeof ref === "function") { - ref(el) - } else if (ref) { - ref.current = el - } - textAreaRef.current = el - }} - value={inputValue} - onChange={(e) => { - handleInputChange(e) - updateHighlights() - }} - onFocus={() => setIsFocused(true)} - onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} - onBlur={handleBlur} - onPaste={handlePaste} - onSelect={updateCursorPosition} - onMouseUp={updateCursorPosition} - onHeightChange={(height) => { - if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { - setTextAreaBaseHeight(height) - } - - onHeightChange?.(height) - }} - placeholder={placeholderText} - minRows={3} - maxRows={15} - autoFocus={true} - className={cn( - "w-full", - "text-vscode-input-foreground", - "font-vscode-font-family", - "text-vscode-editor-font-size", - "leading-vscode-editor-line-height", - "cursor-text", - isEditMode ? "pt-1.5 pb-10 px-2" : "py-1.5 px-2", - isFocused - ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" - : isDraggingOver - ? "border-2 border-dashed border-vscode-focusBorder" - : "border border-transparent", - isDraggingOver - ? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]" - : "bg-vscode-input-background", - "transition-background-color duration-150 ease-in-out", - "will-change-background-color", - "min-h-[90px]", - "box-border", - "rounded", - "resize-none", - "overflow-x-hidden", - "overflow-y-auto", - "pr-9", - "flex-none flex-grow", - "z-[2]", - "scrollbar-none", - "scrollbar-hide", - )} - onScroll={() => updateHighlights()} - /> - -
- - - -
- - {!isEditMode && ( -
- - - -
- )} - - {!inputValue && !isEditMode && ( -
- {placeholderBottomText} -
- )} -
- ) - return (
( "flex-col", "gap-1", "bg-editor-background", - isEditMode ? "px-0" : "px-1.5", + "px-1.5", "pb-1", "outline-none", "border", "border-none", - isEditMode ? "w-full" : "w-[calc(100%-16px)]", + "w-[calc(100%-16px)]", "ml-auto", "mr-auto", "box-border", @@ -1228,23 +976,189 @@ export const ChatTextArea = forwardRef(
)} - {renderTextAreaSection()} -
+
+
+ { + if (typeof ref === "function") { + ref(el) + } else if (ref) { + ref.current = el + } + textAreaRef.current = el + }} + value={inputValue} + onChange={(e) => { + handleInputChange(e) + updateHighlights() + }} + onFocus={() => setIsFocused(true)} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onBlur={handleBlur} + onPaste={handlePaste} + onSelect={updateCursorPosition} + onMouseUp={updateCursorPosition} + onHeightChange={(height) => { + if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { + setTextAreaBaseHeight(height) + } - {isEditMode && ( - - )} + onHeightChange?.(height) + }} + placeholder={placeholderText} + minRows={3} + maxRows={15} + autoFocus={true} + className={cn( + "w-full", + "text-vscode-input-foreground", + "font-vscode-font-family", + "text-vscode-editor-font-size", + "leading-vscode-editor-line-height", + "cursor-text", + "py-1.5 px-2", + isFocused + ? "border border-vscode-focusBorder outline outline-vscode-focusBorder" + : isDraggingOver + ? "border-2 border-dashed border-vscode-focusBorder" + : "border border-transparent", + isDraggingOver + ? "bg-[color-mix(in_srgb,var(--vscode-input-background)_95%,var(--vscode-focusBorder))]" + : "bg-vscode-input-background", + "transition-background-color duration-150 ease-in-out", + "will-change-background-color", + "min-h-[90px]", + "box-border", + "rounded", + "resize-none", + "overflow-x-hidden", + "overflow-y-auto", + "pr-9", + "flex-none flex-grow", + "z-[2]", + "scrollbar-none", + "scrollbar-hide", + )} + onScroll={() => updateHighlights()} + /> + +
+ + + +
+ +
+ {isEditMode && ( + + + + )} + + + +
+ + {!inputValue && ( +
+ {placeholderBottomText} +
+ )} +
+
{selectedImages.length > 0 && ( @@ -1259,7 +1173,80 @@ export const ChatTextArea = forwardRef( /> )} - {!isEditMode && renderNonEditModeControls()} +
+
+
+ +
+
+ +
+
+
+ {isTtsPlaying && ( + + + + )} + {!isEditMode ? : null} + {!isEditMode ? : null} + + + +
+
) }, diff --git a/webview-ui/src/components/chat/EditModeControls.tsx b/webview-ui/src/components/chat/EditModeControls.tsx deleted file mode 100644 index 0246b461fd..0000000000 --- a/webview-ui/src/components/chat/EditModeControls.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react" -import { Mode } from "@roo/modes" -import { Button, StandardTooltip } from "@/components/ui" -import { Image, SendHorizontal } from "lucide-react" -import { cn } from "@/lib/utils" -import { ModeSelector } from "./ModeSelector" -import { useAppTranslation } from "@/i18n/TranslationContext" - -interface EditModeControlsProps { - mode: Mode - onModeChange: (value: Mode) => void - modeShortcutText: string - customModes: any - customModePrompts: any - onCancel?: () => void - onSend: () => void - onSelectImages: () => void - sendingDisabled: boolean - shouldDisableImages: boolean -} - -export const EditModeControls: React.FC = ({ - mode, - onModeChange, - modeShortcutText, - customModes, - customModePrompts, - onCancel, - onSend, - onSelectImages, - sendingDisabled, - shouldDisableImages, -}) => { - const { t } = useAppTranslation() - - return ( -
-
-
- -
-
-
- - - - - - - -
-
- ) -} diff --git a/webview-ui/src/components/chat/__tests__/EditModeControls.spec.tsx b/webview-ui/src/components/chat/__tests__/EditModeControls.spec.tsx deleted file mode 100644 index 2b72202b32..0000000000 --- a/webview-ui/src/components/chat/__tests__/EditModeControls.spec.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React from "react" -import { render, screen, fireEvent } from "@testing-library/react" -import { describe, it, expect, vi, beforeEach } from "vitest" -import { EditModeControls } from "../EditModeControls" -import { Mode } from "@roo/modes" - -// Mock the translation hook -vi.mock("@/i18n/TranslationContext", () => ({ - useAppTranslation: () => ({ - t: (key: string) => key, - }), -})) - -// Mock the UI components -vi.mock("@/components/ui", () => ({ - Button: ({ children, onClick, disabled, ...props }: any) => ( - - ), - StandardTooltip: ({ children, content }: any) =>
{children}
, -})) - -// Mock ModeSelector -vi.mock("../ModeSelector", () => ({ - default: ({ value, onChange, title }: any) => ( - - ), -})) - -describe("EditModeControls", () => { - const defaultProps = { - mode: "code" as Mode, - onModeChange: vi.fn(), - modeShortcutText: "Ctrl+M", - customModes: [], - customModePrompts: {}, - onCancel: vi.fn(), - onSend: vi.fn(), - onSelectImages: vi.fn(), - sendingDisabled: false, - shouldDisableImages: false, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - it("renders all controls correctly", () => { - render() - - // Check for mode selector - expect(screen.getByTitle("chat:selectMode")).toBeInTheDocument() - - // Check for Cancel button - expect(screen.getByText("Cancel")).toBeInTheDocument() - - // Check for image button - expect(screen.getByTitle("chat:addImages")).toBeInTheDocument() - - // Check for send button - expect(screen.getByTitle("chat:save.tooltip")).toBeInTheDocument() - }) - - it("calls onCancel when Cancel button is clicked", () => { - render() - - const cancelButton = screen.getByText("Cancel") - fireEvent.click(cancelButton) - - expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) - }) - - it("calls onSend when send button is clicked", () => { - render() - - const sendButton = screen.getByLabelText("chat:save.tooltip") - fireEvent.click(sendButton) - - expect(defaultProps.onSend).toHaveBeenCalledTimes(1) - }) - - it("calls onSelectImages when image button is clicked", () => { - render() - - const imageButton = screen.getByLabelText("chat:addImages") - fireEvent.click(imageButton) - - expect(defaultProps.onSelectImages).toHaveBeenCalledTimes(1) - }) - - it("disables buttons when sendingDisabled is true", () => { - render() - - const cancelButton = screen.getByText("Cancel") - const sendButton = screen.getByLabelText("chat:save.tooltip") - - expect(cancelButton).toBeDisabled() - expect(sendButton).toBeDisabled() - }) - - it("disables image button when shouldDisableImages is true", () => { - render() - - const imageButton = screen.getByLabelText("chat:addImages") - expect(imageButton).toBeDisabled() - }) - - it("does not call onSelectImages when image button is disabled", () => { - render() - - const imageButton = screen.getByLabelText("chat:addImages") - fireEvent.click(imageButton) - - expect(defaultProps.onSelectImages).not.toHaveBeenCalled() - }) - - it("does not call onSend when send button is disabled", () => { - render() - - const sendButton = screen.getByLabelText("chat:save.tooltip") - fireEvent.click(sendButton) - - expect(defaultProps.onSend).not.toHaveBeenCalled() - }) - - it("calls onModeChange when mode is changed", () => { - render() - - const modeSelector = screen.getByTitle("chat:selectMode") - fireEvent.change(modeSelector, { target: { value: "architect" } }) - - expect(defaultProps.onModeChange).toHaveBeenCalledWith("architect") - }) -})