diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fc7d029cf8..affd936d0c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2020,6 +2020,24 @@ export class ClineProvider extends EventEmitter implements await this.postStateToWebview() break } + + case "getBugReportInfo": { + // Send environment information back to the webview for bug reporting + const properties = await this.getTelemetryProperties() + await this.postMessageToWebview({ + type: "bugReportInfo", + info: properties, + }) + break + } + + case "openExternal": { + // Open an external URL (used for GitHub issue creation) + if (message.url) { + vscode.env.openExternal(vscode.Uri.parse(message.url)) + } + break + } } }, null, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index b7219de2f8..f917aa08b3 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -58,6 +58,7 @@ export interface ExtensionMessage { | "ttsStop" | "maxReadFileLine" | "fileSearchResults" + | "bugReportInfo" text?: string action?: | "chatButtonClicked" @@ -100,6 +101,7 @@ export interface ExtensionMessage { label?: string }> error?: string + info?: Record // Environment information for bug reports } export interface ApiConfigMeta { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d87be2a716..f5acad152a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -116,6 +116,8 @@ export interface WebviewMessage { | "language" | "maxReadFileLine" | "searchFiles" + | "getBugReportInfo" + | "openExternal" text?: string disabled?: boolean askResponse?: ClineAskResponse @@ -141,6 +143,7 @@ export interface WebviewMessage { source?: "global" | "project" requestId?: string ids?: string[] + url?: string // URL for openExternal message } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index d196ec037d..000d576c75 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -1,7 +1,7 @@ -import { HTMLAttributes } from "react" +import { HTMLAttributes, useState } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { Trans } from "react-i18next" -import { Info } from "lucide-react" +import { Bug, Info } from "lucide-react" import { VSCodeButton, VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" @@ -11,6 +11,7 @@ import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" import { SectionHeader } from "./SectionHeader" +import { BugReportDialog } from "./BugReportDialog" import { Section } from "./Section" type AboutProps = HTMLAttributes & { @@ -21,6 +22,7 @@ type AboutProps = HTMLAttributes & { export const About = ({ version, telemetrySetting, setTelemetrySetting, className, ...props }: AboutProps) => { const { t } = useAppTranslation() + const [showBugReportDialog, setShowBugReportDialog] = useState(false) return (
@@ -73,7 +75,20 @@ export const About = ({ version, telemetrySetting, setTelemetrySetting, classNam {t("settings:footer.reset.button")}
+ +
+

{t("settings:footer.bugreport.description")}

+ setShowBugReportDialog(true)} + appearance="secondary" + className="shrink-0"> + + {t("settings:footer.bugreport.button")} + +
+ + {showBugReportDialog && setShowBugReportDialog(false)} />} ) } diff --git a/webview-ui/src/components/settings/BugReportDialog.tsx b/webview-ui/src/components/settings/BugReportDialog.tsx new file mode 100644 index 0000000000..aef16522e0 --- /dev/null +++ b/webview-ui/src/components/settings/BugReportDialog.tsx @@ -0,0 +1,150 @@ +import { useEffect, useState } from "react" +import { Bug, ClipboardCopy, ExternalLink } from "lucide-react" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import { useAppTranslation } from "@/i18n/TranslationContext" +import { vscode } from "@/utils/vscode" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui" + +interface BugReportDialogProps { + onClose: () => void +} + +export function BugReportDialog({ onClose }: BugReportDialogProps) { + const { t } = useAppTranslation() + const [environmentInfo, setEnvironmentInfo] = useState | null>(null) + const [copied, setCopied] = useState(false) + + useEffect(() => { + // Request environment info from extension + vscode.postMessage({ type: "getBugReportInfo" }) + + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "bugReportInfo" && message.info) { + setEnvironmentInfo(message.info) + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, []) + + const formattedInfo = environmentInfo + ? Object.entries(environmentInfo) + .map(([key, value]) => `- **${key}**: ${value}`) + .join("\n") + : "" + + const handleCopy = () => { + if (formattedInfo) { + navigator.clipboard.writeText(formattedInfo) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + const handleCreateIssue = () => { + if (!environmentInfo) return + + // Build URL parameters to pre-fill GitHub issue template fields + const params = new URLSearchParams() + + // Add parameters based on the template's field IDs + // version - The app version + if (environmentInfo.appVersion) { + params.append("version", environmentInfo.appVersion) + } + + // provider - Try to extract from API provider if available + if (environmentInfo.apiProvider) { + params.append("provider", environmentInfo.apiProvider) + } + + // model - Try to extract from model ID if available + if (environmentInfo.modelId) { + params.append("model", environmentInfo.modelId) + } + + // what-happened - Pre-fill with a template that includes environment info + const environmentSummary = Object.entries(environmentInfo) + .map(([key, value]) => `${key}: ${value}`) + .join("\n") + + params.append( + "what-happened", + `I encountered an issue with Roo Code.\n\nEnvironment Information:\n${environmentSummary}`, + ) + + // additional-context - Add any system-specific details + if (environmentInfo.platform) { + params.append( + "additional-context", + `Platform: ${environmentInfo.platform}\nVSCode: ${environmentInfo.vscodeVersion || "unknown"}`, + ) + } + + const issueUrl = `https://github.com/RooVetGit/Roo-Code/issues/new?template=bug_report.yml&${params.toString()}` + + // Open URL + vscode.postMessage({ type: "openExternal", url: issueUrl }) + onClose() + } + + return ( + + + + + + {t("settings:bugreport.title")} + + + +
+

