Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
45 changes: 45 additions & 0 deletions src/core/environment/__tests__/getEnvironmentDetails.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,4 +390,49 @@ describe("getEnvironmentDetails", () => {
const result = await getEnvironmentDetails(cline as Task)
expect(result).toContain("REMINDERS")
})

describe("Selection Context", () => {
it("should include selection context when available", async () => {
const clineWithSelection = {
...mockCline,
selectionContext: {
selectedText: "const x = 1;\nconst y = 2;",
selectionFilePath: "src/test.ts",
selectionStartLine: 10,
selectionEndLine: 11,
},
}

const result = await getEnvironmentDetails(clineWithSelection as Task)

expect(result).toContain("# Current Selection")
expect(result).toContain("File: src/test.ts:10-11")
expect(result).toContain("```")
expect(result).toContain("const x = 1;")
expect(result).toContain("const y = 2;")
})

it("should clear selection context after including it", async () => {
const clineWithSelection = {
...mockCline,
selectionContext: {
selectedText: "test code",
selectionFilePath: "src/app.ts",
selectionStartLine: 5,
selectionEndLine: 5,
},
}

await getEnvironmentDetails(clineWithSelection as Task)

// Selection context should be cleared after use
expect(clineWithSelection.selectionContext).toBeUndefined()
})

it("should not include selection section when no context is available", async () => {
const result = await getEnvironmentDetails(mockCline as Task)

expect(result).not.toContain("# Current Selection")
})
})
})
11 changes: 11 additions & 0 deletions src/core/environment/getEnvironmentDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
maxWorkspaceFiles = 200,
} = state ?? {}

// Include selection context if available
if (cline.selectionContext) {
const { selectedText, selectionFilePath, selectionStartLine, selectionEndLine } = cline.selectionContext
details += "\n\n# Current Selection"
details += `\nFile: ${selectionFilePath}:${selectionStartLine}-${selectionEndLine}`
details += `\n\`\`\`\n${selectedText}\n\`\`\``

// Clear the selection context after including it once
cline.selectionContext = undefined
}

