diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 17c53f40d4..b30a97da27 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -128,6 +128,10 @@ export const stories: Record = { const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture") return }, + "chat-row-conversation": async () => { + const { ChatRowConversationStory } = await import("@/components/chat/__tests__/ChatRow.visual.fixture") + return + }, "history-empty": async () => { const [{ AppProviders }, { default: HistoryView }] = await Promise.all([ import("../AppProviders"), diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 952322084f..f14c84e025 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -58,7 +58,6 @@ import { Eye, FileDiff, ListTree, - User, Edit, Trash2, MessageCircleQuestionMark, @@ -67,7 +66,6 @@ import { PocketKnife, FolderTree, SquareTerminal, - MessageCircle, Repeat2, Split, ArrowRight, @@ -1207,38 +1205,36 @@ export const ChatRowContent = ({ return null // we should never see this message type case "text": return ( -
-
- - {t("chat:text.rooSaid")} -
- -
-
- - {message.images && message.images.length > 0 && ( -
- {message.images.map((image, index) => ( - - ))} -
- )} +
+
+
+ +
+
+ + {message.images && message.images.length > 0 && ( +
+ {message.images.map((image, index) => ( + + ))} +
+ )} +
) case "user_feedback": return ( -
-
- - {t("chat:feedback.youSaid")} -
+
{isEditing ? (
@@ -1261,50 +1257,66 @@ export const ChatRowContent = ({ />
) : ( -
-
{ - e.stopPropagation() - if (!isStreaming) { - handleEditClick() - } - }} - title={t("chat:queuedMessages.clickToEdit")}> - -
-
-
{ - e.stopPropagation() - handleEditClick() - }}> - -
-
{ - e.stopPropagation() - vscode.postMessage({ type: "deleteMessage", value: message.ts }) - }}> - -
-
+
{ + e.stopPropagation() + if (!isStreaming) { + handleEditClick() + } + }} + onKeyDown={(e) => { + if (isStreaming) { + return + } + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleEditClick() + } + }} + title={t("chat:queuedMessages.clickToEdit")}> +
)} {!isEditing && message.images && message.images.length > 0 && ( )}
+ {!isEditing && ( +
+ + +
+ )}
) case "user_feedback_diff": const tool = safeJsonParse(message.text) return ( -
+
{ vscode.postMessage({ type: "openMention", text: part })}> + onClick={(event) => { + // Keep a mention click from bubbling into the surrounding chat row, where it + // would otherwise also trigger the row's click-to-edit handler. + event.stopPropagation() + vscode.postMessage({ type: "openMention", text: part }) + }}> @{part} ) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.user-feedback-bubble.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.user-feedback-bubble.spec.tsx new file mode 100644 index 0000000000..13b6e26280 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.user-feedback-bubble.spec.tsx @@ -0,0 +1,318 @@ +import React from "react" +import userEvent from "@testing-library/user-event" +import { fireEvent, renderWithExtensionState, screen } from "@/utils/test-utils" +import type { ClineMessage } from "@roo-code/types" +import { ChatRowContent } from "../ChatRow" + +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ children }: { children?: React.ReactNode }) => <>{children}, + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock CodeBlock (avoid ESM/highlighter costs) +vi.mock("@src/components/common/CodeBlock", () => ({ + default: () => null, +})) + +// Mock useSelectedModel so the hook has a stable default +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: () => ({ info: { supportsImages: true } }), +})) + +const makeUserFeedback = (): ClineMessage => + ({ ts: 1, type: "say", say: "user_feedback", text: "hello bubble" }) as ClineMessage + +function renderRow(message: ClineMessage, isExpanded = false, isStreaming = false) { + return renderWithExtensionState( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, + ) +} + +describe("ChatRow - user feedback bubble layout & contrast", () => { + beforeEach(() => { + vi.clearAllMocks() + mockPostMessage.mockClear() + }) + + it("lays the user feedback row out as a right-aligned bubble", () => { + const { container } = renderRow(makeUserFeedback()) + + // The wrapper is the bubble container + const bubbleContainer = container.querySelector(".ml-auto") as HTMLElement | null + expect(bubbleContainer).toBeTruthy() + expect(bubbleContainer!.className).toContain("w-fit") + expect(bubbleContainer!.className).toContain("max-w-[70%]") + expect(bubbleContainer!.className).toContain("items-end") + expect(bubbleContainer!.className).toContain("flex-col") + }) + + it("uses a soft themed background instead of the inverted foreground color", () => { + const { container } = renderRow(makeUserFeedback()) + const bubble = container.querySelector(".cursor-text") as HTMLElement | null + + expect(bubble).toBeTruthy() + expect(bubble!.className).toContain("bg-vscode-list-hoverBackground") + expect(bubble!.className).toContain("text-vscode-foreground") + // The previous implementation inverted foreground/background, causing harsh contrast. + expect(bubble!.className).not.toContain("bg-vscode-editor-foreground/70") + expect(bubble!.className).not.toContain("text-vscode-editor-background") + }) + + it("still renders the message text", () => { + const { container } = renderRow(makeUserFeedback()) + const bubble = container.querySelector(".cursor-text") as HTMLElement | null + + expect(bubble).toBeTruthy() + expect(bubble!.textContent).toContain("hello bubble") + }) + + it("places keyboard-operable edit/delete action buttons outside the bubble and wires them up", () => { + const { container } = renderRow(makeUserFeedback()) + + // Both controls are real buttons with translated accessible names, so + // keyboard and screen-reader users can reach them. + const editButton = screen.getByRole("button", { name: "common:confirmation.editMessage" }) + const deleteButton = screen.getByRole("button", { name: "common:confirmation.deleteMessage" }) + expect(editButton.tagName).toBe("BUTTON") + expect(deleteButton.tagName).toBe("BUTTON") + + // The bubble must NOT contain the edit/delete controls. + const bubble = container.querySelector(".cursor-text") as HTMLElement | null + expect(bubble).toBeTruthy() + expect(bubble!).not.toContainElement(editButton) + expect(bubble!).not.toContainElement(deleteButton) + + // Clicking delete forwards the message timestamp to the extension host. + fireEvent.click(deleteButton) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "deleteMessage", value: 1 }) + + // Clicking edit enters edit mode: the bubble switches to the editor background. + fireEvent.click(editButton) + const editBubble = container.querySelector(".border.rounded-sm") as HTMLElement | null + expect(editBubble).toBeTruthy() + expect(editBubble!.className).toContain("bg-vscode-editor-background") + }) + + describe.each([ + { name: "Enter", key: "Enter", input: "{Enter}" }, + { name: "Space", key: " ", input: " " }, + ])("$name keyboard activation", ({ key, input }) => { + const originalFocusDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "focus")! + + beforeEach(() => { + // vitest.setup.ts replaces focus with a no-op for FAST components. Borrow + // JSDOM's native focus from a fresh realm so keyboard navigation works here. + const frame = document.createElement("iframe") + document.body.appendChild(frame) + const nativeFocus = frame.contentDocument!.createElement("button").focus + frame.remove() + Object.defineProperty(HTMLElement.prototype, "focus", { + configurable: true, + writable: true, + value: nativeFocus, + }) + }) + + afterEach(() => { + Object.defineProperty(HTMLElement.prototype, "focus", originalFocusDescriptor) + }) + + it("enters edit mode from the message text", async () => { + const user = userEvent.setup() + renderRow(makeUserFeedback()) + + await user.tab() + expect(screen.getByRole("button", { name: "hello bubble" })).toHaveFocus() + await user.keyboard(input) + + expect(screen.getByRole("textbox")).toHaveValue("hello bubble") + expect(screen.queryByRole("button", { name: "hello bubble" })).not.toBeInTheDocument() + }) + + it("does not enter edit mode from the message text while streaming", () => { + renderRow(makeUserFeedback(), false, true) + const messageControl = screen.getByRole("button", { name: "hello bubble" }) + expect(messageControl).toHaveAttribute("aria-disabled", "true") + expect(messageControl).toHaveAttribute("tabindex", "-1") + + // Dispatch directly to exercise the guard even though streaming removes the tab stop. + fireEvent.keyDown(messageControl, { key }) + + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + expect(messageControl).toBeInTheDocument() + expect(messageControl).toHaveTextContent("hello bubble") + }) + + it("activates the edit action button", async () => { + const user = userEvent.setup() + renderRow(makeUserFeedback()) + + await user.tab() + await user.tab() + expect(screen.getByRole("button", { name: "common:confirmation.editMessage" })).toHaveFocus() + await user.keyboard(input) + + expect(screen.getByRole("textbox")).toHaveValue("hello bubble") + }) + + it("activates the delete action button without entering edit mode", async () => { + const user = userEvent.setup() + renderRow(makeUserFeedback()) + + await user.tab() + await user.tab() + await user.tab() + expect(screen.getByRole("button", { name: "common:confirmation.deleteMessage" })).toHaveFocus() + await user.keyboard(input) + + expect(mockPostMessage).toHaveBeenCalledWith({ type: "deleteMessage", value: 1 }) + expect(screen.queryByRole("textbox")).not.toBeInTheDocument() + }) + }) + + it("stays out of edit mode and hides edit/delete actions while streaming", () => { + const { container } = renderRow(makeUserFeedback(), false, true) + + // Clicking the bubble must not enter edit mode while streaming. + const clickTarget = container.querySelector('[title="chat:queuedMessages.clickToEdit"]') as HTMLElement | null + expect(clickTarget).toBeTruthy() + fireEvent.click(clickTarget!) + + // The bubble keeps its resting (non-edit) treatment instead of switching to + // the editor background used in edit mode. + const bubbleWrapper = container.querySelector(".border.rounded-sm") as HTMLElement | null + expect(bubbleWrapper).toBeTruthy() + expect(bubbleWrapper!.className).toContain("cursor-text") + expect(bubbleWrapper!.className).toContain("bg-vscode-list-hoverBackground") + expect(bubbleWrapper!.className).not.toContain("bg-vscode-editor-background") + + // The edit/delete controls remain mounted but hidden via visibility: hidden. + const editButton = screen.getByLabelText("common:confirmation.editMessage") + const deleteButton = screen.getByLabelText("common:confirmation.deleteMessage") + expect(editButton.style.visibility).toBe("hidden") + expect(deleteButton.style.visibility).toBe("hidden") + }) + + it("uses the editor background for the bubble while editing", () => { + const { container } = renderRow(makeUserFeedback()) + + // Enter edit mode by clicking the message text (the clickable inner bubble) + const bubble = container.querySelector('[title="chat:queuedMessages.clickToEdit"]') as HTMLElement | null + expect(bubble).toBeTruthy() + fireEvent.click(bubble!) + + // In edit mode the bubble switches to editor background/foreground and + // no longer uses the soft list-hover treatment. + const editBubble = container.querySelector(".border.rounded-sm") as HTMLElement | null + expect(editBubble).toBeTruthy() + expect(editBubble!.className).toContain("bg-vscode-editor-background") + expect(editBubble!.className).toContain("text-vscode-editor-foreground") + expect(editBubble!.className).not.toContain("bg-vscode-list-hoverBackground") + + expect(screen.queryByLabelText("common:confirmation.editMessage")).not.toBeInTheDocument() + expect(screen.queryByLabelText("common:confirmation.deleteMessage")).not.toBeInTheDocument() + }) + + it("renders the feedback bubble without a header label", () => { + renderRow(makeUserFeedback()) + // The header label ("you said") must not be rendered for user feedback + expect(screen.queryByText("chat:feedback.youSaid")).not.toBeInTheDocument() + expect(screen.queryByLabelText("User icon")).not.toBeInTheDocument() + }) + + it("opens a mention without also entering edit mode", () => { + const { container } = renderRow({ + ts: 1, + type: "say", + say: "user_feedback", + text: "please check @problems for details", + }) + + fireEvent.click(screen.getByText("@problems")) + + // The mention click opens the mention... + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMention", text: "problems" }) + // ...and must not bubble into the row's click-to-edit handler. + const bubbleWrapper = container.querySelector(".border.rounded-sm") as HTMLElement | null + expect(bubbleWrapper).toBeTruthy() + expect(bubbleWrapper!.className).not.toContain("bg-vscode-editor-background") + }) +}) + +describe("ChatRow - assistant text and user edits", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders assistant markdown without a role header", () => { + renderRow({ ts: 2, type: "say", say: "text", text: "## Updated greeting\n\nHello **Zoo**." }) + + expect(screen.getByRole("heading", { name: "Updated greeting" })).toBeInTheDocument() + expect(screen.getByText("Zoo", { selector: "strong" })).toBeInTheDocument() + expect(screen.queryByText("chat:text.rooSaid")).not.toBeInTheDocument() + expect(screen.queryByLabelText("Speech bubble icon")).not.toBeInTheDocument() + }) + + it("anchors the preview button to the assistant content and opens that markdown", () => { + const markdown = "## Updated greeting\n\nHello Zoo.\n\n## Verification\n\nTests pass." + renderRow({ ts: 2, type: "say", say: "text", text: markdown }) + + const preview = screen.getByRole("button", { name: "Open markdown in preview" }) + const previewPosition = preview.parentElement + expect(previewPosition).toHaveClass("absolute", "top-0", "right-0", "z-10") + const content = previewPosition?.parentElement + expect(content).toHaveClass("relative", "pl-6") + expect(content).toContainElement(screen.getByRole("heading", { name: "Updated greeting" })) + expect(content).toContainElement(screen.getByRole("heading", { name: "Verification" })) + + fireEvent.click(preview) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "openMarkdownPreview", text: markdown }) + }) + + it("omits the preview button for simple assistant text", () => { + renderRow({ ts: 2, type: "say", say: "text", text: "The greeting is updated." }) + + expect(screen.getByText("The greeting is updated.")).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Open markdown in preview" })).not.toBeInTheDocument() + }) + + it("keeps the user edits accordion inside the right-aligned width constraint", () => { + const { container } = renderRow( + { + ts: 3, + type: "say", + say: "user_feedback_diff", + text: JSON.stringify({ diff: "@@ -1 +1 @@\n-Hello world\n+Hello Zoo" }), + }, + true, + ) + + const diffContainer = container.firstElementChild + expect(diffContainer).toHaveClass("max-w-[70%]", "ml-auto") + expect(diffContainer).toHaveStyle({ width: "100%", marginTop: "-10px" }) + expect(diffContainer).toContainElement(screen.getByText("User Edits")) + expect(diffContainer).toContainElement(screen.getByText("Hello Zoo")) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.visual.fixture.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.visual.fixture.tsx new file mode 100644 index 0000000000..67fd99ba07 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.visual.fixture.tsx @@ -0,0 +1,44 @@ +import React, { useState } from "react" +import type { ClineMessage } from "@roo-code/types" + +import { AppProviders } from "../../../../playwright/AppProviders" +import ChatRow from "../ChatRow" + +const messages: ClineMessage[] = [ + { ts: 1, type: "say", say: "user_feedback", text: "Please update the greeting and explain the change." }, + { + ts: 2, + type: "say", + say: "user_feedback_diff", + text: JSON.stringify({ diff: "@@ -1 +1 @@\n-Hello world\n+Hello Zoo" }), + }, + { + ts: 3, + type: "say", + say: "text", + text: "## Updated greeting\n\nThe greeting now welcomes **Zoo**.\n\n## Verification\n\nThe rest of the message is unchanged.", + }, +] + +export function ChatRowConversationStory() { + const [expanded, setExpanded] = useState(true) + + return ( + +
+ {messages.map((message) => ( +
+ setExpanded((previous) => !previous)} + onHeightChange={() => undefined} + /> +
+ ))} +
+
+ ) +} diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.visual.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.visual.tsx new file mode 100644 index 0000000000..4acff463f8 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatRow.visual.tsx @@ -0,0 +1,32 @@ +import { expect, test } from "../../../../playwright/coverage-fixture" +import { mountedStory } from "../../../../playwright/mounted-story" +import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" + +// The production ChatRow dependency graph can take over 30s to compile on a cold gallery server. +test.setTimeout(120_000) + +const messagesUserText = "Please update the greeting and explain the change." + +for (const theme of visualThemes.filter(({ name }) => name === "dark" || name === "light")) { + test(`renders user, diff and assistant rows in the ${theme.name} theme`, async ({ page }) => { + await page.goto("/") + await page.waitForFunction(() => typeof window.mount === "function") + // DiffView reads the theme on initialization, so apply it before the only mount. + await applyVisualTheme(page, theme) + await page.evaluate(() => window.mount({ story: "chat-row-conversation" })) + const component = mountedStory(page.locator("#root")) + const conversation = component.getByTestId("chat-row-conversation") + await expect(conversation.getByRole("heading", { name: "Verification" })).toBeVisible() + await expect(component.getByTestId("chat-row-user_feedback_diff").getByText("Hello Zoo")).toBeVisible() + + // Capture both hover states so the action row and preview placement are visible. + await component.getByTestId("chat-row-user_feedback").getByText(messagesUserText).hover() + await expect(conversation).toHaveScreenshot(`chat-row-user-actions-${theme.name}.png`) + await component + .getByTestId("chat-row-text") + .locator(".group") + .hover({ position: { x: 2, y: 12 } }) + await expect(conversation.getByRole("button", { name: "Open markdown in preview" })).toHaveCSS("opacity", "1") + await expect(conversation).toHaveScreenshot(`chat-row-assistant-preview-${theme.name}.png`) + }) +} diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-dark.png new file mode 100644 index 0000000000..4c972fc074 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-light.png new file mode 100644 index 0000000000..ec14bc55a8 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-assistant-preview-light.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-dark.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-dark.png new file mode 100644 index 0000000000..6f1804f555 Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-dark.png differ diff --git a/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-light.png b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-light.png new file mode 100644 index 0000000000..fa46faeafd Binary files /dev/null and b/webview-ui/src/components/chat/__tests__/__screenshots__/chat-row-user-actions-light.png differ