diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index d872329b93..8e6bd80df4 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -66,6 +66,7 @@ export enum TelemetryEventName { SHELL_INTEGRATION_ERROR = "Shell Integration Error", CONSECUTIVE_MISTAKE_ERROR = "Consecutive Mistake Error", CODE_INDEX_ERROR = "Code Index Error", + CRASH_REPORT_SUBMITTED = "Crash Report Submitted", } /** @@ -190,6 +191,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.TAB_SHOWN, TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, + TelemetryEventName.CRASH_REPORT_SUBMITTED, ]), properties: telemetryPropertiesSchema, }), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4dd0fee75e..4ff0b4083e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2618,5 +2618,100 @@ export const webviewMessageHandler = async ( } break } + case "submitCrashReport": { + if (message.crashReport) { + try { + // Log the crash report for debugging + provider.log(`Crash report submitted: ${JSON.stringify(message.crashReport, null, 2)}`) + + // If it's a human-relay crash, show the human relay dialog + if (message.crashReport.source === "human-relay") { + // Create a formatted message for human relay + const formattedReport = ` +Crash Report - ${message.crashReport.source} +===================================== +Description: ${message.crashReport.description} +Email: ${message.crashReport.email || "Not provided"} +Timestamp: ${new Date(message.crashReport.timestamp).toISOString()} + +Error Details: +${message.crashReport.errorDetails ? JSON.stringify(message.crashReport.errorDetails, null, 2) : "No error details"} + +User Agent: ${message.crashReport.userAgent} + `.trim() + + // Copy to clipboard for human relay + await vscode.env.clipboard.writeText(formattedReport) + + // Show human relay dialog + vscode.commands.executeCommand(getCommand("showHumanRelayDialog"), { + requestId: `crash-${Date.now()}`, + promptText: formattedReport, + }) + } else { + // For code-index and general crashes, save to a file or send to telemetry + const crashReportDir = path.join(provider.context.globalStorageUri.fsPath, "crash-reports") + await fs.mkdir(crashReportDir, { recursive: true }) + + const filename = `crash-${message.crashReport.source}-${Date.now()}.json` + const filepath = path.join(crashReportDir, filename) + + await safeWriteJson(filepath, message.crashReport) + + // Show success notification + vscode.window.showInformationMessage( + t("crashReport:submitSuccess") || + "Crash report submitted successfully. Thank you for your feedback!", + ) + + // Log telemetry event if available + if (TelemetryService.hasInstance()) { + TelemetryService.instance.captureEvent(TelemetryEventName.CRASH_REPORT_SUBMITTED, { + source: message.crashReport.source, + hasDescription: !!message.crashReport.description, + hasEmail: !!message.crashReport.email, + hasErrorDetails: !!message.crashReport.errorDetails, + }) + } + } + + // Send success response to webview + await provider.postMessageToWebview({ + type: "crashReportSubmitted", + success: true, + }) + } catch (error) { + provider.log( + `Error submitting crash report: ${error instanceof Error ? error.message : String(error)}`, + ) + vscode.window.showErrorMessage( + t("crashReport:submitError") || "Failed to submit crash report. Please try again.", + ) + + // Send error response to webview + await provider.postMessageToWebview({ + type: "crashReportSubmitted", + success: false, + error: error instanceof Error ? error.message : String(error), + }) + } + } + break + } + case "showCrashReportDialog": { + // This message can be sent from various parts of the UI to show the crash report dialog + await provider.postMessageToWebview({ + type: "showCrashReportDialog", + errorDetails: message.values?.errorDetails, + source: message.values?.source || "general", + }) + break + } + case "showNotification": { + if (message.text) { + vscode.window.showInformationMessage(message.text) + } + break + } } } diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ebdc137432..fb17491d2f 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -119,6 +119,8 @@ export interface ExtensionMessage { | "showEditMessageDialog" | "commands" | "insertTextIntoTextarea" + | "crashReportSubmitted" + | "showCrashReportDialog" text?: string payload?: any // Add a generic payload for now, can refine later action?: @@ -193,6 +195,14 @@ export interface ExtensionMessage { messageTs?: number context?: string commands?: Command[] + errorDetails?: { + message?: string + stack?: string + componentStack?: string + context?: string + timestamp?: number + } + source?: "code-index" | "human-relay" | "general" } export type ExtensionState = Pick< diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d59ccd556c..4cff21bcf7 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -211,6 +211,9 @@ export interface WebviewMessage { | "deleteCommand" | "createCommand" | "insertTextIntoTextarea" + | "submitCrashReport" + | "showCrashReportDialog" + | "showNotification" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account" @@ -273,6 +276,20 @@ export interface WebviewMessage { codebaseIndexGeminiApiKey?: string codebaseIndexMistralApiKey?: string } + crashReport?: { + source: "code-index" | "human-relay" | "general" + description: string + email?: string + errorDetails?: { + message?: string + stack?: string + componentStack?: string + context?: string + timestamp?: number + } + timestamp: number + userAgent: string + } } export const checkoutDiffPayloadSchema = z.object({ diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 4a90a60f3d..0f9f7d0b1c 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -39,6 +39,7 @@ import { import { AlertTriangle } from "lucide-react" import { useRooPortal } from "@src/components/ui/hooks/useRooPortal" import { useEscapeKey } from "@src/hooks/useEscapeKey" +import { CrashReportDialog } from "@src/components/common/CrashReportDialog" import type { EmbedderProvider } from "@roo/embeddingModels" import type { IndexingStatus } from "@roo/ExtensionMessage" import { CODEBASE_INDEX_DEFAULTS } from "@roo-code/types" @@ -151,6 +152,7 @@ export const CodeIndexPopover: React.FC = ({ const [open, setOpen] = useState(false) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = useState(false) const [isSetupSettingsOpen, setIsSetupSettingsOpen] = useState(false) + const [showCrashReport, setShowCrashReport] = useState(false) const [indexingStatus, setIndexingStatus] = useState(externalIndexingStatus) @@ -579,6 +581,14 @@ export const CodeIndexPopover: React.FC = ({ {t(`settings:codeIndex.indexingStatuses.${indexingStatus.systemStatus.toLowerCase()}`)} {indexingStatus.message ? ` - ${indexingStatus.message}` : ""} + {indexingStatus.systemStatus === "Error" && ( + setShowCrashReport(true)} + style={{ marginTop: "8px" }}> + {t("settings:codeIndex.reportError")} + + )} {indexingStatus.systemStatus === "Indexing" && (
@@ -1282,6 +1292,18 @@ export const CodeIndexPopover: React.FC = ({ + + {/* Crash Report Dialog */} + {showCrashReport && ( + setShowCrashReport(false)} + source="code-index" + errorDetails={{ + message: indexingStatus.message || "Code indexing error occurred", + }} + /> + )} ) } diff --git a/webview-ui/src/components/common/CrashReportDialog.tsx b/webview-ui/src/components/common/CrashReportDialog.tsx new file mode 100644 index 0000000000..89cf7b25ac --- /dev/null +++ b/webview-ui/src/components/common/CrashReportDialog.tsx @@ -0,0 +1,179 @@ +import React, { useState, useCallback } from "react" +import { VSCodeButton, VSCodeTextArea, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useTranslation } from "react-i18next" +import { vscode } from "@src/utils/vscode" + +interface CrashReportDialogProps { + isOpen: boolean + onClose: () => void + errorDetails?: { + message?: string + stack?: string + componentStack?: string + context?: string + timestamp?: number + } + source?: "code-index" | "human-relay" | "general" +} + +export const CrashReportDialog: React.FC = ({ + isOpen, + onClose, + errorDetails, + source = "general", +}) => { + const { t } = useTranslation(["common", "crashReport"]) + const [description, setDescription] = useState("") + const [email, setEmail] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [submitSuccess, setSubmitSuccess] = useState(false) + + const handleSubmit = useCallback(async () => { + // Validate that description is not empty + if (!description.trim()) { + return + } + + setIsSubmitting(true) + + const crashReport = { + source, + description, + email, + errorDetails, + timestamp: Date.now(), + userAgent: navigator.userAgent, + } + + // Send crash report to backend + vscode.postMessage({ + type: "submitCrashReport", + crashReport, + }) + + // Simulate submission delay + setTimeout(() => { + setIsSubmitting(false) + setSubmitSuccess(true) + // Auto-close after success + setTimeout(() => { + onClose() + setSubmitSuccess(false) + setDescription("") + setEmail("") + }, 2000) + }, 1000) + }, [source, description, email, errorDetails, onClose]) + + const handleCopyDetails = useCallback(() => { + const details = JSON.stringify(errorDetails, null, 2) + navigator.clipboard.writeText(details) + vscode.postMessage({ + type: "showNotification", + text: t("crashReport:copiedToClipboard"), + }) + }, [errorDetails, t]) + + if (!isOpen) return null + + return ( +
+
+

{t("crashReport:title")}

+ + {submitSuccess ? ( +
+
+

{t("crashReport:submitSuccess")}

+
+ ) : ( + <> +
+

{t("crashReport:description")}

+
+ + {errorDetails && ( +
+
+ + + {t("crashReport:copyDetails")} + +
+
+
+										{errorDetails.message && (
+											<>
+												Message: {errorDetails.message}
+												{"\n\n"}
+											
+										)}
+										{errorDetails.context && (
+											<>
+												Context: {errorDetails.context}
+												{"\n\n"}
+											
+										)}
+										{errorDetails.stack && (
+											<>
+												Stack:
+												{"\n"}
+												{errorDetails.stack}
+											
+										)}
+									
+
+
+ )} + +
+ + setDescription(e.target.value)} + placeholder={t("crashReport:whatHappenedPlaceholder")} + rows={4} + className="w-full" + /> +
+ +
+ + setEmail(e.target.value)} + placeholder={t("crashReport:emailPlaceholder")} + type="email" + className="w-full" + /> +

+ {t("crashReport:emailDescription")} +

+
+ +
+ + {t("common:cancel")} + + + {isSubmitting ? t("crashReport:submitting") : t("crashReport:submit")} + +
+ + {source === "human-relay" && ( +
+

{t("crashReport:humanRelayNote")}

+
+ )} + + )} +
+
+ ) +} diff --git a/webview-ui/src/components/common/__tests__/CrashReportDialog.spec.tsx b/webview-ui/src/components/common/__tests__/CrashReportDialog.spec.tsx new file mode 100644 index 0000000000..05447ab810 --- /dev/null +++ b/webview-ui/src/components/common/__tests__/CrashReportDialog.spec.tsx @@ -0,0 +1,145 @@ +import React from "react" +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { vi } from "vitest" +import { CrashReportDialog } from "../CrashReportDialog" + +// Mock vscode API +const mockPostMessage = vi.fn() +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (message: any) => mockPostMessage(message), + }, +})) + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "crashReport:title": "Report an Issue", + "crashReport:description": "Help us improve by reporting this issue.", + "crashReport:errorDetails": "Error Details", + "crashReport:copyDetails": "Copy Details", + "crashReport:copiedToClipboard": "Copied to clipboard", + "crashReport:whatHappened": "What happened?", + "crashReport:whatHappenedPlaceholder": "Describe what happened...", + "crashReport:email": "Email", + "crashReport:optional": "optional", + "crashReport:emailPlaceholder": "your@email.com", + "crashReport:emailDescription": "We'll only use this to follow up", + "crashReport:submit": "Submit Report", + "crashReport:submitting": "Submitting...", + "crashReport:submitSuccess": "Thank you for your report!", + "crashReport:humanRelayNote": "This will be sent via Human Relay", + "common:cancel": "Cancel", + } + return translations[key] || key + }, + }), +})) + +// Mock clipboard API +Object.assign(navigator, { + clipboard: { + writeText: vi.fn().mockResolvedValue(undefined), + }, +}) + +describe("CrashReportDialog", () => { + beforeEach(() => { + vi.clearAllMocks() + mockPostMessage.mockClear() + }) + + it("should not render when isOpen is false", () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it("should render when isOpen is true", () => { + render() + expect(screen.getByText("Report an Issue")).toBeInTheDocument() + }) + + it("should display error details when provided", () => { + const errorDetails = { + message: "Test error message", + stack: "Error stack trace", + context: "code-index", + timestamp: Date.now(), + } + + render() + + expect(screen.getByText(/Test error message/)).toBeInTheDocument() + expect(screen.getByText(/Error stack trace/)).toBeInTheDocument() + }) + + it("should copy error details to clipboard when copy button is clicked", async () => { + const errorDetails = { + message: "Test error", + stack: "Stack trace", + } + + render() + + const copyButton = screen.getByText("Copy Details") + fireEvent.click(copyButton) + + await waitFor(() => { + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(JSON.stringify(errorDetails, null, 2)) + }) + }) + + it("should show human relay note for human-relay source", () => { + render() + + expect(screen.getByText("This will be sent via Human Relay")).toBeInTheDocument() + }) + + it("should call onClose when cancel button is clicked", () => { + const onClose = vi.fn() + render() + + const cancelButton = screen.getByText("Cancel") + fireEvent.click(cancelButton) + + expect(onClose).toHaveBeenCalled() + }) + + it("should render all required form fields", () => { + render() + + // Check for form labels + expect(screen.getByText("What happened?")).toBeInTheDocument() + // Email label is rendered with (optional) in the same element + expect(screen.getByText(/Email/)).toBeInTheDocument() + + // Check for buttons + expect(screen.getByText("Cancel")).toBeInTheDocument() + expect(screen.getByText("Submit Report")).toBeInTheDocument() + }) + + it("should display correct source in crash report", () => { + const { container } = render() + + // The source is passed to the component and will be included in the crash report + // We can't easily test the actual submission due to VSCode webview component limitations + // but we can verify the component renders with the correct props + expect(container.querySelector(".fixed")).toBeInTheDocument() + }) + + it("should include error details in rendered output", () => { + const errorDetails = { + message: "Critical error occurred", + context: "During indexing", + stack: "at function xyz", + } + + render() + + expect(screen.getByText(/Critical error occurred/)).toBeInTheDocument() + expect(screen.getByText(/During indexing/)).toBeInTheDocument() + expect(screen.getByText(/at function xyz/)).toBeInTheDocument() + }) +}) diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 0ea5e133b8..d659d6e7c3 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -1,4 +1,22 @@ { + "crashReport": { + "title": "Report an Issue", + "description": "Help us improve by reporting this issue. Your feedback is valuable in making the extension better.", + "errorDetails": "Error Details", + "copyDetails": "Copy Details", + "copiedToClipboard": "Error details copied to clipboard", + "whatHappened": "What were you doing when this happened?", + "whatHappenedPlaceholder": "Please describe what you were trying to do when the error occurred...", + "email": "Email", + "optional": "optional", + "emailPlaceholder": "your.email@example.com", + "emailDescription": "We'll only use this to follow up if we need more information", + "submit": "Submit Report", + "submitting": "Submitting...", + "submitSuccess": "Thank you! Your report has been submitted successfully.", + "submitError": "Failed to submit crash report. Please try again.", + "humanRelayNote": "This report will be sent via Human Relay. The error details will be copied to your clipboard for manual submission." + }, "title": "Modes", "done": "Done", "modes": {