Adding a button for copying error messages (#1882)

close #1870 





<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a “Copy” button to error banners and chat error output so users can
quickly copy error messages with clear feedback. Addresses Linear #1870.

- **New Features**
- Introduced CopyErrorMessage component that writes to clipboard and
shows “Copied” for 2s.
- Added the copy button to the Preview error banner and DyadOutput;
actions grouped at the bottom beside “Fix with AI”.
- Added Playwright e2e test and helpers to verify copy behavior and
clipboard content.

<sup>Written for commit 12e9bf1437ded36dc022e1d795025580d2ffd111.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Mohamed Aziz Mejri
2025-12-09 03:54:59 +01:00
committed by GitHub
parent 4b17870049
commit c174778d5f
6 changed files with 111 additions and 25 deletions

View File

@@ -1,4 +1,5 @@
import { testSkipIfWindows, test } from "./helpers/test_helper"; import { testSkipIfWindows, test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
testSkipIfWindows("fix error with AI", async ({ po }) => { testSkipIfWindows("fix error with AI", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
@@ -20,6 +21,26 @@ testSkipIfWindows("fix error with AI", async ({ po }) => {
await po.snapshotPreview(); await po.snapshotPreview();
}); });
testSkipIfWindows("copy error message from banner", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=create-error");
await po.page.getByText("Error Line 6 error", { exact: true }).waitFor({
state: "visible",
});
await po.clickCopyErrorMessage();
const clipboardText = await po.getClipboardText();
expect(clipboardText).toContain("Error Line 6 error");
expect(clipboardText.length).toBeGreaterThan(0);
await expect(po.page.getByRole("button", { name: "Copied" })).toBeVisible();
await expect(po.page.getByRole("button", { name: "Copied" })).toBeHidden({
timeout: 3000,
});
});
test("fix all errors button", async ({ po }) => { test("fix all errors button", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=create-multiple-errors"); await po.sendPrompt("tc=create-multiple-errors");

View File

@@ -575,6 +575,13 @@ export class PageObject {
await this.page.getByRole("button", { name: "Fix error with AI" }).click(); await this.page.getByRole("button", { name: "Fix error with AI" }).click();
} }
async clickCopyErrorMessage() {
await this.page.getByRole("button", { name: /Copy/ }).click();
}
async getClipboardText(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}
async clickFixAllErrors() { async clickFixAllErrors() {
await this.page.getByRole("button", { name: /Fix All Errors/ }).click(); await this.page.getByRole("button", { name: /Fix All Errors/ }).click();
} }

View File

@@ -7,10 +7,11 @@
- img - img
- text: "src/pages/Index.tsx Summary: intentionally add first error" - text: "src/pages/Index.tsx Summary: intentionally add first error"
- img - img
- text: Error - text: Error First error in Index...
- button "Fix with AI":
- img - img
- text: First error in Index... - button "Copy":
- img
- button "Fix with AI":
- img - img
- img - img
- text: ErrorComponent.tsx - text: ErrorComponent.tsx
@@ -19,10 +20,11 @@
- img - img
- text: "src/components/ErrorComponent.tsx Summary: intentionally add second error" - text: "src/components/ErrorComponent.tsx Summary: intentionally add second error"
- img - img
- text: Error - text: Error Second error in ErrorComponent...
- button "Fix with AI":
- img - img
- text: Second error in ErrorComponent... - button "Copy":
- img
- button "Fix with AI":
- img - img
- img - img
- text: helper.ts - text: helper.ts
@@ -31,10 +33,11 @@
- img - img
- text: "src/utils/helper.ts Summary: intentionally add third error" - text: "src/utils/helper.ts Summary: intentionally add third error"
- img - img
- text: Error - text: Error Third error in helper...
- button "Fix with AI":
- img - img
- text: Third error in helper... - button "Copy":
- img
- button "Fix with AI":
- img - img
- button "Fix All Errors (3)": - button "Fix All Errors (3)":
- img - img

View File

@@ -0,0 +1,49 @@
import { Copy, Check } from "lucide-react";
import { useState } from "react";
interface CopyErrorMessageProps {
errorMessage: string;
className?: string;
}
export const CopyErrorMessage = ({
errorMessage,
className = "",
}: CopyErrorMessageProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(errorMessage);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy error message:", err);
}
};
return (
<button
onClick={handleCopy}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
isCopied
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
} ${className}`}
title={isCopied ? "Copied!" : "Copy error message"}
>
{isCopied ? (
<>
<Check size={14} />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
);
};

View File

@@ -9,6 +9,7 @@ import {
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
interface DyadOutputProps { interface DyadOutputProps {
type: "error" | "warning"; type: "error" | "warning";
message?: string; message?: string;
@@ -59,19 +60,6 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
<span>{label}</span> <span>{label}</span>
</div> </div>
{/* Fix with AI button - always visible for errors */}
{isError && message && (
<div className="absolute top-9 left-2">
<button
onClick={handleAIFix}
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs p-1 w-24 h-6"
>
<Sparkles size={16} className="mr-1" />
<span>Fix with AI</span>
</button>
</div>
)}
{/* Main content, padded to avoid label */} {/* Main content, padded to avoid label */}
<div className="flex items-center justify-between pl-24 pr-6"> <div className="flex items-center justify-between pl-24 pr-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -103,6 +91,22 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
{children} {children}
</div> </div>
)} )}
{/* Action buttons at the bottom - always visible for errors */}
{isError && message && (
<div className="mt-3 px-6 flex justify-end gap-2">
<CopyErrorMessage
errorMessage={children ? `${message}\n${children}` : message}
/>
<button
onClick={handleAIFix}
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs px-2 py-1 h-6"
>
<Sparkles size={14} className="mr-1" />
<span>Fix with AI</span>
</button>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -25,6 +25,7 @@ import {
Smartphone, Smartphone,
} from "lucide-react"; } from "lucide-react";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useParseRouter } from "@/hooks/useParseRouter"; import { useParseRouter } from "@/hooks/useParseRouter";
@@ -136,13 +137,14 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
</div> </div>
</div> </div>
{/* AI Fix button at the bottom */} {/* Action buttons at the bottom */}
{!isDockerError && error.source === "preview-app" && ( {!isDockerError && error.source === "preview-app" && (
<div className="mt-2 flex justify-end"> <div className="mt-3 px-6 flex justify-end gap-2">
<CopyErrorMessage errorMessage={error.message} />
<button <button
disabled={isStreaming} disabled={isStreaming}
onClick={onAIFix} onClick={onAIFix}
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" className="cursor-pointer flex items-center space-x-1 px-2 py-1 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Sparkles size={14} /> <Sparkles size={14} />
<span>Fix error with AI</span> <span>Fix error with AI</span>