From 9b3c291d225a4e5f382876a690ee00f2537e96d7 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 14 Jun 2026 16:53:01 -0700 Subject: [PATCH] feat(workbench): full-content reveal for truncated table cells (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workbench grid tables truncate dense cells (proposal ids, source kinds, case ids/reasons, lexicon/morphology rows, artifact paths) with `...`, leaving the full value unreachable except via a native title tooltip. Add a reusable `TruncatedCell` (design/components): keeps the compact display, attaches one hover/focus-revealed trigger that opens an accessible Radix popover with the complete value (selectable, scrollable) plus one-click copy, and — for long/structured values — an "Open full view" button into a dynamic Radix Dialog modal. One component delivers both the lightweight reveal and the interactive modal. The reveal trigger calls stopPropagation, so it never steals a row's click/select behaviour — revealing a value and selecting the row stay independent. Digests keep `DigestBadge` (already copy + full-value title). Wired into the non-virtualized + contents grid tables: Proposal queue (id, source), eval wrong=0 case ledger (case id, reason), CORE-Logos lexicon/glosses/morphology rows, and proposal artifact paths. Trace propagation edges (short, right-aligned stage names) and single-column selection rails / detail cards keep current behaviour. Evidence: workbench-ui `pnpm build` (tsc -b) green; full vitest suite 525 passed / 59 files, including 5 new TruncatedCell tests (display + title, popover reveal + copy, modal-only-for-long, no row-click bubble). Co-authored-by: Claude Opus 4.8 --- .../src/app/evals/EvalWrongZeroLedger.tsx | 22 +- .../src/app/logos/LogosContentsTabs.tsx | 26 ++- .../proposals/ProposalProvenanceViewer.tsx | 7 +- .../src/app/proposals/ProposalTable.tsx | 19 +- .../TruncatedCell/TruncatedCell.test.tsx | 58 ++++++ .../TruncatedCell/TruncatedCell.tsx | 192 ++++++++++++++++++ .../design/components/TruncatedCell/index.ts | 2 + 7 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 workbench-ui/src/design/components/TruncatedCell/TruncatedCell.test.tsx create mode 100644 workbench-ui/src/design/components/TruncatedCell/TruncatedCell.tsx create mode 100644 workbench-ui/src/design/components/TruncatedCell/index.ts 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";