Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6288,6 +6288,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