From 6a3c407e71026eb034357007905c6d0d17cb7537 Mon Sep 17 00:00:00 2001 From: Sannidhya Date: Sun, 26 Oct 2025 13:21:27 +0530 Subject: [PATCH] feat(chat): icon-only selection overlay for quote + stable hooks Use codicon-quote glyph in a rounded tile; keep aria-label for a11y. Wrap selection-scoping helpers in useCallback and add deps to satisfy lint. Ensure message containers carry data-message-ts for selection scoping. --- webview-ui/src/components/chat/ChatRow.tsx | 5 +- webview-ui/src/components/chat/ChatView.tsx | 170 +++++++++++++++++--- 2 files changed, 153 insertions(+), 22 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ed5257528fe1..62b7bd57eaa2 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 b454c97bef96..f618f6922a4b 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 && ( + + )}
) }