Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,7 @@ export interface WebviewMessage {
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
| "sendErrorToSupport"
| "requestClaudeCodeRateLimits"
| "refreshCustomTools"
| "requestModes"
Expand Down
72 changes: 72 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3310,6 +3310,78 @@ export const webviewMessageHandler = async (
break
}

case "sendErrorToSupport": {
const currentTask = provider.getCurrentTask()
if (!currentTask) {
vscode.window.showErrorMessage("No active task to send error details")
break
}

try {
// Get the cloud API URL from the message values
const cloudApiUrl =
message.values?.cloudApiUrl || process.env.ROO_CODE_CLOUD_URL || "https://app.roocode.com"
const contactEndpoint = `${cloudApiUrl}/api/contact/issue`

// Prepare the error details JSON
const errorData = {
timestamp: message.values?.timestamp || new Date().toISOString(),
version: message.values?.version || "",
provider: message.values?.provider || "",
model: message.values?.model || "",
details: message.values?.details || "",
taskId: currentTask.taskId,
}

// Create a FormData-like structure
const errorJson = JSON.stringify(errorData, null, 2)
const boundary = `----RooCodeBoundary${Date.now()}`

const body = [
`--${boundary}`,
'Content-Disposition: form-data; name="file"; filename="error-details.json"',
"Content-Type: application/json",
"",
errorJson,
`--${boundary}--`,
].join("\r\n")

// Get the session token from CloudService
const sessionToken = CloudService.hasInstance()
? CloudService.instance.authService?.getSessionToken()
: undefined

if (!sessionToken) {
vscode.window.showErrorMessage("You must be logged in to send error details to support")
break
}

// Make the authenticated POST request
const response = await fetch(contactEndpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${sessionToken}`,
"Content-Type": `multipart/form-data; boundary=${boundary}`,
},
body: body,
})

if (response.ok) {
vscode.window.showInformationMessage("Error details sent to Roo Code support successfully")
} else {
const errorText = await response.text()
provider.log(`Failed to send error details to support: ${response.status} ${errorText}`)
vscode.window.showErrorMessage("Failed to send error details to support. Please try again later.")
}
} catch (error) {
provider.log(
`Error sending error details to support: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage("Failed to send error details to support. Please try again later.")
}
break
}

default: {
// console.log(`Unhandled message type: ${message.type}`)
//
Expand Down
42 changes: 40 additions & 2 deletions webview-ui/src/components/chat/ErrorRow.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useCallback, memo, useMemo } from "react"
import { useTranslation } from "react-i18next"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import { BookOpenText, MessageCircleWarning, Copy, Check, Microscope, Info } from "lucide-react"
import { BookOpenText, MessageCircleWarning, Copy, Check, Microscope, Info, Send } from "lucide-react"

import { useCopyToClipboard } from "@src/utils/clipboard"
import { vscode } from "@src/utils/vscode"
Expand Down Expand Up @@ -94,8 +94,9 @@ export const ErrorRow = memo(
const [showCopySuccess, setShowCopySuccess] = useState(false)
const [isDetailsDialogOpen, setIsDetailsDialogOpen] = useState(false)
const [showDetailsCopySuccess, setShowDetailsCopySuccess] = useState(false)
const [showSendSuccess, setShowSendSuccess] = useState(false)
const { copyWithFeedback } = useCopyToClipboard()
const { version, apiConfiguration } = useExtensionState()
const { version, apiConfiguration, cloudIsAuthenticated, cloudApiUrl } = useExtensionState()
const { provider, id: modelId } = useSelectedModel(apiConfiguration)

const usesProxy = PROVIDERS.find((p) => p.value === provider)?.proxy ?? false
Expand Down Expand Up @@ -133,6 +134,28 @@ export const ErrorRow = memo(
[version, provider, modelId, errorDetails],
)

const handleSendToSupport = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
vscode.postMessage({
type: "sendErrorToSupport",
values: {
timestamp: new Date().toISOString(),
version,
provider,
model: modelId,
details: errorDetails || "",
cloudApiUrl,
},
})
setShowSendSuccess(true)
setTimeout(() => {
setShowSendSuccess(false)
}, 2000)
},
[version, provider, modelId, errorDetails, cloudApiUrl],
)

// Default titles for different error types
const getDefaultTitle = () => {
if (title) return title
Expand Down Expand Up @@ -307,6 +330,21 @@ export const ErrorRow = memo(
)}
</div>
<DialogFooter>
{cloudIsAuthenticated && (
<Button variant="secondary" className="w-full" onClick={handleSendToSupport}>
{showSendSuccess ? (
<>
<Check className="size-3" />
{t("chat:errorDetails.sent")}
</>
) : (
<>
<Send className="size-3" />
{t("chat:errorDetails.sendToSupport")}
</>
)}
</Button>
)}
<Button variant="secondary" className="w-full" onClick={handleCopyDetails}>
{showDetailsCopySuccess ? (
<>
Expand Down
75 changes: 71 additions & 4 deletions webview-ui/src/components/chat/__tests__/ErrorRow.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,15 @@ vi.mock("@/utils/vscode", () => ({
}))

// Mock ExtensionState context
const mockExtensionState = {
version: "1.0.0",
apiConfiguration: {},
cloudIsAuthenticated: false,
cloudApiUrl: "https://app.roocode.com",
}

vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: () => ({
version: "1.0.0",
apiConfiguration: {},
}),
useExtensionState: () => mockExtensionState,
}))

