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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- **feat(playground): copy an individual Compare column's response.** Each column in the Compare
tab now has a copy button beside the remove button, reusing the existing `useCopyToClipboard`
hook to copy that column's response text and show a checkmark while `disabled` on an empty
response. (The independent-scrolling half of this PR was already fixed separately in #13532.)
(#13317 — thanks @ventulus95)
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

// src/app/(dashboard)/dashboard/playground/components/CompareColumn.tsx

import type { StreamMetrics } from "@/shared/schemas/playground";
import { useTranslations } from "next-intl";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import type { StreamMetrics } from "@/shared/schemas/playground";
import MarkdownMessage from "./MarkdownMessage";
import ProviderMetrics from "./ProviderMetrics";

Expand All @@ -30,6 +31,7 @@ interface CompareColumnProps {
*/
export default function CompareColumn({ column, onCancel, onRemove }: CompareColumnProps) {
const t = useTranslations("playground");
const { copied, copy } = useCopyToClipboard();
const { id, model, status, metrics, response, errorMessage } = column;

return (
Expand Down Expand Up @@ -65,6 +67,17 @@ export default function CompareColumn({ column, onCancel, onRemove }: CompareCol
{t("cancel")}
</button>
)}
<button
onClick={() => void copy(response, id)}
disabled={response === ""}
className="p-0.5 rounded text-text-muted hover:text-text-main transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={copied === id ? t("copiedCode") : t("copy")}
aria-label={copied === id ? t("copiedCode") : t("copy")}
>
<span className="material-symbols-outlined text-[14px]">
{copied === id ? "check" : "content_copy"}
</span>
</button>
<button
onClick={() => onRemove(id)}
className="p-0.5 rounded text-text-muted hover:text-destructive transition-colors"
Expand Down
32 changes: 32 additions & 0 deletions tests/unit/ui/playground-compare-column.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,38 @@ describe("CompareColumn", () => {
expect(onRemove).toHaveBeenCalledWith("col-1");
});

it("copies this column response from the button beside remove", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});

const el = renderColumn(makeColumn({ status: "done", response: "Column-specific answer" }));
const copyBtn = el.querySelector("[aria-label='Copy']") as HTMLButtonElement;
const removeBtn = el.querySelector(
"[aria-label='Remove column for openai/gpt-4o']"
) as HTMLButtonElement;

expect(copyBtn).not.toBeNull();
expect(copyBtn.nextElementSibling).toBe(removeBtn);

await act(async () => {
copyBtn.click();
});

expect(writeText).toHaveBeenCalledWith("Column-specific answer");
expect(copyBtn.textContent).toContain("check");
});

it("disables response copy while the column has no response", () => {
const el = renderColumn(makeColumn({ response: "" }));
const copyBtn = el.querySelector("[aria-label='Copy']") as HTMLButtonElement;

expect(copyBtn).not.toBeNull();
expect(copyBtn.disabled).toBe(true);
});

it("updates metrics displayed when metrics change (TTFT after first chunk)", () => {
const metricsWithTtft = { ...BASE_METRICS, ttftMs: 187 };
const el = renderColumn(
Expand Down