Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ export const stories: Record<string, Story> = {
const { ChatTextAreaStory } = await import("@/components/chat/__tests__/ChatTextArea.visual.fixture")
return <ChatTextAreaStory />
},
"chat-row-conversation": async () => {
const { ChatRowConversationStory } = await import("@/components/chat/__tests__/ChatRow.visual.fixture")
return <ChatRowConversationStory />
},
"history-empty": async () => {
const [{ AppProviders }, { default: HistoryView }] = await Promise.all([
import("../AppProviders"),
Expand Down
89 changes: 40 additions & 49 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@
Eye,
FileDiff,
ListTree,
User,
Edit,
Trash2,
MessageCircleQuestionMark,
Expand All @@ -67,7 +66,6 @@
PocketKnife,
FolderTree,
SquareTerminal,
MessageCircle,
Repeat2,
Split,
ArrowRight,
Expand Down Expand Up @@ -1208,13 +1206,10 @@
case "text":
return (
<div className="group">
<div style={headerStyle}>
<MessageCircle className="w-4 shrink-0" aria-label="Speech bubble icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
<div style={{ flexGrow: 1 }} />
<OpenMarkdownPreviewButton markdown={message.text} />
</div>
<div className="pl-6">
<div className="pl-6 relative">
<div className="absolute top-0 right-0 z-10">
<OpenMarkdownPreviewButton markdown={message.text} />
</div>
<Markdown markdown={message.text} partial={message.partial} />
{message.images && message.images.length > 0 && (
<div style={{ marginTop: "10px" }}>
Expand All @@ -1228,17 +1223,13 @@
)
case "user_feedback":
return (
<div className="group">
<div style={headerStyle}>
<User className="w-4 shrink-0" aria-label="User icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:feedback.youSaid")}</span>
</div>
<div className="group w-fit max-w-[70%] ml-auto flex flex-col items-end gap-1">
<div
className={cn(
"ml-6 border rounded-sm overflow-hidden whitespace-pre-wrap",
"border rounded-sm overflow-hidden whitespace-pre-wrap",
isEditing
? "bg-vscode-editor-background text-vscode-editor-foreground"
: "cursor-text p-1 bg-vscode-editor-foreground/70 text-vscode-editor-background",
: "cursor-text p-1 bg-vscode-list-hoverBackground text-vscode-foreground",
)}>
{isEditing ? (
<div className="flex flex-col gap-2">
Expand All @@ -1261,50 +1252,50 @@
/>
</div>
) : (
<div className="flex justify-between">
<div
className="flex-grow px-2 py-1 wrap-anywhere rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation()
if (!isStreaming) {
handleEditClick()
}
}}
title={t("chat:queuedMessages.clickToEdit")}>
<Mention text={message.text} withShadow />
</div>
<div className="flex gap-2 pr-1">
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}
onClick={(e) => {
e.stopPropagation()
handleEditClick()
}}>
<Edit className="w-4 shrink-0" aria-label="Edit message icon" />
</div>
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}
onClick={(e) => {
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<Trash2 className="w-4 shrink-0" aria-label="Delete message icon" />
</div>
</div>
<div
className="px-2 py-1 wrap-anywhere rounded-lg transition-colors"
onClick={(e) => {
e.stopPropagation()

Check warning on line 1258 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1258: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.

Check warning on line 1258 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1258: Survived CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
if (!isStreaming) {
handleEditClick()
}
}}
title={t("chat:queuedMessages.clickToEdit")}>
<Mention text={message.text} withShadow />
</div>
)}
{!isEditing && message.images && message.images.length > 0 && (
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
{!isEditing && (
<div className="flex gap-2 pr-1">
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}

Check warning on line 1275 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1275: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

Check warning on line 1275 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1275: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
onClick={(e) => {

Check warning on line 1276 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1276: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 1276 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1276: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
e.stopPropagation()

Check warning on line 1277 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1277: NoCoverage CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.

Check warning on line 1277 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1277: NoCoverage CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
handleEditClick()

Check warning on line 1278 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1278: NoCoverage CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.

Check warning on line 1278 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1278: NoCoverage CallExpression mutant (replacement: ;). See the job summary for the complete list and resolution guidance.
}}>
<Edit className="w-4 shrink-0" aria-label="Edit message icon" />
</div>
<div
className="cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ visibility: isStreaming ? "hidden" : "visible" }}

Check warning on line 1284 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1284: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

Check warning on line 1284 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1284: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
onClick={(e) => {

Check warning on line 1285 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1285: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.

Check warning on line 1285 in webview-ui/src/components/chat/ChatRow.tsx

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

webview-ui/src/components/chat/ChatRow.tsx:1285: NoCoverage BlockStatement mutant (replacement: {}). See the job summary for the complete list and resolution guidance.
e.stopPropagation()
vscode.postMessage({ type: "deleteMessage", value: message.ts })
}}>
<Trash2 className="w-4 shrink-0" aria-label="Delete message icon" />
</div>
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
</div>
)
case "user_feedback_diff":
const tool = safeJsonParse<ClineSayTool>(message.text)
return (
<div style={{ marginTop: -10, width: "100%" }}>
<div className="max-w-[70%] ml-auto" style={{ marginTop: -10, width: "100%" }}>
<CodeAccordion
code={tool?.diff}
language="diff"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import React from "react"
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(
<ChatRowContent
message={message}
isExpanded={isExpanded}
isLast={false}
isStreaming={isStreaming}
onToggleExpand={() => {}}
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 edit/delete action buttons outside the bubble", () => {
const { container } = renderRow(makeUserFeedback())

// The action buttons container is a sibling of the bubble, below it
const actionBar = Array.from(container.querySelectorAll("div")).find(
(el) => el.className.includes("flex") && el.className.includes("gap-2") && el.className.includes("pr-1"),
) as HTMLElement | undefined

expect(actionBar).toBeTruthy()
expect(actionBar!.querySelector('[aria-label="Edit message icon"]')).toBeTruthy()
expect(actionBar!.querySelector('[aria-label="Delete message icon"]')).toBeTruthy()

// The bubble must NOT contain the edit/delete icons
const bubble = container.querySelector(".cursor-text") as HTMLElement | null
expect(bubble).toBeTruthy()
expect(bubble!.querySelector('[aria-label="Edit message icon"]')).toBeFalsy()
expect(bubble!.querySelector('[aria-label="Delete message icon"]')).toBeFalsy()
})

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 action icons remain mounted but hidden via visibility: hidden.
const actionBar = Array.from(container.querySelectorAll("div")).find(
(el) => el.className.includes("flex") && el.className.includes("gap-2") && el.className.includes("pr-1"),
) as HTMLElement | undefined
expect(actionBar).toBeTruthy()

const editIconWrapper = actionBar!.querySelector('[aria-label="Edit message icon"]')
?.parentElement as HTMLElement | null
const deleteIconWrapper = actionBar!.querySelector('[aria-label="Delete message icon"]')
?.parentElement as HTMLElement | null
expect(editIconWrapper).toBeTruthy()
expect(deleteIconWrapper).toBeTruthy()
expect(editIconWrapper!.style.visibility).toBe("hidden")
expect(deleteIconWrapper!.style.visibility).toBe("hidden")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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("Edit message icon")).not.toBeInTheDocument()
expect(screen.queryByLabelText("Delete message icon")).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()
})
})

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"))
})
})
Loading
Loading