diff --git a/e2e-tests/fix_error.spec.ts b/e2e-tests/fix_error.spec.ts index c361237800..2b78b75678 100644 --- a/e2e-tests/fix_error.spec.ts +++ b/e2e-tests/fix_error.spec.ts @@ -9,10 +9,17 @@ testSkipIfWindows("fix error with AI", async ({ po }) => { name: "fix-error-with-AI-1.aria.yml", }); + await po.previewPanel.collapsePreviewErrorBanner(); + await expect(po.previewPanel.locatePreviewErrorBanner()).toBeVisible(); await expect( - po.page.getByText("Error Line 6 error", { exact: true }), - ).toBeVisible({ timeout: Timeout.MEDIUM }); - await po.page.getByText("Error Line 6 error", { exact: true }).click(); + po.page.getByRole("button", { name: "Fix error with AI" }), + ).toBeHidden(); + await po.previewPanel.expandPreviewErrorBanner(); + + await expect(po.page.getByText("Line 6 error", { exact: true })).toBeVisible({ + timeout: Timeout.MEDIUM, + }); + await po.page.getByRole("button", { name: "Show details" }).click(); await po.previewPanel.snapshotPreviewErrorBanner({ name: "fix-error-with-AI-2.aria.yml", }); @@ -35,9 +42,9 @@ testSkipIfWindows("copy error message from banner", async ({ po }) => { await po.setUp({ autoApprove: true }); await po.sendPrompt("tc=create-error"); - await expect( - po.page.getByText("Error Line 6 error", { exact: true }), - ).toBeVisible({ timeout: Timeout.MEDIUM }); + await expect(po.page.getByText("Line 6 error", { exact: true })).toBeVisible({ + timeout: Timeout.MEDIUM, + }); await po.previewPanel.clickCopyErrorMessage(); diff --git a/e2e-tests/helpers/page-objects/components/PreviewPanel.ts b/e2e-tests/helpers/page-objects/components/PreviewPanel.ts index 89d1589b75..ed7f0afbc8 100644 --- a/e2e-tests/helpers/page-objects/components/PreviewPanel.ts +++ b/e2e-tests/helpers/page-objects/components/PreviewPanel.ts @@ -320,6 +320,18 @@ export class PreviewPanel { await this.page.getByRole("button", { name: "Fix error with AI" }).click(); } + async collapsePreviewErrorBanner() { + await this.page + .getByRole("button", { name: "Collapse error banner" }) + .click(); + } + + async expandPreviewErrorBanner() { + await this.page + .getByRole("button", { name: "Expand error banner" }) + .click(); + } + async clickCopyErrorMessage() { await this.page .getByTestId("preview-error-banner") diff --git a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-1.aria.yml b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-1.aria.yml index 28fecdd5c3..7169b0d8b9 100644 --- a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-1.aria.yml +++ b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-1.aria.yml @@ -1,12 +1,9 @@ -- button: - - img -- img -- text: Error Line 6 error -- img -- text: "Tip: Check if restarting the app fixes the error." +- paragraph: Line 6 error +- button "Collapse error banner" [expanded] +- button "Dismiss error banner" +- button "Show details" +- text: Try restarting the app. - button "Copy": - img - text: "" -- button "Fix error with AI": - - img - - text: "" \ No newline at end of file +- button "Fix error with AI" \ No newline at end of file diff --git a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-2.aria.yml b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-2.aria.yml index 1c18c53f53..d22257f22a 100644 --- a/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-2.aria.yml +++ b/e2e-tests/snapshots/fix_error.spec.ts_fix-error-with-AI-2.aria.yml @@ -1,12 +1,9 @@ -- button: - - img -- img -- text: "/Error Line 6 error Stack trace: Index \\(http:\\/\\/localhost:\\d+\\/src\\/pages\\/Index\\.tsx:6:6\\)/" -- img -- text: "Tip: Check if restarting the app fixes the error." +- paragraph: Line 6 error +- button "Collapse error banner" [expanded] +- button "Dismiss error banner" +- button "Hide details" [expanded] +- text: "/Error Line 6 error Stack trace: Index \\(http:\\/\\/localhost:\\d+\\/src\\/pages\\/Index\\.tsx:6:6\\) Try restarting the app\\./" - button "Copy": - img - text: "" -- button "Fix error with AI": - - img - - text: "" \ No newline at end of file +- button "Fix error with AI" \ No newline at end of file diff --git a/src/components/preview_panel/PreviewErrorBanner.test.tsx b/src/components/preview_panel/PreviewErrorBanner.test.tsx new file mode 100644 index 0000000000..3a9699d05d --- /dev/null +++ b/src/components/preview_panel/PreviewErrorBanner.test.tsx @@ -0,0 +1,122 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PreviewErrorBanner } from "./PreviewErrorBanner"; + +vi.mock("@/hooks/useStreamChat", () => ({ + useStreamChat: () => ({ isStreaming: false }), +})); + +vi.mock("@/components/CopyErrorMessage", () => ({ + CopyErrorMessage: () => , +})); + +const previewError = { + message: "Error Line 6 error\nStack trace: Index.tsx:6:6", + source: "preview-app" as const, +}; + +describe("PreviewErrorBanner", () => { + it("collapses to a compact summary and can be expanded again", () => { + render( + , + ); + + expect(screen.getByText("Try restarting the app.")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Fix error with AI" }), + ).toBeTruthy(); + + fireEvent.click( + screen.getByRole("button", { name: "Collapse error banner" }), + ); + + expect(screen.getByTestId("preview-error-banner")).toBeTruthy(); + expect(screen.getByText("Line 6 error")).toBeTruthy(); + expect(screen.queryByText("Try restarting the app.")).toBeNull(); + expect( + screen.queryByRole("button", { name: "Fix error with AI" }), + ).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Expand error banner" }), + ); + + expect(screen.getByText("Try restarting the app.")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Fix error with AI" }), + ).toBeTruthy(); + }); + + it("keeps dismissal separate from collapsing", () => { + const onDismiss = vi.fn(); + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Dismiss error banner" }), + ); + + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("still reveals the full error message independently", () => { + render( + , + ); + + expect(screen.queryByText(/Stack trace/)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + + expect(screen.getByText(/Stack trace/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Hide details" })).toBeTruthy(); + }); + + it("resets disclosure state when a different error arrives", () => { + const { rerender } = render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: "Collapse error banner" }), + ); + + const nextError = { + message: "Error A different failure", + source: "preview-app" as const, + }; + rerender( + , + ); + + expect( + screen.getByRole("button", { name: "Collapse error banner" }), + ).toBeTruthy(); + expect(screen.getByText("Try restarting the app.")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Fix error with AI" }), + ).toBeTruthy(); + }); +}); diff --git a/src/components/preview_panel/PreviewErrorBanner.tsx b/src/components/preview_panel/PreviewErrorBanner.tsx new file mode 100644 index 0000000000..b2f8c58d5f --- /dev/null +++ b/src/components/preview_panel/PreviewErrorBanner.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { + CircleAlert, + ChevronDown, + ChevronUp, + Lightbulb, + Sparkles, + X, +} from "lucide-react"; +import { CopyErrorMessage } from "@/components/CopyErrorMessage"; +import { useStreamChat } from "@/hooks/useStreamChat"; +import type { PreviewError } from "@/preview_iframe/state"; + +interface PreviewErrorBannerProps { + error: PreviewError; + onDismiss: () => void; + onAIFix: () => void; +} + +export function PreviewErrorBanner({ + error, + onDismiss, + onAIFix, +}: PreviewErrorBannerProps) { + const [isBannerCollapsed, setIsBannerCollapsed] = useState(false); + const [areErrorDetailsVisible, setAreErrorDetailsVisible] = useState(false); + const { isStreaming } = useStreamChat(); + + const isDockerError = error.message.includes("Cannot connect to the Docker"); + const isInternalDyadError = error.source === "dyad-app"; + const isSyncError = error.source === "dyad-sync"; + + const firstLine = error.message.split("\n")[0]; + const summaryWithoutErrorPrefix = firstLine.replace(/^Error:?\s+/i, ""); + const errorSummary = summaryWithoutErrorPrefix || firstLine; + + return ( +
+
+
+ + {!isBannerCollapsed && ( +
+ + + {areErrorDetailsVisible && ( +
+              {error.message}
+            
+ )} + +
+
+ + {!isDockerError && error.source === "preview-app" && ( +
+ + +
+ )} +
+ )} +
+ ); +} diff --git a/src/components/preview_panel/PreviewIframe.tsx b/src/components/preview_panel/PreviewIframe.tsx index 19cfffe6f9..61dcd1eff7 100644 --- a/src/components/preview_panel/PreviewIframe.tsx +++ b/src/components/preview_panel/PreviewIframe.tsx @@ -17,11 +17,7 @@ import { ExternalLink, Cloud, Cog, - X, - Sparkles, - Lightbulb, ChevronDown, - ChevronRight, MousePointerClick, Power, MonitorSmartphone, @@ -35,7 +31,6 @@ import { Loader2, } from "lucide-react"; import { selectedChatIdAtom } from "@/atoms/chatAtoms"; -import { CopyErrorMessage } from "@/components/CopyErrorMessage"; import { ipc } from "@/ipc/types"; import { useParseRouter } from "@/hooks/useParseRouter"; @@ -94,6 +89,7 @@ import { RecordingBannerHost } from "./RecordingBannerHost"; import { RecordingStorageWarningDialog } from "./RecordingStorageWarningDialog"; import { resolvePreviewBrowserUrl } from "./previewBrowserUrl"; import { PreviewLoadingScreen } from "./PreviewLoadingScreen"; +import { PreviewErrorBanner } from "./PreviewErrorBanner"; import { useTranslation } from "react-i18next"; import { formatPreviewAddressPath, @@ -114,110 +110,6 @@ import { } from "@/screenshot/useScreenshot"; import { useAppRunRemoteManager } from "@/app_run/AppRunRemoteProvider"; -interface ErrorBannerProps { - error: - | { - message: string; - source: "preview-app" | "dyad-app" | "dyad-sync"; - } - | undefined; - onDismiss: () => void; - onAIFix: () => void; -} - -const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => { - const [isCollapsed, setIsCollapsed] = useState(true); - const { isStreaming } = useStreamChat(); - if (!error) return null; - const isDockerError = error.message.includes("Cannot connect to the Docker"); - const isInternalDyadError = error.source === "dyad-app"; - const isSyncError = error.source === "dyad-sync"; - - const getTruncatedError = () => { - const firstLine = error.message.split("\n")[0]; - const snippetLength = 250; - const snippet = error.message.substring(0, snippetLength); - return firstLine.length < snippet.length - ? firstLine - : snippet + (snippet.length === snippetLength ? "..." : ""); - }; - - return ( -
- {/* Close button in top left */} - - - {(isInternalDyadError || isSyncError) && ( -
- {isSyncError ? "Cloud sync issue" : "Internal Dyad error"} -
- )} - - {/* Error message in the middle */} -
-
setIsCollapsed(!isCollapsed)} - > - - - {isCollapsed ? getTruncatedError() : error.message} -
-
- - {/* Tip message */} -
-
-
- -
- - Tip: - {isDockerError - ? "Make sure Docker Desktop is running and try restarting the app." - : isSyncError - ? "Dyad could not upload your latest local changes to the cloud sandbox. Check your network connection or wait for sync to recover." - : isInternalDyadError - ? "Try restarting the Dyad app or restarting your computer to see if that fixes the error." - : "Check if restarting the app fixes the error."} - -
-
- - {/* Action buttons at the bottom */} - {!isDockerError && error.source === "preview-app" && ( -
- - -
- )} -
- ); -}; - // Preview iframe component export const PreviewIframe = ({ loading, @@ -1695,14 +1587,15 @@ export const PreviewIframe = ({ />
- {!loading && ( - sendIframeEvent({ type: "DISMISS" })} onAIFix={() => { if (selectedChatId) { streamMessage({ - prompt: `Fix error: ${errorMessage?.message}`, + prompt: `Fix error: ${errorMessage.message}`, chatId: selectedChatId, }); }