From d926a771bc7648b8677391762f19af5508f760dc Mon Sep 17 00:00:00 2001 From: Will Li Date: Wed, 9 Jul 2025 13:02:18 -0700 Subject: [PATCH] big UI improvements --- webview-ui/src/components/chat/ChatRow.tsx | 17 +- .../src/components/chat/ChatTextArea.tsx | 45 +++- .../chat/__tests__/ChatTextArea.spec.tsx | 206 ++++++++++++++++++ 3 files changed, 245 insertions(+), 23 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index d43db0c794..60786945df 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -156,11 +156,6 @@ export const ChatRowContent = ({ }) }, [message.ts, editedContent]) - // Handle sending edited message (when Enter is pressed in ChatTextArea) - const handleSendEdit = useCallback(() => { - handleSaveEdit() - }, [handleSaveEdit]) - // Handle image selection for editing const handleSelectImages = useCallback(() => { vscode.postMessage({ type: "selectImages" }) @@ -1059,21 +1054,15 @@ export const ChatRowContent = ({ placeholderText={t("chat:editMessage.placeholder")} selectedImages={editImages} setSelectedImages={setEditImages} - onSend={handleSendEdit} + onSend={handleSaveEdit} onSelectImages={handleSelectImages} shouldDisableImages={false} mode={editMode} setMode={setEditMode} modeShortcutText="" + isEditMode={true} + onCancel={handleCancelEdit} /> -
- - -
) : (
diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index b522b43ecc..b12e9b1961 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -46,6 +46,9 @@ interface ChatTextAreaProps { mode: Mode setMode: (value: Mode) => void modeShortcutText: string + // Edit mode props + isEditMode?: boolean + onCancel?: () => void } const ChatTextArea = forwardRef( @@ -65,6 +68,8 @@ const ChatTextArea = forwardRef( mode, setMode, modeShortcutText, + isEditMode = false, + onCancel, }, ref, ) => { @@ -972,15 +977,27 @@ const ChatTextArea = forwardRef( />
-
- -
+ {isEditMode ? ( +
+ +
+ ) : ( +
+ +
+ )} {!inputValue && (
(
+ {isEditMode && ( + + )} { expect(apiConfigDropdown).toHaveAttribute("disabled") }) }) + describe("edit mode functionality", () => { + it("should show edit button instead of send button when isEditMode is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + // Should show edit button (codicon-edit) + const editButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-edit") !== null + }, + }) + expect(editButton).toBeInTheDocument() + + // Should not show send button (codicon-send) + const sendButton = screen.queryByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-send") !== null + }, + }) + expect(sendButton).not.toBeInTheDocument() + }) + + it("should show send button when isEditMode is false", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + // Should show send button (codicon-send) + const sendButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-send") !== null + }, + }) + expect(sendButton).toBeInTheDocument() + + // Should not show edit button (codicon-edit) + const editButton = screen.queryByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-edit") !== null + }, + }) + expect(editButton).not.toBeInTheDocument() + }) + + it("should show cancel button in edit mode", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + // Should show cancel button with text "Cancel" + const cancelButton = screen.getByRole("button", { name: /cancel/i }) + expect(cancelButton).toBeInTheDocument() + expect(cancelButton).toHaveTextContent("Cancel") + }) + + it("should not show cancel button when not in edit mode", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + // Should not show cancel button + const cancelButton = screen.queryByRole("button", { name: /cancel/i }) + expect(cancelButton).not.toBeInTheDocument() + }) + + it("should call onSend when edit button is clicked", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const editButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-edit") !== null + }, + }) + + fireEvent.click(editButton) + expect(onSend).toHaveBeenCalledTimes(1) + }) + + it("should call onCancel when cancel button is clicked", () => { + const onCancel = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const cancelButton = screen.getByRole("button", { name: /cancel/i }) + + fireEvent.click(cancelButton) + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it("should disable edit button when sendingDisabled is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const editButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-edit") !== null + }, + }) + + expect(editButton).toBeDisabled() + }) + + it("should disable cancel button when sendingDisabled is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const cancelButton = screen.getByRole("button", { name: /cancel/i }) + expect(cancelButton).toBeDisabled() + }) + + it("should have correct tooltip for edit button", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + render() + + const editButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-edit") !== null + }, + }) + + // Check that the button has the correct aria-label (which is used for tooltip) + expect(editButton).toHaveAttribute("aria-label", expect.stringMatching(/save/i)) + }) + + it("should position cancel button correctly relative to camera button", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + taskHistory: [], + cwd: "/test/workspace", + }) + + const { container } = render() + + const cancelButton = screen.getByRole("button", { name: /cancel/i }) + const cameraButton = screen.getByRole("button", { + name: (_, element) => { + return element.querySelector(".codicon-device-camera") !== null + }, + }) + + // Both buttons should be in the same container (bottom toolbar) + const bottomToolbar = container.querySelector(".flex.items-center.gap-0\\.5.shrink-0") + expect(bottomToolbar).toContainElement(cancelButton) + expect(bottomToolbar).toContainElement(cameraButton) + + // Cancel button should come before camera button in DOM order + const buttons = bottomToolbar?.querySelectorAll("button") + const cancelIndex = Array.from(buttons || []).indexOf(cancelButton as HTMLButtonElement) + const cameraIndex = Array.from(buttons || []).indexOf(cameraButton as HTMLButtonElement) + expect(cancelIndex).toBeLessThan(cameraIndex) + }) + }) })