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
5 changes: 4 additions & 1 deletion webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ const ChatRow = memo(
const prevHeightRef = useRef(0)

const [chatrow, { height }] = useSize(
<div className="px-[15px] py-[10px] pr-[6px]">
<div
className="px-[15px] py-[10px] pr-[6px] message"
data-message-ts={message.ts}
data-testid={`message-${message.ts}`}>
<ChatRowContent {...props} />
</div>,
)
Expand Down
170 changes: 149 additions & 21 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatViewRef, ChatViewProps> = (
Expand Down Expand Up @@ -280,6 +287,77 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
vscode.postMessage({ type: "playTts", text })
}

// ---------------------------
// Quote selection integration
// ---------------------------

// Quote selection state and overlay
const [activeQuote, setActiveQuote] = useState<string | null>(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.
Expand Down Expand Up @@ -593,15 +671,19 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
*/
const handleSendMessage = useCallback(
(text: string, images: string[]) => {
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)}`,
Expand All @@ -615,16 +697,14 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
userRespondedRef.current = true

if (messagesRef.current.length === 0) {
vscode.postMessage({ type: "newTask", text, images })
vscode.postMessage({ type: "newTask", text: finalText, images })
} else if (clineAskRef.current) {
if (clineAskRef.current === "followup") {
markFollowUpAsAnswered()
}

// Use clineAskRef.current
switch (
clineAskRef.current // Use clineAskRef.current
) {
switch (clineAskRef.current) {
case "followup":
case "tool":
case "browser_action_launch":
Expand All @@ -638,21 +718,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
vscode.postMessage({
type: "askResponse",
askResponse: "messageResponse",
text,
text: finalText,
images,
})
break
// There is no other case that a textfield should be enabled.
}
} else {
// This is a new message in an ongoing task.
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text, images })
vscode.postMessage({ type: "askResponse", askResponse: "messageResponse", text: finalText, images })
}

// Clear quote and reset input after sending
setActiveQuote(null)
handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled], // messagesRef and clineAskRef are stable
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, activeQuote], // messagesRef and clineAskRef are stable
)

const handleSetChatBoxMessage = useCallback(
Expand Down Expand Up @@ -1413,16 +1494,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}, [groupedMessages.length, scrollToBottomSmooth])

const handleWheel = useCallback((event: Event) => {
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

Expand Down Expand Up @@ -1720,6 +1807,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
// Add keyboard event handler
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// 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 === ".") {
Expand All @@ -1734,7 +1837,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}
},
[switchToNextMode, switchToPreviousMode],
[switchToNextMode, switchToPreviousMode, hideQuoteOverlay, withinSingleMessage],
)

useEffect(() => {
Expand Down Expand Up @@ -1983,6 +2086,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
}
}}
/>
{activeQuote && (
<div className="px-3">
<div className="flex items-start gap-2 bg-vscode-editor-background border border-vscode-editorGroup-border rounded-sm px-2 py-1 mt-1">
<span className="codicon codicon-quote mt-0.5"></span>
<div className="text-sm whitespace-pre-wrap flex-1 max-h-20 overflow-hidden">{activeQuote}</div>
<button
type="button"
aria-label="Dismiss quote"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, the dismiss button uses a hardcoded ARIA label "Dismiss quote". Please update this to use a translatable string (e.g. t('chat:dismissQuote')).

Suggested change
aria-label="Dismiss quote"
aria-label={t('chat:dismissQuote')}

This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.

onClick={() => setActiveQuote(null)}
className="codicon codicon-close text-vscode-descriptionForeground hover:text-vscode-foreground mt-0.5"
/>
</div>
</div>
)}
<ChatTextArea
ref={textAreaRef}
inputValue={inputValue}
Expand Down Expand Up @@ -2013,6 +2130,17 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

<div id="roo-portal" />
<CloudUpsellDialog open={isUpsellOpen} onOpenChange={closeUpsell} onConnect={handleConnect} />

{quoteOverlay.visible && (
<button
type="button"
aria-label="Quote selection"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making the ARIA label for the quote overlay button translatable (e.g. using t('chat:quoteSelection')) instead of the hardcoded "Quote selection" to support i18n.

Suggested change
aria-label="Quote selection"
aria-label={t('chat:quoteSelection')}

This comment was generated because it violated a code review rule: irule_C0ez7Rji6ANcGkkX.

onClick={onQuoteClick}
className="fixed z-[1000] rounded-md min-w-[28px] min-h-[28px] flex items-center justify-center border border-[color-mix(in_srgb,var(--vscode-foreground)_15%,transparent)] shadow-md bg-[color-mix(in_srgb,var(--vscode-button-background)_100%,transparent)] text-vscode-button-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-vscode-focusBorder"
style={{ left: `${quoteOverlay.x}px`, top: `${quoteOverlay.y}px` }}>
<span className="codicon codicon-quote text-[16px]" aria-hidden="true"></span>
</button>
)}
</div>
)
}
Expand Down