diff --git a/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx
new file mode 100644
index 0000000000000..3a8b3d951bff9
--- /dev/null
+++ b/apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx
@@ -0,0 +1,38 @@
+import "../../index.css";
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it, vi } from "vite-plus/test";
+import { page } from "vite-plus/test/browser";
+
+import { ThreadErrorBanner } from "./ThreadErrorBanner";
+
+it("expands even a short JSON error to its exact diagnostics and collapses back to its message", async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ document.body.append(container);
+ const root = createRoot(container);
+ const error = ' {"error": {"message":"Turn failed", "code":"unsupported_value"}}\t ';
+
+ try {
+ await act(async () => root.render());
+
+ await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible();
+ expect(container.textContent).not.toContain("unsupported_value");
+
+ await act(async () => page.getByRole("button", { name: "Show full error" }).click());
+
+ const diagnostics = page.getByText(error, { exact: true });
+ await expect.element(diagnostics).toBeVisible();
+ expect(diagnostics.element().textContent).toBe(error);
+
+ await act(async () => page.getByRole("button", { name: "Show less" }).click());
+
+ await expect.element(page.getByText("Turn failed", { exact: true })).toBeVisible();
+ expect(container.textContent).not.toContain("unsupported_value");
+ } finally {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ }
+});
diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx
index 418fdf0adb5b1..d603dc8530e3f 100644
--- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx
+++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx
@@ -3,10 +3,41 @@ import { describe, expect, it } from "vite-plus/test";
import {
dismissThreadErrorBannerForSession,
getThreadErrorBannerKey,
+ getThreadErrorBannerMessage,
isThreadErrorBannerDismissedForSession,
shouldShowThreadErrorBanner,
} from "./ThreadErrorBanner";
+describe("getThreadErrorBannerMessage", () => {
+ it("extracts a readable nested message without trimming its whitespace", () => {
+ const message =
+ " This model is not supported with a ChatGPT account.\nChoose another model. ";
+ const error = ` \n${JSON.stringify({ error: { message, code: "unsupported_model" } })}\t `;
+
+ expect(getThreadErrorBannerMessage(error)).toBe(message);
+ });
+
+ it.each([
+ ["plain text", " Could not start the turn.\nTry again. "],
+ ["invalid JSON", '{"error":{"message":"Cut off"'],
+ ["an unfamiliar shape", '{"message":"Top-level message","status":400}'],
+ ])("leaves %s unchanged", (_description, error) => {
+ expect(getThreadErrorBannerMessage(error)).toBe(error);
+ });
+
+ it("keeps the diagnostics when the nested message is blank", () => {
+ const error = JSON.stringify({ error: { message: " \n\t ", code: "unsupported_model" } });
+
+ expect(getThreadErrorBannerMessage(error)).toBe(error);
+ });
+
+ it("does not recursively unwrap the selected message", () => {
+ const message = JSON.stringify({ error: { message: "Inner message" } });
+
+ expect(getThreadErrorBannerMessage(JSON.stringify({ error: { message } }))).toBe(message);
+ });
+});
+
describe("ThreadErrorBanner", () => {
it("stays hidden after its current error is dismissed", () => {
const bannerKey = getThreadErrorBannerKey("env:thread-a", "Aborted");
@@ -21,15 +52,19 @@ describe("ThreadErrorBanner", () => {
).toBe(false);
});
- it("reappears when a new error arrives on the same thread", () => {
- dismissThreadErrorBannerForSession(getThreadErrorBannerKey("env:thread-b", "Turn failed"));
- const newErrorKey = getThreadErrorBannerKey("env:thread-b", "Provider crashed");
+ it("reappears for different diagnostics with the same headline on the same thread", () => {
+ const firstError = JSON.stringify({ error: { message: "Turn failed", code: "first" } });
+ const secondError = JSON.stringify({ error: { message: "Turn failed", code: "second" } });
+ const dismissedKey = getThreadErrorBannerKey("env:thread-b", firstError);
+ dismissThreadErrorBannerForSession(dismissedKey);
+ const newErrorKey = getThreadErrorBannerKey("env:thread-b", secondError);
+ expect(isThreadErrorBannerDismissedForSession(dismissedKey)).toBe(true);
expect(isThreadErrorBannerDismissedForSession(newErrorKey)).toBe(false);
expect(
shouldShowThreadErrorBanner(
"env:thread-b",
- "Provider crashed",
+ secondError,
isThreadErrorBannerDismissedForSession(newErrorKey),
),
).toBe(true);
diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx
index 54f65238687b6..198fbb687c61b 100644
--- a/apps/web/src/components/chat/ThreadErrorBanner.tsx
+++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx
@@ -7,6 +7,25 @@ import { OpenAI } from "../Icons";
import { ChatGptUsageButton } from "../settings/ChatGptUsageButton";
import { ExpandableText } from "../settings/ExpandableText";
+export function getThreadErrorBannerMessage(error: string): string {
+ let payload: unknown;
+ try {
+ payload = JSON.parse(error);
+ } catch {
+ return error;
+ }
+ if (typeof payload !== "object" || payload === null || !("error" in payload)) {
+ return error;
+ }
+ const nestedError = payload.error;
+ if (typeof nestedError !== "object" || nestedError === null || !("message" in nestedError)) {
+ return error;
+ }
+ return typeof nestedError.message === "string" && nestedError.message.trim().length > 0
+ ? nestedError.message
+ : error;
+}
+
export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {
return error === null ? null : `${threadKey}\u0000${error}`;
}
@@ -59,7 +78,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
Review your usage settings in ChatGPT to continue.
) : (
-
+
)}
{(chatGptUsageLimit || onDismiss) && (
diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx
index f0452eeb1eb83..a30d6f22eb768 100644
--- a/apps/web/src/components/settings/ExpandableText.tsx
+++ b/apps/web/src/components/settings/ExpandableText.tsx
@@ -5,18 +5,20 @@ import { cn } from "../../lib/utils";
export function ExpandableText({
text,
+ collapsedText = text,
className,
collapsedClassName = "line-clamp-3",
expandLabel = "Show full error",
}: {
text: string;
+ collapsedText?: string;
className?: string;
collapsedClassName?: string;
expandLabel?: string;
}) {
const textId = useId();
const [expanded, setExpanded] = useState(false);
- const canExpand = text.length > 180 || text.includes("\n");
+ const canExpand = collapsedText !== text || text.length > 180 || text.includes("\n");
return (
@@ -27,7 +29,7 @@ export function ExpandableText({
!expanded && canExpand ? collapsedClassName : null,
)}
>
- {text}
+ {expanded ? text : collapsedText}
{canExpand ? (