feat(workbench): full-content reveal for truncated table cells (#749)

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 <noreply@anthropic.com>
This commit is contained in:
Shay 2026-06-14 16:53:01 -07:00 committed by GitHub
parent d3711ef413
commit 9b3c291d22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 309 additions and 17 deletions

View file

@ -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 }) {
<ul className="m-0 mt-2 grid list-none gap-1 p-0">
{ledger.refusalReasons.map((item) => (
<li key={item.id} className="grid grid-cols-[minmax(0,10rem)_1fr] gap-2 text-xs">
<span className="truncate font-mono text-[var(--color-text-primary)]">{item.id}</span>
<TruncatedCell
value={item.id}
label="case id"
mono
className="text-[var(--color-text-primary)]"
/>
<span className="text-[var(--color-text-secondary)]">{item.reason}</span>
</li>
))}
@ -214,7 +220,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) {
key={item.id}
className="grid grid-cols-[minmax(0,10rem)_5rem_1fr] gap-2 text-xs"
>
<span className="truncate font-mono text-[var(--color-text-primary)]">{item.id}</span>
<TruncatedCell
value={item.id}
label="case id"
mono
className="text-[var(--color-text-primary)]"
/>
<span
className={
item.kind === "wrong"
@ -226,7 +237,12 @@ export function EvalWrongZeroLedger({ result }: { result: EvalRunResult }) {
>
{item.kind}
</span>
<span className="truncate text-[var(--color-text-secondary)]">{item.reason}</span>
<TruncatedCell
value={item.reason}
label="reason"
wrap="pre-wrap"
className="text-[var(--color-text-secondary)]"
/>
</li>
))}
</ol>

View file