// It could be useful for cline to know if the user went from one or no
// file to another between messages, so we always include this context.
details += "\n\n# VSCode Visible Files"
Expand Down
7 changes: 7 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {

todoList?: TodoItem[]

selectionContext?: {
selectedText: string
selectionFilePath: string
selectionStartLine: number
selectionEndLine: number
}

readonly rootTask: Task | undefined = undefined
readonly parentTask: Task | undefined = undefined
readonly taskNumber: number
Expand Down
94 changes: 92 additions & 2 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,61 @@ export const webviewMessageHandler = async (
}

switch (message.type) {
case "requestSelectionContext": {
// Get the active editor and its selection
const editor = vscode.window.activeTextEditor
if (editor && !editor.selection.isEmpty) {
const selection = editor.selection
const selectedText = editor.document.getText(selection)
const filePath = editor.document.uri.fsPath

// Convert to workspace-relative path if possible
const workspacePath = provider.cwd
let relativeFilePath: string
if (filePath.startsWith(workspacePath)) {
relativeFilePath = path.relative(workspacePath, filePath)
} else {
// File is outside workspace, use absolute path
relativeFilePath = filePath
}

// VSCode uses 0-based line numbers, convert to 1-based for user-friendly display
const startLine = selection.start.line + 1
const endLine = selection.end.line + 1

// Send selection context to webview
await provider.postMessageToWebview({
type: "selectionContext",
selectedText,
selectionFilePath: relativeFilePath,
selectionStartLine: startLine,
selectionEndLine: endLine,
})

// Store selection context in current task for use in environment details
const currentTask = provider.getCurrentTask()
if (currentTask) {
currentTask.selectionContext = {
selectedText,
selectionFilePath: relativeFilePath,
selectionStartLine: startLine,
selectionEndLine: endLine,
}
}
} else {
// No selection, send empty context
await provider.postMessageToWebview({
type: "selectionContext",
})

// Clear selection context in current task
const currentTask = provider.getCurrentTask()
if (currentTask) {
currentTask.selectionContext = undefined
}
}
break
}
case "webviewDidLaunch":
// Load custom modes first
const customModes = await provider.customModesManager.getCustomModes()
Expand Down Expand Up @@ -508,6 +563,24 @@ export const webviewMessageHandler = async (
// task. This essentially creates a fresh slate for the new task.
try {
await provider.createTask(message.text, message.images)

// Store selection context in the newly created task
const newTask = provider.getCurrentTask()
if (
newTask &&
message.selectedText &&
message.selectionFilePath &&
typeof message.selectionStartLine === "number" &&
typeof message.selectionEndLine === "number"
) {
newTask.selectionContext = {
selectedText: message.selectedText,
selectionFilePath: message.selectionFilePath,
selectionStartLine: message.selectionStartLine,
selectionEndLine: message.selectionEndLine,
}
}

// Task created successfully - notify the UI to reset
await provider.postMessageToWebview({ type: "invoke", invoke: "newChat" })
} catch (error) {
Expand All @@ -523,9 +596,26 @@ export const webviewMessageHandler = async (
await provider.updateCustomInstructions(message.text)
break

case "askResponse":
provider.getCurrentTask()?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
case "askResponse": {
// Store selection context in current task before handling response
const task = provider.getCurrentTask()
if (
task &&
message.selectedText &&
message.selectionFilePath &&
typeof message.selectionStartLine === "number" &&
typeof message.selectionEndLine === "number"
) {
task.selectionContext = {
selectedText: message.selectedText,
selectionFilePath: message.selectionFilePath,
selectionStartLine: message.selectionStartLine,
selectionEndLine: message.selectionEndLine,
}
}
task?.handleWebviewAskResponse(message.askResponse!, message.text, message.images)
break
}

case "updateSettings":
if (message.updatedSettings) {
Expand Down
5 changes: 5 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,12 @@ export interface ExtensionMessage {
| "dismissedUpsells"
| "organizationSwitchResult"
| "interactionRequired"
| "selectionContext"
text?: string
selectedText?: string
selectionFilePath?: string
selectionStartLine?: number
selectionEndLine?: number
payload?: any // Add a generic payload for now, can refine later
// Checkpoint warning message
checkpointWarning?: {
Expand Down
5 changes: 5 additions & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,12 @@ export interface WebviewMessage {
| "dismissUpsell"
| "getDismissedUpsells"
| "updateSettings"
| "requestSelectionContext"
text?: string
selectedText?: string
selectionFilePath?: string
selectionStartLine?: number
selectionEndLine?: number
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
disabled?: boolean
Expand Down
39 changes: 35 additions & 4 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const [sendingDisabled, setSendingDisabled] = useState(false)
const [selectedImages, setSelectedImages] = useState<string[]>([])
const [selectionContext, setSelectionContext] = useState<{
selectedText?: string
selectionFilePath?: string
selectionStartLine?: number
selectionEndLine?: number
} | null>(null)

// We need to hold on to the ask because useEffect > lastMessage will always
// let us know when an ask comes in and handle it, but by the time
Expand Down Expand Up @@ -563,7 +569,12 @@ 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,
images,
...(selectionContext || {}),
})
} else if (clineAskRef.current) {
if (clineAskRef.current === "followup") {
markFollowUpAsAnswered()
Expand All @@ -588,19 +599,26 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
askResponse: "messageResponse",
text,
images,
...(selectionContext || {}),
})
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,
images,
...(selectionContext || {}),
})
}

handleChatReset()
}
},
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length], // messagesRef and clineAskRef are stable
[handleChatReset, markFollowUpAsAnswered, sendingDisabled, isStreaming, messageQueue.length, selectionContext], // messagesRef and clineAskRef are stable
)

const handleSetChatBoxMessage = useCallback(
Expand Down Expand Up @@ -743,6 +761,15 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
break
}
break
case "selectionContext":
// Update selection context when received from extension
setSelectionContext({
selectedText: message.selectedText,
selectionFilePath: message.selectionFilePath,
selectionStartLine: message.selectionStartLine,
selectionEndLine: message.selectionEndLine,
})
break
case "selectedImages":
// Only handle selectedImages if it's not for editing context
// When context is "edit", ChatRow will handle the images
Expand Down Expand Up @@ -809,7 +836,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
useEvent("message", handleMessage)

// NOTE: the VSCode window needs to be focused for this to work.
useMount(() => textAreaRef.current?.focus())
useMount(() => {
textAreaRef.current?.focus()
// Request initial selection context when component mounts
vscode.postMessage({ type: "requestSelectionContext" })
})
Comment on lines 811 to 813
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Stale selection context can be sent with messages if the user changes their selection after component mount but before sending a message. The requestSelectionContext call on mount stores the selection in the task, but if the user then selects different text and sends a message, the old selection context will be included. Selection context should be captured at message send time, not at component mount time, to ensure it reflects the user's current selection.

Fix it with Roo Code or mention @roomote and request a fix.


const visibleMessages = useMemo(() => {
// Pre-compute checkpoint hashes that have associated user messages for O(1) lookup
Expand Down