diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 23ec50af37d..be6f21eb300 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -61,6 +61,7 @@ interface ChatRowProps { onFollowUpUnmount?: () => void isFollowUpAnswered?: boolean editable?: boolean + onBeginEdit?: (message: ClineMessage) => void } // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -113,6 +114,7 @@ export const ChatRowContent = ({ onBatchFileResponse, isFollowUpAnswered, editable, + onBeginEdit, }: ChatRowContentProps) => { const { t } = useTranslation() @@ -146,13 +148,15 @@ export const ChatRowContent = ({ // Handle edit button click const handleEditClick = useCallback(() => { + if (onBeginEdit) { + onBeginEdit(message) + return + } setIsEditing(true) setEditedContent(message.text || "") setEditImages(message.images || []) setEditMode(mode || "code") - // Edit mode is now handled entirely in the frontend - // No need to notify the backend - }, [message.text, message.images, mode]) + }, [onBeginEdit, message, mode]) // Handle cancel edit const handleCancelEdit = useCallback(() => { diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index e0528da24cd..dc6fbf19688 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -50,6 +50,7 @@ import Announcement from "./Announcement" import BrowserSessionRow from "./BrowserSessionRow" import ChatRow from "./ChatRow" import { ChatTextArea } from "./ChatTextArea" +import { Mention } from "./Mention" import TaskHeader from "./TaskHeader" import AutoApproveMenu from "./AutoApproveMenu" import SystemPromptWarning from "./SystemPromptWarning" @@ -59,6 +60,7 @@ import { QueuedMessages } from "./QueuedMessages" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" import { Cloud } from "lucide-react" +import { DraftPersistenceProvider, useDraftPersistence } from "./hooks/useDraftPersistence" export interface ChatViewProps { isHidden: boolean @@ -74,11 +76,12 @@ export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit. const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0 -const ChatViewComponent: React.ForwardRefRenderFunction = ( +const ChatViewInner: React.ForwardRefRenderFunction = ( { isHidden, showAnnouncement, hideAnnouncement }, ref, ) => { const isMountedRef = useRef(true) + const { saveCurrentDraft, restoreDraft } = useDraftPersistence() const [audioBaseUri] = useState(() => { const w = window as any @@ -177,6 +180,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null) const [sendingDisabled, setSendingDisabled] = useState(false) const [selectedImages, setSelectedImages] = useState([]) + const [editingOverlay, setEditingOverlay] = useState<{ ts: number; text: string; images: string[] } | null>(null) // we need to hold on to the ask because useEffect > lastMessage will always let us know when an ask comes in and handle it, but by the time handleMessage is called, the last message might not be the ask anymore (it could be a say that followed) const [clineAsk, setClineAsk] = useState(undefined) @@ -773,7 +777,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction vscode.postMessage({ type: "selectImages" }), []) + const selectImages = useCallback(() => { + if (editingOverlay) { + vscode.postMessage({ type: "selectImages", context: "edit", messageTs: editingOverlay.ts }) + } else { + vscode.postMessage({ type: "selectImages" }) + } + }, [editingOverlay]) const shouldDisableImages = !model?.supportsImages || selectedImages.length >= MAX_IMAGES_PER_MESSAGE @@ -795,9 +805,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction + appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE), + ) + } + } else { setSelectedImages((prevImages: string[]) => appendImages(prevImages, message.images, MAX_IMAGES_PER_MESSAGE), ) @@ -841,6 +857,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Save the current draft before starting edit + saveCurrentDraft(inputValue) + + setEditingOverlay({ + ts: message.ts, + text: message.text || "", + images: message.images || [], + }) + setInputValue(message.text || "") + setSelectedImages(message.images || []) + // Focus input when beginning edit + setTimeout(() => textAreaRef.current?.focus(), 0) + }, + [inputValue, saveCurrentDraft], + ) + + const handleCancelEditOverlay = useCallback(() => { + setEditingOverlay(null) + // Restore the draft when canceling edit + const restoredDraft = restoreDraft() + if (restoredDraft !== null) { + setInputValue(restoredDraft) + } else { + setInputValue("") + } + setSelectedImages([]) + setTimeout(() => textAreaRef.current?.focus(), 0) + }, [restoreDraft]) + + const handleSubmitEdited = useCallback(() => { + if (!editingOverlay) return + vscode.postMessage({ + type: "submitEditedMessage", + value: editingOverlay.ts, + editedMessageContent: inputValue, + images: selectedImages, + }) + setEditingOverlay(null) + // Restore the draft after saving edit + const restoredDraft = restoreDraft() + if (restoredDraft !== null) { + setInputValue(restoredDraft) + } else { + setInputValue("") + } + setSelectedImages([]) + }, [editingOverlay, inputValue, selectedImages, restoreDraft]) + // NOTE: the VSCode window needs to be focused for this to work. useMount(() => textAreaRef.current?.focus()) @@ -1560,6 +1628,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ) }, @@ -1577,6 +1646,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + // Handle Escape key to exit editing mode + if (event.key === "Escape" && editingOverlay) { + event.preventDefault() + handleCancelEditOverlay() + return + } + // Check for Command/Ctrl + Period (with or without Shift) // Using event.key to respect keyboard layouts (e.g., Dvorak) if ((event.metaKey || event.ctrlKey) && event.key === ".") { @@ -1741,7 +1818,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction { @@ -1892,7 +1969,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction -
+
-
- -
- {areButtonsVisible && ( -
+ {editingOverlay && ( +
+
+
+ Editing message +
+
+ +
+
+
+ )} +
+ +
+ {areButtonsVisible && ( +
- {showScrollToBottom ? ( - - { - scrollToBottomSmooth() - disableAutoScrollRef.current = false - }}> - - - - ) : ( - <> - {primaryButtonText && !isStreaming && ( - + {showScrollToBottom ? ( + + { + scrollToBottomSmooth() + disableAutoScrollRef.current = false + }}> + + + + ) : ( + <> + {primaryButtonText && !isStreaming && ( + - handlePrimaryButtonClick(inputValue, selectedImages)}> - {primaryButtonText} - - - )} - {(secondaryButtonText || isStreaming) && ( - - handleSecondaryButtonClick(inputValue, selectedImages)}> - {isStreaming ? t("chat:cancel.title") : secondaryButtonText} - - - )} - - )} -
- )} + t("chat:proceedAnyways.title") + ? t("chat:proceedAnyways.tooltip") + : primaryButtonText === + t( + "chat:proceedWhileRunning.title", + ) + ? t( + "chat:proceedWhileRunning.tooltip", + ) + : undefined + }> + + handlePrimaryButtonClick(inputValue, selectedImages) + }> + {primaryButtonText} + + + )} + {(secondaryButtonText || isStreaming) && ( + + + handleSecondaryButtonClick(inputValue, selectedImages) + }> + {isStreaming ? t("chat:cancel.title") : secondaryButtonText} + + + )} + + )} +
+ )} +
)} - { - if (messageQueue[index]) { - vscode.postMessage({ type: "removeQueuedMessage", text: messageQueue[index].id }) - } - }} - onUpdate={(index, newText) => { - if (messageQueue[index]) { - vscode.postMessage({ - type: "editQueuedMessage", - payload: { id: messageQueue[index].id, text: newText, images: messageQueue[index].images }, - }) - } - }} - /> - handleSendMessage(inputValue, selectedImages)} - onSelectImages={selectImages} - shouldDisableImages={shouldDisableImages} - onHeightChange={() => { - if (isAtBottom) { - scrollToBottomAuto() - } - }} - mode={mode} - setMode={setMode} - modeShortcutText={modeShortcutText} - /> - - {isProfileDisabled && ( -
- -
- )} +
+ { + if (messageQueue[index]) { + vscode.postMessage({ type: "removeQueuedMessage", text: messageQueue[index].id }) + } + }} + onUpdate={(index, newText) => { + if (messageQueue[index]) { + vscode.postMessage({ + type: "editQueuedMessage", + payload: { + id: messageQueue[index].id, + text: newText, + images: messageQueue[index].images, + }, + }) + } + }} + /> + handleSendMessage(inputValue, selectedImages)} + onSelectImages={selectImages} + shouldDisableImages={shouldDisableImages} + onHeightChange={() => { + if (isAtBottom) { + scrollToBottomAuto() + } + }} + mode={mode} + setMode={setMode} + modeShortcutText={modeShortcutText} + isEditMode={!!editingOverlay} + onCancel={handleCancelEditOverlay} + /> + + {isProfileDisabled && ( +
+ +
+ )} +
@@ -2048,6 +2162,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction((props, ref) => { + return ( + + + + ) +}) export default ChatView diff --git a/webview-ui/src/components/chat/__tests__/DraftPersistence.spec.tsx b/webview-ui/src/components/chat/__tests__/DraftPersistence.spec.tsx new file mode 100644 index 00000000000..ec155e3eb80 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/DraftPersistence.spec.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import React from "react" +import { DraftPersistenceProvider, useDraftPersistence } from "../hooks/useDraftPersistence" + +// Test component to interact with the draft persistence context +const TestComponent = () => { + const { savedDraft, saveCurrentDraft, restoreDraft, clearDraft } = useDraftPersistence() + const [localDraft, setLocalDraft] = React.useState("") + + return ( +
+ setLocalDraft(e.target.value)} data-testid="input" /> + + + +
{savedDraft || "No draft"}
+
+ ) +} + +describe("DraftPersistence", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should save and restore a draft", async () => { + render( + + + , + ) + + const input = screen.getByTestId("input") + const saveButton = screen.getByTestId("save") + const restoreButton = screen.getByTestId("restore") + const savedDraftDisplay = screen.getByTestId("saved-draft") + + // Initially no draft + expect(savedDraftDisplay.textContent).toBe("No draft") + + // Type some text + fireEvent.change(input, { target: { value: "My draft text" } }) + expect(input).toHaveValue("My draft text") + + // Save the draft + fireEvent.click(saveButton) + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("My draft text") + }) + + // Clear the input + fireEvent.change(input, { target: { value: "" } }) + expect(input).toHaveValue("") + + // Restore the draft + fireEvent.click(restoreButton) + expect(input).toHaveValue("My draft text") + + // After restoring, the saved draft should be cleared + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("No draft") + }) + }) + + it("should clear a draft", async () => { + render( + + + , + ) + + const input = screen.getByTestId("input") + const saveButton = screen.getByTestId("save") + const clearButton = screen.getByTestId("clear") + const savedDraftDisplay = screen.getByTestId("saved-draft") + + // Save a draft + fireEvent.change(input, { target: { value: "Draft to clear" } }) + fireEvent.click(saveButton) + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("Draft to clear") + }) + + // Clear the draft + fireEvent.click(clearButton) + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("No draft") + }) + }) + + it("should handle multiple save operations", async () => { + render( + + + , + ) + + const input = screen.getByTestId("input") + const saveButton = screen.getByTestId("save") + const savedDraftDisplay = screen.getByTestId("saved-draft") + + // Save first draft + fireEvent.change(input, { target: { value: "First draft" } }) + fireEvent.click(saveButton) + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("First draft") + }) + + // Save second draft (overwrites first) + fireEvent.change(input, { target: { value: "Second draft" } }) + fireEvent.click(saveButton) + await waitFor(() => { + expect(savedDraftDisplay.textContent).toBe("Second draft") + }) + }) + + it("should return null when restoring with no saved draft", () => { + render( + + + , + ) + + const input = screen.getByTestId("input") + const restoreButton = screen.getByTestId("restore") + + // Try to restore when no draft is saved + fireEvent.click(restoreButton) + + // Input should remain empty + expect(input).toHaveValue("") + }) + + it("should provide no-op implementation when context is not available", () => { + // Component using the hook outside of provider + const ComponentWithoutProvider = () => { + const { savedDraft, saveCurrentDraft, restoreDraft, clearDraft } = useDraftPersistence() + + return ( +
+
{savedDraft || "null"}
+ + + +
+ ) + } + + render() + + const saved = screen.getByTestId("saved") + expect(saved.textContent).toBe("null") + + // These should not throw errors even without provider + fireEvent.click(screen.getByTestId("save")) + fireEvent.click(screen.getByTestId("restore")) + fireEvent.click(screen.getByTestId("clear")) + + // State should remain unchanged + expect(saved.textContent).toBe("null") + }) +}) diff --git a/webview-ui/src/components/chat/hooks/useDraftPersistence.tsx b/webview-ui/src/components/chat/hooks/useDraftPersistence.tsx new file mode 100644 index 00000000000..3622ef36bf7 --- /dev/null +++ b/webview-ui/src/components/chat/hooks/useDraftPersistence.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from "react" + +interface DraftPersistenceContextType { + savedDraft: string | null + saveCurrentDraft: (draft: string) => void + restoreDraft: () => string | null + clearDraft: () => void +} + +const DraftPersistenceContext = createContext(undefined) + +export const DraftPersistenceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [savedDraft, setSavedDraft] = useState(null) + + const saveCurrentDraft = useCallback((draft: string) => { + setSavedDraft(draft) + }, []) + + const restoreDraft = useCallback(() => { + const draft = savedDraft + setSavedDraft(null) // Clear after restoring + return draft + }, [savedDraft]) + + const clearDraft = useCallback(() => { + setSavedDraft(null) + }, []) + + return ( + + {children} + + ) +} + +export const useDraftPersistence = () => { + const context = useContext(DraftPersistenceContext) + if (!context) { + // Return a no-op implementation if context is not available + return { + savedDraft: null, + saveCurrentDraft: () => {}, + restoreDraft: () => null, + clearDraft: () => {}, + } + } + return context +}