diff --git a/workbench-ui/src/app/evals/EvalWrongZeroLedger.tsx b/workbench-ui/src/app/evals/EvalWrongZeroLedger.tsx index 5bf5664c..d22fcca2 100644 --- a/workbench-ui/src/app/evals/EvalWrongZeroLedger.tsx +++ b/workbench-ui/src/app/evals/EvalWrongZeroLedger.tsx @@ -1,3 +1,4 @@ +import { TruncatedCell } from "../../design/components/TruncatedCell"; import type { EvalRunResult } from "../../types/api"; interface CaseRecord { @@ -196,7 +197,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) { diff --git a/workbench-ui/src/app/proposals/ProposalTable.tsx b/workbench-ui/src/app/proposals/ProposalTable.tsx index c68fd658..a258b206 100644 --- a/workbench-ui/src/app/proposals/ProposalTable.tsx +++ b/workbench-ui/src/app/proposals/ProposalTable.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { EmptyState } from "../../design/components/states/EmptyState"; +import { TruncatedCell } from "../../design/components/TruncatedCell"; import type { ProposalSummary } from "../../types/api"; import { ProposalReplayBadge } from "./ProposalReplayBadge"; import { ProposalStateBadge } from "./ProposalStateBadge"; @@ -71,14 +72,20 @@ export function ProposalTable({ } }} > - - {shortProposalId(proposal.proposal_id)} - + - - {provenanceLabel(proposal)} - + {formatTimestamp(proposal.created_at)} diff --git a/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.test.tsx b/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.test.tsx new file mode 100644 index 00000000..47df1969 --- /dev/null +++ b/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { TruncatedCell } from "./TruncatedCell"; + +const LONG = "x".repeat(200); + +describe("TruncatedCell", () => { + it("shows the compact display and keeps the full value in title", () => { + render( + , + ); + const display = screen.getByText("6ea18d4e5e…"); + expect(display).toBeInTheDocument(); + expect(display).toHaveAttribute("title", "6ea18d4e5e9f1c2a3b4c"); + }); + + it("reveals the full value in a popover and copies it", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(undefined); + + render( + , + ); + + await userEvent.click(screen.getByRole("button", { name: /show full proposal_id/i })); + // full value now visible in the revealed popover + expect(screen.getByText("6ea18d4e5e9f1c2a3b4c")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /copy proposal_id/i })); + expect(writeText).toHaveBeenCalledWith("6ea18d4e5e9f1c2a3b4c"); + + writeText.mockRestore(); + }); + + it("does not offer a modal for short values", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /show full source/i })); + expect(screen.queryByRole("button", { name: /open full view/i })).not.toBeInTheDocument(); + }); + + it("opens a modal with the full value for long values", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: /show full source/i })); + await userEvent.click(screen.getByRole("button", { name: /open full view/i })); + expect(screen.getByRole("dialog", { name: "source" })).toBeInTheDocument(); + }); + + it("does not bubble clicks to a surrounding row handler", async () => { + const onRowClick = vi.fn(); + render( +
+ +
, + ); + await userEvent.click(screen.getByRole("button", { name: /show full proposal_id/i })); + expect(onRowClick).not.toHaveBeenCalled(); + }); +}); diff --git a/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.tsx b/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.tsx new file mode 100644 index 00000000..1e810ab3 --- /dev/null +++ b/workbench-ui/src/design/components/TruncatedCell/TruncatedCell.tsx @@ -0,0 +1,192 @@ +import { type ReactNode, useState } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; +import * as Popover from "@radix-ui/react-popover"; +import { Check, Copy, Expand, Maximize2 } from "lucide-react"; +import { cn } from "../../lib"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; + +/** + * Truncated table cell with a full-content escape hatch. + * + * Every grid "table" in the workbench truncates dense values (proposal ids, + * source kinds, digests, paths) to keep rows scannable. That hides the full + * value. `TruncatedCell` keeps the compact display but attaches one + * hover/focus-revealed trigger that opens a popover showing the *complete* + * value — selectable, scrollable, copyable — and, for long/structured values, + * an "Open full view" button into a roomy modal. + * + * One component, two reveals: a lightweight popover for the common case and a + * dynamic modal for the long case. Drop it in wherever a `truncate` span lives. + * + * The reveal trigger calls `stopPropagation`, so it never steals a row's + * click/select behaviour — opening the full value and selecting the row stay + * independent affordances. + */ + +const MODAL_THRESHOLD = 160; + +export interface TruncatedCellProps { + /** Full value — the source of truth shown in the reveal/modal and copied. */ + value: string; + /** + * Optional compact display node. Defaults to `value` rendered with the + * `truncate` ellipsis from the parent cell width. + */ + display?: ReactNode; + /** Column/field name — labels the trigger, popover, and modal for a11y. */ + label?: string; + /** Render display + full value in the monospace face. */ + mono?: boolean; + /** + * How to render the full value. `break-all` suits opaque ids/digests; + * `pre-wrap` preserves whitespace for prose/JSON. Defaults to `break-all`. + */ + wrap?: "break-all" | "pre-wrap"; + /** Class applied to the inline display span. */ + className?: string; +} + +function shouldOfferModal(value: string) { + return value.length > MODAL_THRESHOLD || value.includes("\n"); +} + +function CopyAction({ value, label }: { value: string; label: string }) { + const { copied, copy } = useCopyToClipboard(); + return ( + + ); +} + +function FullValue({ + value, + mono, + wrap, +}: { + value: string; + mono?: boolean; + wrap: "break-all" | "pre-wrap"; +}) { + return ( +
+ {value} +
+ ); +} + +export function TruncatedCell({ + value, + display, + label = "value", + mono, + wrap = "break-all", + className, +}: TruncatedCellProps) { + const [modalOpen, setModalOpen] = useState(false); + const offerModal = shouldOfferModal(value); + + const stop = (event: { stopPropagation: () => void }) => event.stopPropagation(); + + return ( + + + {display ?? value} + + + + + + + +
+ {label} +
+ +
+ {offerModal ? ( + + ) : null} + +
+
+
+
+ + + + + + + {label} + + + Full contents of the {label} cell. + +
+
+ {value} +
+
+
+ + + + +
+
+
+
+
+ ); +} diff --git a/workbench-ui/src/design/components/TruncatedCell/index.ts b/workbench-ui/src/design/components/TruncatedCell/index.ts new file mode 100644 index 00000000..bda798a4 --- /dev/null +++ b/workbench-ui/src/design/components/TruncatedCell/index.ts @@ -0,0 +1,2 @@ +export { TruncatedCell } from "./TruncatedCell"; +export type { TruncatedCellProps } from "./TruncatedCell";