// Mock selected model hook
Expand All @@ -38,6 +42,8 @@ vi.mock("react-i18next", () => ({
"chat:errorDetails.copyToClipboard": "Copy to Clipboard",
"chat:errorDetails.copied": "Copied!",
"chat:errorDetails.diagnostics": "Get detailed error info",
"chat:errorDetails.sendToSupport": "Send details to Roo Code support",
"chat:errorDetails.sent": "Sent!",
}
return map[key] ?? key
},
Expand Down Expand Up @@ -79,4 +85,65 @@ describe("ErrorRow diagnostics download", () => {
// Timestamp is generated at runtime, but should be a string
expect(typeof payload.values.timestamp).toBe("string")
})

it("does not show send to support button when user is not authenticated", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The mockExtensionState object is shared and mutated across tests without being reset between runs. If vitest runs tests in a different order, the state from one test can leak into another, causing flaky behavior. Consider adding a beforeEach block to reset the mock state, or use a factory function that returns a fresh object for each test:

const createMockExtensionState = (overrides = {}) => ({
  version: "1.0.0",
  apiConfiguration: {},
  cloudIsAuthenticated: false,
  cloudApiUrl: "https://app.roocode.com",
  ...overrides,
});

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

mockExtensionState.cloudIsAuthenticated = false

render(<ErrorRow type="error" message="Something went wrong" errorDetails="Detailed error body" />)

// Open the Error Details dialog via the info button
const infoButton = screen.getByRole("button", { name: "Error Details" })
fireEvent.click(infoButton)

// The send to support button should not be present
const sendButton = screen.queryByRole("button", { name: "Send details to Roo Code support" })
expect(sendButton).not.toBeInTheDocument()
})

it("shows send to support button when user is authenticated", () => {
mockExtensionState.cloudIsAuthenticated = true

render(<ErrorRow type="error" message="Something went wrong" errorDetails="Detailed error body" />)

// Open the Error Details dialog via the info button
const infoButton = screen.getByRole("button", { name: "Error Details" })
fireEvent.click(infoButton)

// The send to support button should be present
const sendButton = screen.getByRole("button", { name: "Send details to Roo Code support" })
expect(sendButton).toBeInTheDocument()
})

it("sends sendErrorToSupport message when authenticated user clicks send to support button", () => {
mockExtensionState.cloudIsAuthenticated = true
const mockPostMessage = vi.mocked(vscode.postMessage)

render(<ErrorRow type="error" message="Something went wrong" errorDetails="Detailed error body" />)

// Open the Error Details dialog via the info button
const infoButton = screen.getByRole("button", { name: "Error Details" })
fireEvent.click(infoButton)

// Click the send to support button
const sendButton = screen.getByRole("button", { name: "Send details to Roo Code support" })
fireEvent.click(sendButton)

expect(mockPostMessage).toHaveBeenCalled()
const call = mockPostMessage.mock.calls.find(([arg]) => arg.type === "sendErrorToSupport")
expect(call).toBeTruthy()
if (!call) return

const payload = call[0] as { type: string; values?: any }
expect(payload.values).toBeTruthy()
if (!payload.values) return

expect(payload.values).toMatchObject({
version: "1.0.0",
provider: "test-provider",
model: "test-model",
cloudApiUrl: "https://app.roocode.com",
})
// Timestamp is generated at runtime, but should be a string
expect(typeof payload.values.timestamp).toBe("string")
})
})
2 changes: 2 additions & 0 deletions webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@
"copyToClipboard": "Copy basic error info",
"copied": "Copied!",
"diagnostics": "Get detailed error info",
"sendToSupport": "Send details to Roo Code support",
"sent": "Sent!",
"proxyProvider": "You seem to be using a proxy-based provider. Make sure to check its logs and to ensure it's not rewriting Roo's requests."
},
"diffError": {
Expand Down
Loading