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:
parent
d3711ef413
commit
9b3c291d22
7 changed files with 309 additions and 17 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { TruncatedCell } from "./TruncatedCell";
|
||||
export type { TruncatedCellProps } from "./TruncatedCell";
|
||||
Loading…
Reference in a new issue