@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
import type {
LogosGlossRow,
@ -159,9 +160,12 @@ export function LexiconTab({
renderItem={(row, _index, focused) => (
<RowShell selected={focused || row.entry_id === selectedEntryId} onSelect={() => onSelect(row)}>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
{row.surface} · {row.lemma}
</span>
<TruncatedCell
value={`${row.surface} · ${row.lemma}`}
label="surface · lemma"
mono
className="text-sm text-[var(--color-text-primary)]"
/>
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">{row.entry_id}</span>
</div>
<div className="flex flex-wrap items-center gap-1.5">
@ -224,7 +228,12 @@ export function GlossesTab({
renderItem={(row, _index, focused) => (
<RowShell selected={focused || row.gloss_id === selectedGlossId} onSelect={() => onSelect(row)}>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">{row.lemma}</span>
<TruncatedCell
value={row.lemma}
label="lemma"
mono
className="text-sm text-[var(--color-text-primary)]"
/>
{row.pos ? <Tag>{row.pos}</Tag> : null}
</div>
<p className="m-0 text-xs text-[var(--color-text-secondary)] [text-wrap:balance]">{row.gloss}</p>
@ -301,9 +310,12 @@ export function MorphologyTab({
onSelect={() => onSelect(row)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-sm text-[var(--color-text-primary)]">
{row.surface} · {row.lemma}
</span>
<TruncatedCell
value={`${row.surface} · ${row.lemma}`}
label="surface · lemma"
mono
className="text-sm text-[var(--color-text-primary)]"
/>
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
{row.morphology_id}
</span>

View file

@ -1,4 +1,5 @@
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
import { TruncatedCell } from "../../design/components/TruncatedCell";
import type { ProposalDetail } from "../../types/api";
import { jsonSource } from "./proposalView";
import { SuggestedCLIBox } from "./SuggestedCLIBox";
@ -30,7 +31,11 @@ export function ProposalProvenanceViewer({ proposal }: { proposal: ProposalDetai
key={artifact.artifact_id}
>
<span className="font-mono text-[var(--color-text-primary)]">{artifact.artifact_id}</span>
<span className="truncate text-[var(--color-text-secondary)]">{artifact.path}</span>
<TruncatedCell
value={artifact.path}
label="artifact path"
className="text-[var(--color-text-secondary)]"
/>
</li>
))}
</ul>

View file

@ -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({
}
}}
>
<span className="font-mono text-xs" title={proposal.proposal_id}>
{shortProposalId(proposal.proposal_id)}
</span>
<TruncatedCell
value={proposal.proposal_id}
display={shortProposalId(proposal.proposal_id)}
label="proposal_id"
mono
className="text-xs"
/>
<ProposalStateBadge value={proposal.state} />
<ProposalReplayBadge value={proposal.replay_equivalent} />
<span className="truncate text-[var(--color-text-secondary)]">
{provenanceLabel(proposal)}
</span>
<TruncatedCell
value={provenanceLabel(proposal)}
label="source"
className="text-[var(--color-text-secondary)]"
/>
<span className="text-xs text-[var(--color-text-secondary)]">
{formatTimestamp(proposal.created_at)}
</span>

View file

@ -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(
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" display="6ea18d4e5e…" label="proposal_id" />,
);
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(
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" display="6ea18d4e5e…" label="proposal_id" />,
);
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(<TruncatedCell value="short" label="source" />);
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(<TruncatedCell value={LONG} label="source" />);
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(
<div role="button" tabIndex={0} onClick={onRowClick}>
<TruncatedCell value="6ea18d4e5e9f1c2a3b4c" label="proposal_id" />
</div>,
);
await userEvent.click(screen.getByRole("button", { name: /show full proposal_id/i }));
expect(onRowClick).not.toHaveBeenCalled();
});
});

View file

@ -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 (
<button
type="button"
onClick={() => copy(value)}
aria-label={copied ? "Copied" : `Copy ${label}`}
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
{copied ? <Check size={12} aria-hidden /> : <Copy size={12} aria-hidden />}
{copied ? "Copied" : "Copy"}
</button>
);
}
function FullValue({
value,
mono,
wrap,
}: {
value: string;
mono?: boolean;
wrap: "break-all" | "pre-wrap";
}) {
return (
<div
className={cn(
"max-h-64 overflow-auto rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-2 text-sm text-[var(--color-text-primary)] select-text",
mono && "font-mono",
wrap === "break-all" ? "break-all" : "whitespace-pre-wrap break-words",
)}
>
{value}
</div>
);
}
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 (
<span className={cn("group/cell flex min-w-0 items-center gap-1", mono && "font-mono")}>
<span className={cn("truncate", className)} title={value}>
{display ?? value}
</span>
<Popover.Root>
<Popover.Trigger asChild>
<button
type="button"
onClick={stop}
onKeyDown={stop}
aria-label={`Show full ${label}`}
className="inline-flex shrink-0 items-center rounded-sm p-0.5 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:text-[var(--color-text-primary)] focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)] group-hover/cell:opacity-100 data-[state=open]:opacity-100"
>
<Expand size={12} aria-hidden />
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
align="start"
side="bottom"
sideOffset={4}
collisionPadding={8}
onClick={stop}
onKeyDown={stop}
className="z-50 w-[min(420px,calc(100vw-32px))] rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-3 shadow-[var(--shadow-overlay)] focus-visible:outline-none"
>
<div className="mb-2 text-xs font-medium uppercase tracking-normal text-[var(--color-text-muted)]">
{label}
</div>
<FullValue value={value} mono={mono} wrap={wrap} />
<div className="mt-2 flex items-center justify-end gap-2">
{offerModal ? (
<button
type="button"
onClick={() => setModalOpen(true)}
className="inline-flex items-center gap-1 rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
<Maximize2 size={12} aria-hidden />
Open full view
</button>
) : null}
<CopyAction value={value} label={label} />
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<Dialog.Root open={modalOpen} onOpenChange={setModalOpen}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55" />
<Dialog.Content
onClick={stop}
aria-label={label}
className="fixed left-1/2 top-[12vh] z-50 flex max-h-[76vh] w-[min(720px,calc(100vw-32px))] -translate-x-1/2 flex-col rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-overlay)] p-4 shadow-[var(--shadow-overlay)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
<Dialog.Title className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">
{label}
</Dialog.Title>
<Dialog.Description className="sr-only">
Full contents of the {label} cell.
</Dialog.Description>
<div className="min-h-0 flex-1 overflow-auto">
<div
className={cn(
"rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3 text-sm text-[var(--color-text-primary)] select-text",
mono && "font-mono",
wrap === "break-all" ? "break-all" : "whitespace-pre-wrap break-words",
)}
>
{value}
</div>
</div>
<div className="mt-3 flex items-center justify-end gap-2">
<CopyAction value={value} label={label} />
<Dialog.Close asChild>
<button
type="button"
className="inline-flex items-center rounded-md border border-[var(--color-border-subtle)] bg-transparent px-2 py-1 text-xs text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
>
Close
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</span>
);
}

View file

@ -0,0 +1,2 @@
export { TruncatedCell } from "./TruncatedCell";
export type { TruncatedCellProps } from "./TruncatedCell";