From 48216d3b4ed6a8dc60bc3e478434605257eab5c1 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Wed, 10 Sep 2025 16:53:51 -0500 Subject: [PATCH] feat: add draft persistence for chat input during message editing - Created DraftPersistenceProvider context to manage draft state - Integrated draft saving when starting message edit - Integrated draft restoration when canceling or saving edit - Added automatic cleanup after restoration to prevent memory leaks - Added comprehensive test coverage --- webview-ui/src/components/chat/ChatView.tsx | 62 ++++-- .../chat/__tests__/DraftPersistence.spec.tsx | 180 ++++++++++++++++++ .../chat/hooks/useDraftPersistence.tsx | 48 +++++ 3 files changed, 273 insertions(+), 17 deletions(-) create mode 100644 webview-ui/src/components/chat/__tests__/DraftPersistence.spec.tsx create mode 100644 webview-ui/src/components/chat/hooks/useDraftPersistence.tsx diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index afc41701e9..47c76ca1b6 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -60,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 @@ -75,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 @@ -867,24 +869,36 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - 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) - }, []) + const handleBeginEdit = useCallback( + (message: ClineMessage) => { + // 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) - setInputValue("") + // 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 @@ -895,9 +909,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction textAreaRef.current?.focus()) @@ -2142,6 +2162,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction = (props) => { + 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 0000000000..ec155e3eb8 --- /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 0000000000..3622ef36bf --- /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 +}