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: () => Copy ,
+}));
+
+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 (
+
+
+
+
+
+
+
+ {errorSummary}
+
+ {(isInternalDyadError || isSyncError) && (
+
+ {isSyncError ? "Cloud sync issue" : "Internal Dyad error"}
+
+ )}
+
+
+
+
+ setIsBannerCollapsed((collapsed) => !collapsed)}
+ aria-label={
+ isBannerCollapsed
+ ? "Expand error banner"
+ : "Collapse error banner"
+ }
+ aria-expanded={!isBannerCollapsed}
+ aria-controls="preview-error-banner-content"
+ className="rounded p-1 text-red-500 transition-colors hover:bg-red-100 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-900 dark:hover:text-red-200"
+ data-testid="preview-error-banner-toggle"
+ >
+ {isBannerCollapsed ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {!isBannerCollapsed && (
+
+
+ setAreErrorDetailsVisible((detailsVisible) => !detailsVisible)
+ }
+ aria-expanded={areErrorDetailsVisible}
+ >
+ {areErrorDetailsVisible ? "Hide details" : "Show details"}
+
+
+ {areErrorDetailsVisible && (
+
+ {error.message}
+
+ )}
+
+
+
+
+ {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 your computer."
+ : "Try restarting the app."}
+
+
+
+ {!isDockerError && error.source === "preview-app" && (
+
+
+
+
+ Fix error with AI
+
+
+ )}
+
+ )}
+
+ );
+}
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" && (
-
-
-
-
- Fix error with AI
-
-
- )}
-
- );
-};
-
// 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,
});
}