{t("settings:bugreport.description")}

+ +
+ {environmentInfo ? ( +
{formattedInfo}
+ ) : ( +
+
+
+
+
+
+
+ )} +
+
+ + + + + {copied ? t("settings:bugreport.copied") : t("settings:bugreport.copy")} + + +
+ + {t("settings:common.cancel")} + + + + {t("settings:bugreport.createIssue")} + +
+
+
+
+ ) +} diff --git a/webview-ui/src/components/settings/__tests__/About.test.tsx b/webview-ui/src/components/settings/__tests__/About.test.tsx new file mode 100644 index 0000000000..a4d4a538d9 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/About.test.tsx @@ -0,0 +1,137 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { About } from "../About" +import { TranslationContext } from "@/i18n/TranslationContext" +import { TelemetrySetting } from "../../../../../src/shared/TelemetrySetting" + +// Mock the BugReportDialog component +jest.mock("../BugReportDialog", () => ({ + BugReportDialog: ({ onClose }: { onClose: () => void }) => ( +
+ +
+ ), +})) + +// Mock lucide-react icons +jest.mock("lucide-react", () => ({ + Info: () =>
, + Bug: () =>
, +})) + +// Mock VSCode components +jest.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, appearance, className }: any) => ( + + ), + VSCodeCheckbox: ({ children, onChange, checked }: any) => ( + + ), + VSCodeLink: ({ children, href }: any) => {children}, +})) + +// Mock vscode API +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +const mockT = jest.fn((key) => key) +const mockTranslationContext = { + t: mockT, + i18n: {} as any, +} + +describe("About component", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const renderComponent = ( + props: { + version?: string + telemetrySetting?: TelemetrySetting + setTelemetrySetting?: (setting: TelemetrySetting) => void + } = {}, + ) => { + const { version = "1.0.0", telemetrySetting = "unset", setTelemetrySetting = jest.fn() } = props + + return render( + + + , + ) + } + + it("renders the version number", () => { + renderComponent({ version: "1.2.3" }) + expect(mockT).toHaveBeenCalledWith("settings:sections.about") + expect(screen.getByText(/Version: 1.2.3/i)).toBeInTheDocument() + }) + + it("renders the telemetry checkbox", () => { + renderComponent() + expect(screen.getByTestId("telemetry-checkbox")).toBeInTheDocument() + }) + + it("renders the bug report button", () => { + renderComponent() + const bugReportText = mockT("settings:footer.bugreport.button") + expect(screen.getByText(bugReportText)).toBeInTheDocument() + expect(screen.getByTestId("bug-icon")).toBeInTheDocument() + }) + + it("opens the bug report dialog when clicking the button", async () => { + renderComponent() + + // Bug report dialog shouldn't be visible initially + expect(screen.queryByTestId("bug-report-dialog")).not.toBeInTheDocument() + + // Click the report bug button + const bugReportButton = screen.getByText(mockT("settings:footer.bugreport.button")) + fireEvent.click(bugReportButton) + + // Dialog should now be visible + await waitFor(() => { + expect(screen.getByTestId("bug-report-dialog")).toBeInTheDocument() + }) + }) + + it("closes the bug report dialog", async () => { + renderComponent() + + // Open dialog + const bugReportButton = screen.getByText(mockT("settings:footer.bugreport.button")) + fireEvent.click(bugReportButton) + + // Dialog should be visible + await waitFor(() => { + expect(screen.getByTestId("bug-report-dialog")).toBeInTheDocument() + }) + + // Close dialog + const closeButton = screen.getByTestId("close-dialog") + fireEvent.click(closeButton) + + // Dialog should be hidden + await waitFor(() => { + expect(screen.queryByTestId("bug-report-dialog")).not.toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/BugReportDialog.test.tsx b/webview-ui/src/components/settings/__tests__/BugReportDialog.test.tsx new file mode 100644 index 0000000000..f41a9e88c8 --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/BugReportDialog.test.tsx @@ -0,0 +1,285 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { BugReportDialog } from "../BugReportDialog" +import { TranslationContext } from "@/i18n/TranslationContext" + +// Mock vscode API +const mockPostMessage = jest.fn() +jest.mock("@/utils/vscode", () => ({ + vscode: { + postMessage: mockPostMessage, + }, +})) + +// Mock window.addEventListener to capture message handler +type MessageHandler = (event: any) => void +const mockMessageHandlers: Record = {} +const originalAddEventListener = window.addEventListener +const originalRemoveEventListener = window.removeEventListener + +beforeAll(() => { + window.addEventListener = jest.fn((event, handler) => { + if (event === "message" && typeof handler === "function") { + mockMessageHandlers[event] = handler as MessageHandler + } + return originalAddEventListener(event, handler) + }) + + window.removeEventListener = jest.fn((event, handler) => { + if (event === "message" && typeof handler === "function" && mockMessageHandlers[event] === handler) { + delete mockMessageHandlers[event] + } + return originalRemoveEventListener(event, handler) + }) +}) + +afterAll(() => { + window.addEventListener = originalAddEventListener + window.removeEventListener = originalRemoveEventListener +}) + +// Mock lucide-react icons +jest.mock("lucide-react", () => ({ + Bug: () =>
, + ClipboardCopy: () =>
, + ExternalLink: () =>
, +})) + +// Mock Dialog components +jest.mock("@/components/ui", () => ({ + Dialog: ({ children, open, onOpenChange }: any) => ( +
onOpenChange && onOpenChange(false)}> + {children} +
+ ), + DialogContent: ({ children, className }: any) => ( +
+ {children} +
+ ), + DialogHeader: ({ children, className }: any) => ( +
+ {children} +
+ ), + DialogTitle: ({ children, className }: any) => ( +
+ {children} +
+ ), + DialogFooter: ({ children, className }: any) => ( +
+ {children} +
+ ), +})) + +// Mock VSCode components +jest.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeButton: ({ children, onClick, appearance, disabled, className }: any) => ( + + ), +})) + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}) + +const mockT = jest.fn((key) => key) +const mockTranslationContext = { + t: mockT, + i18n: {} as any, +} + +describe("BugReportDialog component", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const renderComponent = (props: { onClose?: () => void } = {}) => { + const { onClose = jest.fn() } = props + + return render( + + + , + ) + } + + it("should render the dialog", () => { + renderComponent() + expect(screen.getByTestId("dialog")).toBeInTheDocument() + expect(screen.getByTestId("dialog-content")).toBeInTheDocument() + expect(screen.getByTestId("bug-icon")).toBeInTheDocument() + }) + + it("should request environment info when mounted", () => { + renderComponent() + expect(mockPostMessage).toHaveBeenCalledWith({ type: "getBugReportInfo" }) + }) + + it("should display loading state initially", () => { + renderComponent() + expect(screen.getByTestId("create-issue-button")).toBeDisabled() + expect(screen.getByTestId("copy-button")).toBeDisabled() + expect(screen.getByTestId("dialog-content").textContent).toContain(mockT("settings:bugreport.description")) + }) + + it("should display environment info when received", async () => { + renderComponent() + + // Simulate receiving environment info + const envInfo = { + vscodeVersion: "1.70.0", + platform: "darwin", + appVersion: "1.2.3", + } + + // Send a message with environment info + if (mockMessageHandlers.message) { + mockMessageHandlers.message({ + data: { + type: "bugReportInfo", + info: envInfo, + }, + }) + } + + // Should display the formatted info + await waitFor(() => { + const content = screen.getByTestId("dialog-content") + expect(content.textContent).toContain("vscodeVersion") + expect(content.textContent).toContain("platform") + expect(content.textContent).toContain("appVersion") + }) + + // Buttons should be enabled now + expect(screen.getByTestId("create-issue-button")).not.toBeDisabled() + expect(screen.getByTestId("copy-button")).not.toBeDisabled() + }) + + it("should copy environment info to clipboard", async () => { + renderComponent() + + // Simulate receiving environment info + const envInfo = { + vscodeVersion: "1.70.0", + platform: "darwin", + appVersion: "1.2.3", + } + + // Send a message with environment info + if (mockMessageHandlers.message) { + mockMessageHandlers.message({ + data: { + type: "bugReportInfo", + info: envInfo, + }, + }) + } + + // Wait for info to be displayed + await waitFor(() => { + expect(screen.getByTestId("copy-button")).not.toBeDisabled() + }) + + // Click copy button + fireEvent.click(screen.getByTestId("copy-button")) + + // Should call clipboard API + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining("- **vscodeVersion**: 1.70.0"), + ) + }) + + it("should create GitHub issue when button is clicked", async () => { + const onClose = jest.fn() + renderComponent({ onClose }) + + // Simulate receiving environment info + const envInfo = { + vscodeVersion: "1.70.0", + platform: "darwin", + appVersion: "1.2.3", + } + + // Send a message with environment info + if (mockMessageHandlers.message) { + mockMessageHandlers.message({ + data: { + type: "bugReportInfo", + info: envInfo, + }, + }) + } + + // Wait for info to be displayed + await waitFor(() => { + expect(screen.getByTestId("create-issue-button")).not.toBeDisabled() + }) + + // Click create issue button + fireEvent.click(screen.getByTestId("create-issue-button")) + + // Should post message to VS Code with URL containing environment info + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: expect.stringContaining("https://github.com/RooVetGit/Roo-Code/issues/new?template=bug_report.yml"), + }) + + // Verify URL contains the required parameters + const urlArg = mockPostMessage.mock.calls[mockPostMessage.mock.calls.length - 1][0].url + + // Check for version parameter + expect(urlArg).toContain("version=1.2.3") + + // Check for what-happened parameter with environment info + expect(urlArg).toContain("what-happened=") + expect(urlArg).toContain("Environment+Information") + expect(urlArg).toContain("vscodeVersion") + expect(urlArg).toContain("platform") + expect(urlArg).toContain("appVersion") + + // Check for additional-context parameter + expect(urlArg).toContain("additional-context=") + expect(urlArg).toContain("Platform%3A+darwin") + + // Should call onClose + expect(onClose).toHaveBeenCalled() + }) + + it("should close when cancel button is clicked", () => { + const onClose = jest.fn() + renderComponent({ onClose }) + + // Find the cancel button (secondary button that isn't the copy button) + const buttons = screen.getAllByRole("button") + const cancelButton = buttons.find( + (button) => + button !== screen.getByTestId("copy-button") && button !== screen.getByTestId("create-issue-button"), + ) + + // Click cancel button + if (cancelButton) { + fireEvent.click(cancelButton) + expect(onClose).toHaveBeenCalled() + } else { + fail("Cancel button not found") + } + }) +}) diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 29b163512e..58aa9ac9d1 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -372,6 +372,10 @@ "reset": { "description": "Reset all global state and secret storage in the extension.", "button": "Reset" + }, + "bugreport": { + "description": "Found a bug? Report it to help us improve Roo Code.", + "button": "Report Bug" } }, "thinkingBudget": { @@ -423,5 +427,12 @@ "labels": { "customArn": "Custom ARN", "useCustomArn": "Use custom ARN..." + }, + "bugreport": { + "title": "Report a Bug", + "description": "The following information will be included in your bug report to help us diagnose the issue:", + "copy": "Copy Info", + "copied": "Copied!", + "createIssue": "Create GitHub Issue" } }