Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/src/components/chat/ThreadErrorBanner.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<ThreadErrorBanner error={error} />));

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();
}
});
43 changes: 39 additions & 4 deletions apps/web/src/components/chat/ThreadErrorBanner.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand Down
21 changes: 20 additions & 1 deletion apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
Expand Down Expand Up @@ -59,7 +78,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
<p>Review your usage settings in ChatGPT to continue.</p>
</div>
) : (
<ExpandableText text={error} />
<ExpandableText text={error} collapsedText={getThreadErrorBannerMessage(error)} />
)}
</AlertDescription>
{(chatGptUsageLimit || onDismiss) && (
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/settings/ExpandableText.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className={cn("min-w-0", className)}>
Expand All @@ -27,7 +29,7 @@ export function ExpandableText({
!expanded && canExpand ? collapsedClassName : null,
)}
>
{text}
{expanded ? text : collapsedText}
</div>
{canExpand ? (
<InlineButton
Expand Down
Loading