feat: add quote selection feature to chat UI

- Add text selection detection with floating quote button in Markdown component
- Implement quote preview above ChatTextArea with dismiss control
- Add keyboard shortcut support (Cmd/Ctrl+Shift+Q) for quick quoting
- Format quoted text with [context] wrapper for AI context
- Add comprehensive tests for quote selection functionality
- Add translation strings for quote feature

Closes #8837
This commit is contained in:
Roo Code 2025-10-26 08:57:32 +00:00
parent f5d7ba1959
commit 8fca912051
9 changed files with 636 additions and 1173 deletions

View file

@ -181,6 +181,10 @@
"task_prompt": "What should Roo do?",
"task_placeholder": "Type your task here"
},
"chat": {
"quotePreview": "Quote",
"quoteSelection": "Quote"
},
"customModes": {
"errors": {
"yamlParseError": "Invalid YAML in .roomodes file at line {{line}}. Please check for:\n• Proper indentation (use spaces, not tabs)\n• Matching quotes and brackets\n• Valid YAML syntax",

View file

@ -70,6 +70,7 @@ export interface ExtensionMessage {
| "theme"
| "workspaceUpdated"
| "invoke"
| "addQuoteToComposer"
| "messageUpdated"
| "mcpServers"
| "enhancedPrompt"

View file

@ -35,6 +35,7 @@ export interface WebviewMessage {
| "deleteApiConfiguration"
| "loadApiConfiguration"
| "loadApiConfigurationById"
| "addQuoteToComposer"
| "renameApiConfiguration"
| "getListApiConfiguration"
| "customInstructions"

View file

@ -1092,7 +1092,7 @@ export const ChatRowContent = ({
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
</div>
<div className="pl-6">
<Markdown markdown={message.text} partial={message.partial} />
<Markdown markdown={message.text} partial={message.partial} messageTs={message.ts} />
{message.images && message.images.length > 0 && (
<div style={{ marginTop: "10px" }}>
{message.images.map((image, index) => (
@ -1201,7 +1201,7 @@ export const ChatRowContent = ({
{title}
</div>
<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
<Markdown markdown={message.text} />
<Markdown markdown={message.text} messageTs={message.ts} />
</div>
</>
)
@ -1355,7 +1355,7 @@ export const ChatRowContent = ({
</div>
)}
<div style={{ paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />
<Markdown markdown={message.text} partial={message.partial} messageTs={message.ts} />
</div>
</>
)
@ -1441,7 +1441,11 @@ export const ChatRowContent = ({
{title}
</div>
<div style={{ color: "var(--vscode-charts-green)", paddingTop: 10 }}>
<Markdown markdown={message.text} partial={message.partial} />
<Markdown
markdown={message.text}
partial={message.partial}
messageTs={message.ts}
/>
</div>
</div>
)
@ -1460,6 +1464,7 @@ export const ChatRowContent = ({
<div className="flex flex-col gap-2 ml-6">
<Markdown
markdown={message.partial === true ? message?.text : followUpData?.question}
messageTs={message.ts}
/>
<FollowUpSuggest
suggestions={followUpData?.suggest}

View file

@ -51,6 +51,9 @@ interface ChatTextAreaProps {
// Edit mode props
isEditMode?: boolean
onCancel?: () => void
// Quote selection props
quotedText?: string
onClearQuote?: () => void
}
export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
@ -71,6 +74,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
modeShortcutText,
isEditMode = false,
onCancel,
quotedText,
onClearQuote,
},
ref,
) => {
@ -913,6 +918,31 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"flex flex-col gap-1 bg-editor-background outline-none border border-none box-border",
isEditMode ? "p-2 w-full" : "relative px-1.5 pb-1 w-[calc(100%-16px)] ml-auto mr-auto",
)}>
{/* Quote preview above text area */}
{quotedText && !isEditMode && (
<div className="mx-1.5 mb-1 p-2 bg-vscode-textCodeBlock-background rounded border border-vscode-widget-border relative">
<button
className="absolute top-1 right-1 p-1 hover:bg-vscode-toolbar-hoverBackground rounded"
onClick={() => onClearQuote?.()}
aria-label="Clear quote">
<span className="codicon codicon-close text-xs" />
</button>
<div className="text-xs text-vscode-descriptionForeground mb-1">{t("chat:quotePreview")}</div>
<div className="text-xs max-h-20 overflow-y-auto pr-6">
{quotedText
.split("\n")
.slice(0, 3)
.map((line, idx) => (
<div key={idx} className="text-vscode-foreground opacity-75">
{line}
</div>
))}
{quotedText.split("\n").length > 3 && (
<div className="text-vscode-descriptionForeground">...</div>
)}
</div>
</div>
)}
<div className={cn(!isEditMode && "relative")}>
<div
className={cn("chat-text-area", !isEditMode && "relative", "flex", "flex-col", "outline-none")}

View file

@ -197,6 +197,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
>(undefined)
const [isCondensing, setIsCondensing] = useState<boolean>(false)
const [showAnnouncementModal, setShowAnnouncementModal] = useState(false)
const [quotedText, setQuotedText] = useState<string>("")
const everVisibleMessagesTsRef = useRef<LRUCache<number, boolean>>(
new LRUCache({
max: 100,
@ -783,6 +784,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const message: ExtensionMessage = e.data
switch (message.type) {
case "addQuoteToComposer":
// Handle quote from message selection
if (message.text) {
// Format the quoted text with [context] wrapper
const formattedQuote = `[context]\n${message.text
.split("\n")
.map((line) => `> ${line}`)
.join("\n")}\n[/context]\n`
setQuotedText(formattedQuote)
}
break
case "action":
switch (message.action!) {
case "didBecomeVisible":
@ -1720,6 +1732,28 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Add keyboard event handler
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Check for Command/Ctrl + Shift + Q for quote selection
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "q") {
event.preventDefault()
// Get current text selection
const selection = window.getSelection()
if (selection && !selection.isCollapsed) {
const selectedText = selection.toString().trim()
if (selectedText) {
// Format the quoted text with [context] wrapper
const formattedQuote = `[context]\n${selectedText
.split("\n")
.map((line) => `> ${line}`)
.join("\n")}\n[/context]\n`
setQuotedText(formattedQuote)
// Clear the selection
selection.removeAllRanges()
}
}
}
// 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 === ".") {
@ -1992,7 +2026,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
placeholderText={placeholderText}
selectedImages={selectedImages}
setSelectedImages={setSelectedImages}
onSend={() => handleSendMessage(inputValue, selectedImages)}
onSend={() => {
// Include quoted text when sending
const finalMessage = quotedText ? quotedText + inputValue : inputValue
handleSendMessage(finalMessage, selectedImages)
setQuotedText("") // Clear quote after sending
}}
onSelectImages={selectImages}
shouldDisableImages={shouldDisableImages}
onHeightChange={() => {
@ -2003,6 +2042,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
mode={mode}
setMode={setMode}
modeShortcutText={modeShortcutText}
quotedText={quotedText}
onClearQuote={() => setQuotedText("")}
/>
{isProfileDisabled && (

View file

@ -1,67 +1,179 @@
import { memo, useState } from "react"
import { memo, useState, useEffect, useRef } from "react"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { MessageSquareQuote } from "lucide-react"
import { useCopyToClipboard } from "@src/utils/clipboard"
import { StandardTooltip } from "@src/components/ui"
import { vscode } from "@src/utils/vscode"
import MarkdownBlock from "../common/MarkdownBlock"
export const Markdown = memo(({ markdown, partial }: { markdown?: string; partial?: boolean }) => {
const [isHovering, setIsHovering] = useState(false)
export const Markdown = memo(
({ markdown, partial, messageTs }: { markdown?: string; partial?: boolean; messageTs?: number }) => {
const [isHovering, setIsHovering] = useState(false)
const [selectedText, setSelectedText] = useState("")
const [quoteButtonPosition, setQuoteButtonPosition] = useState<{ top: number; left: number } | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Shorter feedback duration for copy button flash.
const { copyWithFeedback } = useCopyToClipboard(200)
// Shorter feedback duration for copy button flash.
const { copyWithFeedback } = useCopyToClipboard(200)
if (!markdown || markdown.length === 0) {
return null
}
// Handle text selection
useEffect(() => {
const handleSelectionChange = () => {
const selection = window.getSelection()
if (!selection || selection.isCollapsed || !containerRef.current) {
setSelectedText("")
setQuoteButtonPosition(null)
return
}
return (
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
<MarkdownBlock markdown={markdown} />
</div>
{markdown && !partial && isHovering && (
<div
style={{
position: "absolute",
bottom: "-4px",
right: "8px",
opacity: 0,
animation: "fadeIn 0.2s ease-in-out forwards",
borderRadius: "4px",
}}>
<style>{`@keyframes fadeIn { from { opacity: 0; } to { opacity: 1.0; } }`}</style>
<StandardTooltip content="Copy as markdown">
<VSCodeButton
className="copy-button"
appearance="icon"
style={{
height: "24px",
border: "none",
background: "var(--vscode-editor-background)",
transition: "background 0.2s ease-in-out",
}}
onClick={async () => {
const success = await copyWithFeedback(markdown)
if (success) {
const button = document.activeElement as HTMLElement
if (button) {
button.style.background = "var(--vscode-button-background)"
setTimeout(() => {
button.style.background = ""
}, 200)
}
}
}}>
<span className="codicon codicon-copy" />
</VSCodeButton>
</StandardTooltip>
// Check if selection is within our container
const range = selection.getRangeAt(0)
const commonAncestor = range.commonAncestorContainer
const isInContainer = containerRef.current.contains(commonAncestor)
if (isInContainer) {
const text = selection.toString().trim()
if (text) {
setSelectedText(text)
// Get the selection bounds to position the quote button
const rect = range.getBoundingClientRect()
const containerRect = containerRef.current.getBoundingClientRect()
// Position the button above and centered on the selection
setQuoteButtonPosition({
top: rect.top - containerRect.top - 36, // 36px above selection
left: rect.left - containerRect.left + rect.width / 2 - 16, // centered (button is ~32px wide)
})
} else {
setSelectedText("")
setQuoteButtonPosition(null)
}
} else {
setSelectedText("")
setQuoteButtonPosition(null)
}
}
// Listen for selection changes
document.addEventListener("selectionchange", handleSelectionChange)
return () => {
document.removeEventListener("selectionchange", handleSelectionChange)
}
}, [])
const handleQuoteClick = () => {
if (selectedText) {
// Send message to add quote to composer
vscode.postMessage({
type: "addQuoteToComposer",
text: selectedText,
messageTs: messageTs,
})
// Clear selection
window.getSelection()?.removeAllRanges()
setSelectedText("")
setQuoteButtonPosition(null)
}
}
if (!markdown || markdown.length === 0) {
return null
}
return (
<div
ref={containerRef}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
style={{ position: "relative" }}>
<div style={{ wordBreak: "break-word", overflowWrap: "anywhere" }}>
<MarkdownBlock markdown={markdown} />
</div>
)}
</div>
)
})
{/* Quote button that appears on text selection */}
{selectedText && quoteButtonPosition && !partial && (
<div
style={{
position: "absolute",
top: `${quoteButtonPosition.top}px`,
left: `${quoteButtonPosition.left}px`,
zIndex: 1000,
animation: "fadeIn 0.15s ease-in-out forwards",
}}>
<StandardTooltip content="Quote selection to reply">
<button
onClick={handleQuoteClick}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "4px",
border: "1px solid var(--vscode-widget-border)",
background: "var(--vscode-button-background)",
color: "var(--vscode-button-foreground)",
cursor: "pointer",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.15)",
transition: "all 0.2s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--vscode-button-hoverBackground)"
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--vscode-button-background)"
}}>
<MessageSquareQuote size={16} />
</button>
</StandardTooltip>
</div>
)}
{/* Copy button that appears on hover */}
{markdown && !partial && isHovering && (
<div
style={{
position: "absolute",
bottom: "-4px",
right: "8px",
opacity: 0,
animation: "fadeIn 0.2s ease-in-out forwards",
borderRadius: "4px",
}}>
<style>{`@keyframes fadeIn { from { opacity: 0; } to { opacity: 1.0; } }`}</style>
<StandardTooltip content="Copy as markdown">
<VSCodeButton
className="copy-button"
appearance="icon"
style={{
height: "24px",
border: "none",
background: "var(--vscode-editor-background)",
transition: "background 0.2s ease-in-out",
}}
onClick={async () => {
const success = await copyWithFeedback(markdown)
if (success) {
const button = document.activeElement as HTMLElement
if (button) {
button.style.background = "var(--vscode-button-background)"
setTimeout(() => {
button.style.background = ""
}, 200)
}
}
}}>
<span className="codicon codicon-copy" />
</VSCodeButton>
</StandardTooltip>
</div>
)}
</div>
)
},
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,204 @@
import { render, fireEvent, waitFor } from "@testing-library/react"
import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"
import { Markdown } from "../Markdown"
import React from "react"
// Mock vscode API
const mockPostMessage = vi.fn()
const vscodeApi = {
postMessage: mockPostMessage,
}
// @ts-expect-error - Mocking global window API for testing
global.window.acquireVsCodeApi = vi.fn(() => vscodeApi)
describe("Markdown Quote Selection", () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock getSelection
global.window.getSelection = vi.fn(
() =>
({
toString: vi.fn(() => ""),
removeAllRanges: vi.fn(),
getRangeAt: vi.fn(() => ({
getBoundingClientRect: vi.fn(() => ({
top: 100,
left: 50,
bottom: 120,
right: 200,
width: 150,
height: 20,
})),
})),
rangeCount: 1,
}) as any,
)
})
afterEach(() => {
vi.restoreAllMocks()
})
it("should show quote button when text is selected", async () => {
const { container } = render(
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
)
// Mock text selection
const mockSelection = {
toString: vi.fn(() => "test content"),
removeAllRanges: vi.fn(),
getRangeAt: vi.fn(() => ({
getBoundingClientRect: vi.fn(() => ({
top: 100,
left: 50,
bottom: 120,
right: 200,
width: 150,
height: 20,
})),
})),
rangeCount: 1,
}
global.window.getSelection = vi.fn(() => mockSelection as any)
// Trigger selection change event
const selectionEvent = new Event("selectionchange", { bubbles: true })
document.dispatchEvent(selectionEvent)
// Wait for the quote button to appear
await waitFor(() => {
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
expect(quoteButton).toBeTruthy()
})
})
it("should send addQuoteToComposer message when quote button is clicked", async () => {
const { container } = render(
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
)
// Mock text selection
const selectedText = "test content"
const mockSelection = {
toString: vi.fn(() => selectedText),
removeAllRanges: vi.fn(),
getRangeAt: vi.fn(() => ({
getBoundingClientRect: vi.fn(() => ({
top: 100,
left: 50,
bottom: 120,
right: 200,
width: 150,
height: 20,
})),
})),
rangeCount: 1,
}
global.window.getSelection = vi.fn(() => mockSelection as any)
// Trigger selection change event
const selectionEvent = new Event("selectionchange", { bubbles: true })
document.dispatchEvent(selectionEvent)
// Wait for the quote button to appear
await waitFor(() => {
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
expect(quoteButton).toBeTruthy()
})
// Click the quote button
const quoteButton = container.querySelector('[aria-label="Quote selected text"]') as HTMLElement
fireEvent.click(quoteButton)
// Verify the message was sent
expect(mockPostMessage).toHaveBeenCalledWith({
type: "addQuoteToComposer",
text: selectedText,
messageTs: 123456,
})
})
it("should hide quote button when selection is cleared", async () => {
const { container } = render(
<Markdown markdown="This is some test content that can be selected" messageTs={123456} />,
)
// Mock text selection
const mockSelection = {
toString: vi.fn(() => "test content"),
removeAllRanges: vi.fn(),
getRangeAt: vi.fn(() => ({
getBoundingClientRect: vi.fn(() => ({
top: 100,
left: 50,
bottom: 120,
right: 200,
width: 150,
height: 20,
})),
})),
rangeCount: 1,
}
global.window.getSelection = vi.fn(() => mockSelection as any)
// Trigger selection change event
const selectionEvent = new Event("selectionchange", { bubbles: true })
document.dispatchEvent(selectionEvent)
// Wait for the quote button to appear
await waitFor(() => {
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
expect(quoteButton).toBeTruthy()
})
// Clear the selection
mockSelection.toString = vi.fn(() => "")
mockSelection.rangeCount = 0
// Trigger selection change event again
document.dispatchEvent(selectionEvent)
// Wait for the quote button to disappear
await waitFor(() => {
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
expect(quoteButton).toBeFalsy()
})
})
it("should not show quote button when messageTs is not provided", async () => {
const { container } = render(<Markdown markdown="This is some test content that can be selected" />)
// Mock text selection
const mockSelection = {
toString: vi.fn(() => "test content"),
removeAllRanges: vi.fn(),
getRangeAt: vi.fn(() => ({
getBoundingClientRect: vi.fn(() => ({
top: 100,
left: 50,
bottom: 120,
right: 200,
width: 150,
height: 20,
})),
})),
rangeCount: 1,
}
global.window.getSelection = vi.fn(() => mockSelection as any)
// Trigger selection change event
const selectionEvent = new Event("selectionchange", { bubbles: true })
document.dispatchEvent(selectionEvent)
// Wait a bit and verify no quote button appears
await waitFor(
() => {
const quoteButton = container.querySelector('[aria-label="Quote selected text"]')
expect(quoteButton).toBeFalsy()
},
{ timeout: 100 },
)
})
})