-
- {row.surface} · {row.lemma}
-
+
{row.morphology_id}
diff --git a/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx b/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx
index ff49b399..4060e7b1 100644
--- a/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx
+++ b/workbench-ui/src/app/proposals/ProposalProvenanceViewer.tsx
@@ -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}
>
{artifact.artifact_id}
- {artifact.path}
+
))}
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 (
+