diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index ed5257528f..62b7bd57ea 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -89,7 +89,10 @@ const ChatRow = memo(
const prevHeightRef = useRef(0)
const [chatrow, { height }] = useSize(
-
+
,
)
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx
index b454c97bef..f618f6922a 100644
--- a/webview-ui/src/components/chat/ChatView.tsx
+++ b/webview-ui/src/components/chat/ChatView.tsx
@@ -71,6 +71,13 @@ export interface ChatViewRef {
export const MAX_IMAGES_PER_MESSAGE = 20 // This is the Anthropic limit.
+// Quote formatting helper for quoted selections
+export function asContextBlock(quote: string): string {
+ // Ensure stable, machine-readable context format
+ const lines = (quote || "").split(/\r?\n/).map((l) => `> ${l}`)
+ return `[context]\n${lines.join("\n")}\n[/context]\n\n`
+}
+
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
const ChatViewComponent: React.ForwardRefRenderFunction
= (
@@ -280,6 +287,77 @@ const ChatViewComponent: React.ForwardRefRenderFunction(null)
+ const [quoteOverlay, setQuoteOverlay] = useState<{ visible: boolean; x: number; y: number }>({
+ visible: false,
+ x: 0,
+ y: 0,
+ })
+
+ const getMessageHost = useCallback((node: Node | null): HTMLElement | null => {
+ let el: any = node
+ // If a text node, navigate to its element parent
+ if (el && el.nodeType === Node.TEXT_NODE) el = el.parentElement
+ return el?.closest?.("[data-message-ts]") || null
+ }, [])
+
+ const withinSingleMessage = useCallback(
+ (sel: Selection | null): boolean => {
+ if (!sel || sel.rangeCount === 0) return false
+ const range = sel.getRangeAt(0)
+ const hostA = getMessageHost(range.startContainer)
+ const hostB = getMessageHost(range.endContainer)
+ return !!hostA && hostA === hostB
+ },
+ [getMessageHost],
+ )
+
+ const hideQuoteOverlay = useCallback(() => {
+ setQuoteOverlay((o) => (o.visible ? { ...o, visible: false } : o))
+ }, [])
+
+ const handleSelectionMouseUp = useCallback(() => {
+ setTimeout(() => {
+ const sel = window.getSelection()
+ if (!sel || sel.isCollapsed || !withinSingleMessage(sel)) {
+ hideQuoteOverlay()
+ return
+ }
+ const rect = sel.getRangeAt(0).getBoundingClientRect()
+ const y = Math.max(0, rect.top - 28) // place above selection
+ const x = Math.max(0, rect.left)
+ setQuoteOverlay({ visible: true, x, y })
+ }, 0)
+ }, [hideQuoteOverlay, withinSingleMessage])
+
+ const onQuoteClick = useCallback(() => {
+ const sel = window.getSelection()
+ const text = sel ? sel.toString().trim() : ""
+ if (text) {
+ const clamped = text.length > 1000 ? text.slice(0, 1000) + "…" : text
+ setActiveQuote(clamped)
+ }
+ sel?.removeAllRanges?.()
+ hideQuoteOverlay()
+ }, [hideQuoteOverlay])
+
+ // Global listeners for selection and blur
+ useEvent("mouseup", handleSelectionMouseUp, window)
+ useEvent(
+ "selectionchange",
+ () => {
+ const sel = window.getSelection()
+ if (!sel || sel.isCollapsed || !withinSingleMessage(sel)) hideQuoteOverlay()
+ },
+ document,
+ )
+ useEvent("blur", hideQuoteOverlay, window)
+
useDeepCompareEffect(() => {
// if last message is an ask, show user ask UI
// if user finished a task, then start a new task with a new conversation history since in this moment that the extension is waiting for user response, the user could close the extension and the conversation history would be lost.
@@ -593,15 +671,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
- text = text.trim()
+ const userText = (text || "").trim()
+ const prefix = activeQuote ? asContextBlock(activeQuote) : ""
+ const finalText = `${prefix}${userText}`
- if (text || images.length > 0) {
+ if (finalText || images.length > 0) {
if (sendingDisabled) {
try {
- console.log("queueMessage", text, images)
- vscode.postMessage({ type: "queueMessage", text, images })
+ console.log("queueMessage", finalText, images)
+ vscode.postMessage({ type: "queueMessage", text: finalText, images })
setInputValue("")
setSelectedImages([])
+ // Clear quote after queueing
+ setActiveQuote(null)
} catch (error) {
console.error(
`Failed to queue message: ${error instanceof Error ? error.message : String(error)}`,
@@ -615,16 +697,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
- const wheelEvent = event as WheelEvent
+ const handleWheel = useCallback(
+ (event: Event) => {
+ const wheelEvent = event as WheelEvent
- if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
- if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
- // User scrolled up
- disableAutoScrollRef.current = true
+ // Hide quote overlay on any wheel scroll
+ hideQuoteOverlay()
+
+ if (wheelEvent.deltaY && wheelEvent.deltaY < 0) {
+ if (scrollContainerRef.current?.contains(wheelEvent.target as Node)) {
+ // User scrolled up
+ disableAutoScrollRef.current = true
+ }
}
- }
- }, [])
+ },
+ [hideQuoteOverlay],
+ )
useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance
@@ -1720,6 +1807,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
+ // Quote selection via keyboard: Cmd/Ctrl + Shift + Q
+ if ((event.metaKey || event.ctrlKey) && event.shiftKey && (event.key === "Q" || event.key === "q")) {
+ event.preventDefault()
+ const sel = window.getSelection()
+ if (sel && !sel.isCollapsed && withinSingleMessage(sel)) {
+ const text = sel.toString().trim()
+ if (text) {
+ const clamped = text.length > 1000 ? text.slice(0, 1000) + "…" : text
+ setActiveQuote(clamped)
+ sel.removeAllRanges?.()
+ hideQuoteOverlay()
+ }
+ }
+ return
+ }
+
// 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 === ".") {
@@ -1734,7 +1837,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction {
@@ -1983,6 +2086,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction
+ {activeQuote && (
+
+
+
+
{activeQuote}
+
+
+ )}
+
+ {quoteOverlay.visible && (
+
+ )}
)
}