diff --git a/.changeset/fluffy-apples-attack.md b/.changeset/fluffy-apples-attack.md
new file mode 100644
index 0000000000..924a1b2505
--- /dev/null
+++ b/.changeset/fluffy-apples-attack.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Handle really long text in the ChatRow similar to TaskHeader
diff --git a/.changeset/orange-zoos-train.md b/.changeset/orange-zoos-train.md
new file mode 100644
index 0000000000..76c16f4567
--- /dev/null
+++ b/.changeset/orange-zoos-train.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Support multiple files in drag-and-drop
diff --git a/.changeset/tender-cycles-help.md b/.changeset/tender-cycles-help.md
new file mode 100644
index 0000000000..d43e423ee6
--- /dev/null
+++ b/.changeset/tender-cycles-help.md
@@ -0,0 +1,5 @@
+---
+"roo-cline": patch
+---
+
+Better OpenRouter error handling
diff --git a/src/core/Cline.ts b/src/core/Cline.ts
index cdd1f8f4c5..397d3aca80 100644
--- a/src/core/Cline.ts
+++ b/src/core/Cline.ts
@@ -1066,7 +1066,7 @@ export class Cline {
} catch (error) {
// note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely.
if (alwaysApproveResubmit) {
- const errorMsg = error.message ?? "Unknown error"
+ const errorMsg = error.error?.metadata?.raw ?? error.message ?? "Unknown error"
const baseDelay = requestDelaySeconds || 5
const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt))
// Wait for the greater of the exponential delay or the rate limit delay
diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx
index b139c68f96..4017ccf318 100644
--- a/webview-ui/src/components/chat/ChatRow.tsx
+++ b/webview-ui/src/components/chat/ChatRow.tsx
@@ -617,8 +617,10 @@ export const ChatRowContent = ({
color: "var(--vscode-badge-foreground)",
borderRadius: "3px",
padding: "9px",
- whiteSpace: "pre-line",
- wordWrap: "break-word",
+ overflow: "hidden",
+ whiteSpace: "pre-wrap",
+ wordBreak: "break-word",
+ overflowWrap: "anywhere",
}}>
(
const files = Array.from(e.dataTransfer.files)
const text = e.dataTransfer.getData("text")
if (text) {
- // Convert the path to a mention-friendly format
- const mentionText = convertToMentionPath(text, cwd)
+ // Split text on newlines to handle multiple files
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== "")
- const newValue =
- inputValue.slice(0, cursorPosition) + mentionText + " " + inputValue.slice(cursorPosition)
- setInputValue(newValue)
- const newCursorPosition = cursorPosition + mentionText.length + 1
- setCursorPosition(newCursorPosition)
- setIntendedCursorPosition(newCursorPosition)
+ if (lines.length > 0) {
+ // Process each line as a separate file path
+ let newValue = inputValue.slice(0, cursorPosition)
+ let totalLength = 0
+
+ lines.forEach((line, index) => {
+ // Convert each path to a mention-friendly format
+ const mentionText = convertToMentionPath(line, cwd)
+ newValue += mentionText
+ totalLength += mentionText.length
+
+ // Add space after each mention except the last one
+ if (index < lines.length - 1) {
+ newValue += " "
+ totalLength += 1
+ }
+ })
+
+ // Add space after the last mention and append the rest of the input
+ newValue += " " + inputValue.slice(cursorPosition)
+ totalLength += 1
+
+ setInputValue(newValue)
+ const newCursorPosition = cursorPosition + totalLength
+ setCursorPosition(newCursorPosition)
+ setIntendedCursorPosition(newCursorPosition)
+ }
return
}
diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx
index 205912fc15..3241010e88 100644
--- a/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx
+++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.test.tsx
@@ -3,6 +3,7 @@ import ChatTextArea from "../ChatTextArea"
import { useExtensionState } from "../../../context/ExtensionStateContext"
import { vscode } from "../../../utils/vscode"
import { defaultModeSlug } from "../../../../../src/shared/modes"
+import * as pathMentions from "../../../utils/path-mentions"
// Mock modules
jest.mock("../../../utils/vscode", () => ({
@@ -12,9 +13,20 @@ jest.mock("../../../utils/vscode", () => ({
}))
jest.mock("../../../components/common/CodeBlock")
jest.mock("../../../components/common/MarkdownBlock")
+jest.mock("../../../utils/path-mentions", () => ({
+ convertToMentionPath: jest.fn((path, cwd) => {
+ // Simple mock implementation that mimics the real function's behavior
+ if (cwd && path.toLowerCase().startsWith(cwd.toLowerCase())) {
+ const relativePath = path.substring(cwd.length)
+ return "@" + (relativePath.startsWith("/") ? relativePath : "/" + relativePath)
+ }
+ return path
+ }),
+}))
// Get the mocked postMessage function
const mockPostMessage = vscode.postMessage as jest.Mock
+const mockConvertToMentionPath = pathMentions.convertToMentionPath as jest.Mock
// Mock ExtensionStateContext
jest.mock("../../../context/ExtensionStateContext")
@@ -160,4 +172,230 @@ describe("ChatTextArea", () => {
expect(setInputValue).toHaveBeenCalledWith("Enhanced test prompt")
})
})
+
+ describe("multi-file drag and drop", () => {
+ const mockCwd = "/Users/test/project"
+
+ beforeEach(() => {
+ jest.clearAllMocks()
+ ;(useExtensionState as jest.Mock).mockReturnValue({
+ filePaths: [],
+ openedTabs: [],
+ cwd: mockCwd,
+ })
+ mockConvertToMentionPath.mockClear()
+ })
+
+ it("should process multiple file paths separated by newlines", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render(
+ ,
+ )
+
+ // Create a mock dataTransfer object with text data containing multiple file paths
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was called for each file path
+ expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file1.js", mockCwd)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith("/Users/test/project/file2.js", mockCwd)
+
+ // Verify setInputValue was called with the correct value
+ // The mock implementation of convertToMentionPath will convert the paths to @/file1.js and @/file2.js
+ expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
+ })
+
+ it("should filter out empty lines in the dragged text", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render(
+ ,
+ )
+
+ // Create a mock dataTransfer object with text data containing empty lines
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n\n/Users/test/project/file2.js\n\n"),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was called only for non-empty lines
+ expect(mockConvertToMentionPath).toHaveBeenCalledTimes(2)
+
+ // Verify setInputValue was called with the correct value
+ expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Initial text")
+ })
+
+ it("should correctly update cursor position after adding multiple mentions", () => {
+ const setInputValue = jest.fn()
+ const initialCursorPosition = 5
+
+ const { container } = render(
+ ,
+ )
+
+ // Set the cursor position manually
+ const textArea = container.querySelector("textarea")
+ if (textArea) {
+ textArea.selectionStart = initialCursorPosition
+ textArea.selectionEnd = initialCursorPosition
+ }
+
+ // Create a mock dataTransfer object with text data
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue("/Users/test/project/file1.js\n/Users/test/project/file2.js"),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // The cursor position should be updated based on the implementation in the component
+ expect(setInputValue).toHaveBeenCalledWith("@/file1.js @/file2.js Hello world")
+ })
+
+ it("should handle very long file paths correctly", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render()
+
+ // Create a very long file path
+ const longPath =
+ "/Users/test/project/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript"
+
+ // Create a mock dataTransfer object with the long path
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue(longPath),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was called with the long path
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(longPath, mockCwd)
+
+ // The mock implementation will convert it to @/very/long/path/...
+ expect(setInputValue).toHaveBeenCalledWith(
+ "@/very/long/path/with/many/nested/directories/and/a/very/long/filename/with/extension.typescript ",
+ )
+ })
+
+ it("should handle paths with special characters correctly", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render()
+
+ // Create paths with special characters
+ const specialPath1 = "/Users/test/project/file with spaces.js"
+ const specialPath2 = "/Users/test/project/file-with-dashes.js"
+ const specialPath3 = "/Users/test/project/file_with_underscores.js"
+ const specialPath4 = "/Users/test/project/file.with.dots.js"
+
+ // Create a mock dataTransfer object with the special paths
+ const dataTransfer = {
+ getData: jest
+ .fn()
+ .mockReturnValue(`${specialPath1}\n${specialPath2}\n${specialPath3}\n${specialPath4}`),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was called for each path
+ expect(mockConvertToMentionPath).toHaveBeenCalledTimes(4)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath1, mockCwd)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath2, mockCwd)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath3, mockCwd)
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(specialPath4, mockCwd)
+
+ // Verify setInputValue was called with the correct value
+ expect(setInputValue).toHaveBeenCalledWith(
+ "@/file with spaces.js @/file-with-dashes.js @/file_with_underscores.js @/file.with.dots.js ",
+ )
+ })
+
+ it("should handle paths outside the current working directory", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render()
+
+ // Create paths outside the current working directory
+ const outsidePath = "/Users/other/project/file.js"
+
+ // Mock the convertToMentionPath function to return the original path for paths outside cwd
+ mockConvertToMentionPath.mockImplementationOnce((path, cwd) => {
+ return path // Return original path for this test
+ })
+
+ // Create a mock dataTransfer object with the outside path
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue(outsidePath),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was called with the outside path
+ expect(mockConvertToMentionPath).toHaveBeenCalledWith(outsidePath, mockCwd)
+
+ // Verify setInputValue was called with the original path
+ expect(setInputValue).toHaveBeenCalledWith("/Users/other/project/file.js ")
+ })
+
+ it("should do nothing when dropped text is empty", () => {
+ const setInputValue = jest.fn()
+
+ const { container } = render(
+ ,
+ )
+
+ // Create a mock dataTransfer object with empty text
+ const dataTransfer = {
+ getData: jest.fn().mockReturnValue(""),
+ files: [],
+ }
+
+ // Simulate drop event
+ fireEvent.drop(container.querySelector(".chat-text-area")!, {
+ dataTransfer,
+ preventDefault: jest.fn(),
+ })
+
+ // Verify convertToMentionPath was not called
+ expect(mockConvertToMentionPath).not.toHaveBeenCalled()
+
+ // Verify setInputValue was not called
+ expect(setInputValue).not.toHaveBeenCalled()
+ })
+ })
})