Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6259,6 +6259,7 @@ function ChatViewContent(props: ChatViewProps) {

<ThreadErrorBanner
error={visibleThreadError}
key={threadErrorBannerKey}
onDismiss={() => {
setThreadError(activeThread.id, null);
dismissThreadErrorBannerForSession(threadErrorBannerKey);
Expand Down
36 changes: 34 additions & 2 deletions apps/web/src/components/chat/ThreadErrorBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vite-plus/test";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

import {
dismissThreadErrorBannerForSession,
getThreadErrorBannerKey,
isThreadErrorBannerDismissedForSession,
showThreadErrorCopyFailure,
shouldShowThreadErrorBanner,
ThreadErrorBanner,
} from "./ThreadErrorBanner";
import { anchoredToastManager } from "../ui/toast";

afterEach(() => {
vi.restoreAllMocks();
});

describe("ThreadErrorBanner", () => {
it("stays hidden after its current error is dismissed", () => {
Expand Down Expand Up @@ -51,6 +57,12 @@ describe("ThreadErrorBanner", () => {
).toBe(true);
});

it("gives identical diagnostics on different threads distinct render identities", () => {
expect(getThreadErrorBannerKey("env:thread-c", "Aborted")).not.toBe(
getThreadErrorBannerKey("env:other-thread", "Aborted"),
);
});

it("keeps a dismissal across visiting threads with no error", () => {
const bannerKey = getThreadErrorBannerKey("env:thread-d", "Aborted");
dismissThreadErrorBannerForSession(bannerKey);
Expand All @@ -69,7 +81,8 @@ describe("ThreadErrorBanner", () => {
it("never shows a null error", () => {
expect(shouldShowThreadErrorBanner("env:thread-e", null, false)).toBe(false);
});
it("aligns the warning and dismiss icons with the first line of a multi-line error", () => {

it("offers copy and aligns the controls with the first line of a multi-line error", () => {
const markup = renderToStaticMarkup(
<ThreadErrorBanner
error={"The first error line\ncontinues on a second line"}
Expand All @@ -78,11 +91,30 @@ describe("ThreadErrorBanner", () => {
);

expect(markup).toContain('role="alert"');
expect(markup).toContain('aria-label="Copy error"');
expect(markup).toContain("lucide-copy text-error");
expect(markup).toContain('aria-label="Dismiss error"');
expect(markup).not.toContain("controlAlignment");
expect(markup).toContain("flex gap-2 items-start");
expect(markup).toContain("min-h-7 pt-1 sm:min-h-6 sm:pt-0.5");
expect(markup).toContain("h-lh w-4");
expect(markup).toContain("h-lh self-start");
});

it("shows anchored feedback when copying fails", () => {
const addToast = vi
.spyOn(anchoredToastManager, "add")
.mockImplementation(() => undefined as never);
const anchor = {} as HTMLButtonElement;

showThreadErrorCopyFailure(anchor, new Error("Clipboard access was denied."));

expect(addToast).toHaveBeenCalledWith({
data: { tooltipStyle: true },
positionerProps: { anchor },
timeout: 1000,
title: "Failed to copy",
description: "Clipboard access was denied.",
});
});
});
62 changes: 56 additions & 6 deletions apps/web/src/components/chat/ThreadErrorBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,56 @@
import { memo } from "react";
import { memo, useRef } from "react";
import { Alert, AlertAction, AlertDescription } from "../ui/alert";
import { Button } from "../ui/button";
import { CircleAlertIcon, XIcon } from "lucide-react";
import { CheckIcon, CircleAlertIcon, CopyIcon, XIcon } from "lucide-react";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
import { anchoredToastManager } from "../ui/toast";

const COPY_FEEDBACK_TIMEOUT_MS = 1000;

export function showThreadErrorCopyFailure(
anchor: HTMLButtonElement | null,
copyError: Error,
): void {
if (!anchor) return;
anchoredToastManager.add({
data: { tooltipStyle: true },
positionerProps: { anchor },
timeout: COPY_FEEDBACK_TIMEOUT_MS,
title: "Failed to copy",
description: copyError.message,
});
}

function ThreadErrorCopyButton({ error }: { error: string }) {
const buttonRef = useRef<HTMLButtonElement>(null);
const { copyToClipboard, isCopied } = useCopyToClipboard({
target: "error-message",
timeout: COPY_FEEDBACK_TIMEOUT_MS,
onError: (copyError) => showThreadErrorCopyFailure(buttonRef.current, copyError),
});
const copyLabel = isCopied ? "Copied error" : "Copy error";

return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
aria-label={copyLabel}
onClick={() => copyToClipboard(error)}
ref={buttonRef}
type="button"
/>
}
>
{isCopied ? <CheckIcon className="text-success!" /> : <CopyIcon className="text-error" />}
</TooltipTrigger>
<TooltipPopup side="top">{copyLabel}</TooltipPopup>
</Tooltip>
);
}

export function getThreadErrorBannerKey(threadKey: string, error: string | null): string | null {
return error === null ? null : `${threadKey}\u0000${error}`;
Expand Down Expand Up @@ -41,6 +89,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
onDismiss?: () => void;
}) {
if (!error) return null;

return (
<div className="mx-auto w-fit max-w-[min(48rem,calc(100%-2rem))] pt-3">
<Alert variant="error" controlAlignment="first-line">
Expand All @@ -53,13 +102,14 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({
</TooltipPopup>
</Tooltip>
</AlertDescription>
{onDismiss && (
<AlertAction>
<AlertAction>
<ThreadErrorCopyButton error={error} />
{onDismiss && (
<Button variant="ghost" size="icon-xs" aria-label="Dismiss error" onClick={onDismiss}>
<XIcon className="text-destructive" />
</Button>
</AlertAction>
)}
)}
</AlertAction>
</Alert>
</div>
);
Expand Down
206 changes: 202 additions & 4 deletions apps/web/src/hooks/useCopyToClipboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,100 @@
import type { Dispatch, SetStateAction } from "react";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";

const hooks = vi.hoisted(() => {
let cursor = 0;
let slots: unknown[] = [];

const nextIndex = () => cursor++;
const unmount = () => {
for (const slot of slots) {
if (
typeof slot === "object" &&
slot !== null &&
"effectCleanup" in slot &&
typeof slot.effectCleanup === "function"
) {
slot.effectCleanup();
slot.effectCleanup = undefined;
}
}
};

return {
beginRender() {
cursor = 0;
},
reset() {
unmount();
cursor = 0;
slots = [];
},
unmount,
useCallback<T>(callback: T): T {
nextIndex();
return callback;
},
useEffect(effect: () => void | (() => void)): void {
const index = nextIndex();
if (slots[index] === undefined) {
slots[index] = { effectCleanup: effect() };
}
},
useRef<T>(initialValue: T): { current: T } {
const index = nextIndex();
if (slots[index] === undefined) {
slots[index] = { current: initialValue };
}
return slots[index] as { current: T };
},
useState<T>(initialValue: T | (() => T)): [T, Dispatch<SetStateAction<T>>] {
const index = nextIndex();
if (slots[index] === undefined) {
slots[index] =
typeof initialValue === "function" ? (initialValue as () => T)() : initialValue;
}
const setValue: Dispatch<SetStateAction<T>> = (nextValue) => {
const previous = slots[index] as T;
slots[index] =
typeof nextValue === "function" ? (nextValue as (value: T) => T)(previous) : nextValue;
};
return [slots[index] as T, setValue];
},
};
});

vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useCallback: hooks.useCallback,
useEffect: hooks.useEffect,
useRef: hooks.useRef,
useState: hooks.useState,
};
});

import {
ClipboardApiUnavailableError,
ClipboardWriteError,
useCopyToClipboard,
writeTextToClipboard,
} from "./useCopyToClipboard";

describe("writeTextToClipboard", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
async function flushClipboardWrite(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}

afterEach(() => {
hooks.reset();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe("writeTextToClipboard", () => {
it("reports unavailable clipboard support with structural context", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", {});
Expand Down Expand Up @@ -55,4 +139,118 @@ describe("writeTextToClipboard", () => {
await expect(writeTextToClipboard("", "plan")).resolves.toBe(false);
expect(writeText).not.toHaveBeenCalled();
});

it("writes the complete multiline value without transforming it", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const diagnostic = "Provider failed\ncaused by: socket closed\nrequest id: abc-123";

await expect(writeTextToClipboard(diagnostic, "error-message")).resolves.toBe(true);

expect(writeText).toHaveBeenCalledWith(diagnostic);
});
});

describe("useCopyToClipboard", () => {
it("shows copied state only after success and resets it after the timeout", async () => {
vi.useFakeTimers();
const writeText = vi.fn().mockResolvedValue(undefined);
const onCopy = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const options = { timeout: 1000, onCopy };

hooks.beginRender();
const initial = useCopyToClipboard<string>(options);
initial.copyToClipboard("complete diagnostic", "thread-a");
await flushClipboardWrite();

hooks.beginRender();
expect(useCopyToClipboard<string>(options).isCopied).toBe(true);
expect(onCopy).toHaveBeenCalledWith("thread-a");

vi.advanceTimersByTime(1000);
hooks.beginRender();
expect(useCopyToClipboard<string>(options).isCopied).toBe(false);
});

it("clears copied feedback when a repeat attempt fails", async () => {
vi.useFakeTimers();
const cause = new Error("clipboard permission revoked");
const writeText = vi.fn().mockResolvedValueOnce(undefined).mockRejectedValueOnce(cause);
const onError = vi.fn();
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const options = { timeout: 1000, target: "error-message", onError };

hooks.beginRender();
let current = useCopyToClipboard<string>(options);
current.copyToClipboard("first diagnostic", "thread-a");
await flushClipboardWrite();

hooks.beginRender();
current = useCopyToClipboard<string>(options);
expect(current.isCopied).toBe(true);
current.copyToClipboard("second diagnostic", "thread-a");

hooks.beginRender();
expect(useCopyToClipboard<string>(options).isCopied).toBe(false);
await flushClipboardWrite();

expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ cause, target: "error-message" }),
"thread-a",
);
expect(errorLog).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});

it("ignores stale completions from an earlier copy attempt", async () => {
let resolveFirstWrite!: () => void;
const firstWrite = new Promise<void>((resolve) => {
resolveFirstWrite = resolve;
});
const writeText = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValueOnce(undefined);
const onCopy = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });
const options = { timeout: 0, onCopy };

hooks.beginRender();
const current = useCopyToClipboard<string>(options);
current.copyToClipboard("older diagnostic", "older");
current.copyToClipboard("newer diagnostic", "newer");
await flushClipboardWrite();

expect(onCopy).toHaveBeenCalledOnce();
expect(onCopy).toHaveBeenCalledWith("newer");

resolveFirstWrite();
await flushClipboardWrite();
expect(onCopy).toHaveBeenCalledOnce();
});

it("ignores a pending clipboard completion after unmount", async () => {
vi.useFakeTimers();
let resolveWrite!: () => void;
const pendingWrite = new Promise<void>((resolve) => {
resolveWrite = resolve;
});
const writeText = vi.fn().mockReturnValue(pendingWrite);
const onCopy = vi.fn();
vi.stubGlobal("window", {});
vi.stubGlobal("navigator", { clipboard: { writeText } });

hooks.beginRender();
const current = useCopyToClipboard<void>({ onCopy });
current.copyToClipboard("diagnostic");
hooks.unmount();
resolveWrite();
await flushClipboardWrite();

expect(onCopy).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
});
Loading
Loading