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 ? (