fix(workbench): DAG natural-height fit for dense graphs + consistent click-to-copy feedback

Two UX wrinkles surfaced while exercising the CORE-Logos Studio:

- DAG viewport: tall, few-layer graphs (e.g. the 408x1890, 2-layer/55-node
  alignment fan) were squished by viewBox-meet into an unreadable sliver. Render
  at natural height (scale ~1) inside a bounded, scrollable box; small graphs are
  unaffected (>= the caller height). Golden layout unchanged (layoutDag untouched).
- Click-to-copy: new shared useCopyToClipboard hook gives every copy affordance a
  tooltip + transient confirmation. StatusFooter SHA now confirms 'Copied' (was a
  silent copy that read as 'does nothing'); 'Read Only' is labeled a non-interactive
  status (not a toggle); checkpoint-revision gains an explanatory tooltip.
  DigestBadge + MetadataTable copy buttons route through the hook + gain titles.

Full workbench-ui vitest: 520 passed (+4), clean exit; tsc + build clean.
This commit is contained in:
Shay 2026-06-14 13:31:43 -07:00
parent 5474a15205
commit 8303c3c51e
7 changed files with 161 additions and 28 deletions

View file

@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestQueryClient } from "../test/createTestQueryClient";
import type { RuntimeStatus } from "../types/api";
vi.mock("../api/queries", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api/queries")>();
return { ...actual, useRuntimeStatus: vi.fn() };
});
import { useRuntimeStatus } from "../api/queries";
import { StatusFooter } from "./StatusFooter";
const status: RuntimeStatus = {
backend: "numpy",
git_revision: "5474a152057d9999",
engine_state_present: false,
checkpoint_revision: "unknown",
revision_warning: false,
active_session_id: null,
mutation_mode: "read_only",
};
function renderFooter() {
return render(
<QueryClientProvider client={createTestQueryClient()}>
<StatusFooter />
</QueryClientProvider>,
);
}
beforeEach(() => {
vi.mocked(useRuntimeStatus).mockReturnValue({
data: status,
isError: false,
} as unknown as ReturnType<typeof useRuntimeStatus>);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("StatusFooter", () => {
it("labels Read Only as a non-interactive status, not a toggle", () => {
renderFooter();
const chip = screen.getByText("Read Only");
expect(chip).toHaveAttribute(
"title",
"Runtime mutation mode — read-only by design (status, not a toggle)",
);
expect(chip.tagName).toBe("SPAN"); // status display, never a button
});
it("copies the full SHA and shows a transient confirmation on click", async () => {
// userEvent.setup() installs its own clipboard stub; read it back to verify
// the full SHA was copied (not just the short display form).
const user = userEvent.setup();
renderFooter();
const sha = screen.getByTestId("git-revision");
expect(sha).toHaveAttribute("title", "Copy full git revision SHA");
expect(sha).toHaveTextContent("5474a152"); // short SHA before copy
await user.click(sha);
// The confirmation the silent-copy version lacked.
expect(await screen.findByText("Copied")).toBeInTheDocument();
expect(await navigator.clipboard.readText()).toBe("5474a152057d9999");
});
it("gives the checkpoint-revision toggle an explanatory tooltip", () => {
renderFooter();
const checkpoint = screen.getByTestId("checkpoint-revision");
expect(checkpoint.getAttribute("title")).toMatch(/checkpoint revision/i);
expect(checkpoint.getAttribute("title")).toMatch(/unknown/);
});
});

View file

@ -1,11 +1,13 @@
import { useState } from "react";
import { useIsMutating } from "@tanstack/react-query";
import { useRuntimeStatus } from "../api/queries";
import { useCopyToClipboard } from "../design/hooks/useCopyToClipboard";
export function StatusFooter() {
const { data, isError } = useRuntimeStatus();
const chatTurnsPending = useIsMutating({ mutationKey: ["chat-turn"] }) > 0;
const [revisionExpanded, setRevisionExpanded] = useState(false);
const { copied: shaCopied, copy: copySha } = useCopyToClipboard();
if (isError) {
return (
@ -29,7 +31,8 @@ export function StatusFooter() {
visibleMutationMode === "read_only" ? (
<span
data-testid="mutation-mode"
className="rounded border border-[var(--color-border-subtle)] px-2 py-0.5 text-[var(--color-text-secondary)]"
className="cursor-default rounded border border-[var(--color-border-subtle)] px-2 py-0.5 text-[var(--color-text-secondary)]"
title="Runtime mutation mode — read-only by design (status, not a toggle)"
aria-label="Mutation mode: Read Only"
>
Read Only
@ -54,12 +57,12 @@ export function StatusFooter() {
<button
type="button"
className="font-mono text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
onClick={() => navigator.clipboard.writeText(git_revision).catch(() => {})}
title="Click to copy full SHA"
aria-label={`git revision: ${git_revision}`}
onClick={() => copySha(git_revision)}
title={shaCopied ? "Copied" : "Copy full git revision SHA"}
aria-label={`git revision: ${git_revision}. Click to copy.`}
data-testid="git-revision"
>
{shortSha(git_revision)}
{shaCopied ? "Copied" : shortSha(git_revision)}
</button>
<div className="flex flex-col">
@ -73,6 +76,7 @@ export function StatusFooter() {
].join(" ")}
onClick={() => setRevisionExpanded((v) => !v)}
aria-expanded={revisionExpanded}
title={`Engine checkpoint revision (${checkpoint_revision}) — click to ${revisionExpanded ? "hide" : "show"} details`}
aria-label={`checkpoint revision: ${checkpoint_revision}${revision_warning ? " (warning)" : ""}`}
data-testid="checkpoint-revision"
data-warning={revision_warning ? "true" : undefined}

View file

@ -69,4 +69,25 @@ describe("Dag", () => {
expect(screen.getByTestId("dag-viewer")).toBeInTheDocument();
});
it("renders a tall, few-layer graph at natural height inside a bounded scroll box", () => {
// 12 sources -> 12 sinks: 2 layers, 24 rows — a tall sliver that the old
// fixed-320 viewport squished. The SVG should grow to the layout's natural
// height (legible, scale ~1), and the wrapper bounds + scrolls it.
const wideNodes = Array.from({ length: 24 }, (_, i) => ({ id: `n${i}` }));
const fanEdges = Array.from({ length: 12 }, (_, i) => ({
from: `n${i}`,
to: `n${i + 12}`,
}));
const layout = layoutDag(wideNodes, fanEdges);
expect(layout.height).toBeGreaterThan(320); // precondition: genuinely tall
render(<DagViewer nodes={wideNodes} edges={fanEdges} ariaLabel="Tall fan DAG" />);
const svg = screen.getByRole("img", { name: "Tall fan DAG" });
expect(svg).toHaveAttribute("height", String(layout.height));
const viewport = screen.getByTestId("dag-viewport");
expect(viewport.style.maxHeight).toBe("560px");
expect(viewport.className).toContain("overflow-auto");
});
});

View file

@ -12,6 +12,13 @@ export interface DagViewerProps {
showInspector?: boolean;
}
// Tall, few-layer graphs (e.g. a 2-layer alignment fan of 50+ nodes) must not
// be squished into a fixed short viewport by viewBox-meet — that produces an
// unreadable sliver. Render at natural height (scale ~1) inside a bounded,
// scrollable box instead. Small graphs (layout.height < the caller height) are
// unaffected.
const MAX_VIEWPORT_HEIGHT = 560;
function clippedLabel(label: string) {
return label.length > 22 ? `${label.slice(0, 19)}...` : label;
}
@ -31,6 +38,10 @@ export function DagViewer({
showInspector = true,
}: DagViewerProps) {
const layout = useMemo(() => layoutDag(nodes, edges), [nodes, edges]);
// Grow the SVG to the graph's natural height so dense graphs render legibly;
// the wrapper bounds + scrolls it. Never shrinks below the caller's height.
const svgHeight = Math.max(height, layout.height);
const viewportMaxHeight = Math.max(height, MAX_VIEWPORT_HEIGHT);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
@ -98,11 +109,16 @@ export function DagViewer({
</div>
</div>
<div
className="overflow-auto rounded-md"
style={{ maxHeight: viewportMaxHeight }}
data-testid="dag-viewport"
>
<svg
role="img"
aria-label={ariaLabel}
className="w-full cursor-grab rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
height={height}
height={svgHeight}
viewBox={`0 0 ${layout.width} ${layout.height}`}
tabIndex={0}
onPointerDown={(event) => {
@ -186,6 +202,7 @@ export function DagViewer({
})}
</g>
</svg>
</div>
{showInspector ? (
<div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3">

View file

@ -1,6 +1,4 @@
import { useState } from "react";
import { copyText } from "../../lib";
import { useManagedTimeout } from "../../hooks/useManagedTimeout";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
export interface DigestBadgeProps {
digest: string;
@ -40,8 +38,7 @@ export function DigestBadge({
// Wave R hash display standard: 12 visible chars + copy, everywhere.
truncate = 12,
}: DigestBadgeProps) {
const [copied, setCopied] = useState(false);
const scheduleReset = useManagedTimeout();
const { copied, copy } = useCopyToClipboard();
const display = digest.length > truncate
? `${digest.slice(0, truncate)}...`
@ -52,6 +49,7 @@ export function DigestBadge({
return (
<button
type="button"
title={copied ? "Copied" : `Copy digest ${algorithm}:${digest}`}
aria-label={`Digest ${algorithm}:${digest}. Click to copy.`}
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 font-mono text-xs transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
@ -64,12 +62,7 @@ export function DigestBadge({
transitionDuration: "var(--motion-duration-fast)",
transitionTimingFunction: "var(--motion-ease-standard)",
}}
onClick={() => {
void copyText(`${algorithm}:${digest}`).then(() => {
setCopied(true);
scheduleReset(() => setCopied(false), 1500);
});
}}
onClick={() => copy(`${algorithm}:${digest}`)}
data-testid="digest-badge"
>
{verified !== undefined && <VerifiedDot verified={verified} />}

View file

@ -1,7 +1,6 @@
import { type ReactNode, useState } from "react";
import { type ReactNode } from "react";
import { Copy, Check } from "lucide-react";
import { copyText } from "../../lib";
import { useManagedTimeout } from "../../hooks/useManagedTimeout";
import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
export interface MetadataRow {
key: string;
@ -15,12 +14,12 @@ export interface MetadataTableProps {
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const scheduleReset = useManagedTimeout();
const { copied, copy } = useCopyToClipboard();
return (
<button
type="button"
title={copied ? "Copied" : `Copy ${text}`}
aria-label={`Copy ${text}`}
className="ml-1 inline-flex items-center opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
style={{
@ -32,12 +31,7 @@ function CopyButton({ text }: { text: string }) {
color: "var(--color-text-muted)",
padding: 0,
}}
onClick={() => {
void copyText(text).then(() => {
setCopied(true);
scheduleReset(() => setCopied(false), 1500);
});
}}
onClick={() => copy(text)}
>
{copied ? (
<Check size={12} aria-hidden />

View file

@ -0,0 +1,25 @@
import { useCallback, useState } from "react";
import { copyText } from "../lib";
import { useManagedTimeout } from "./useManagedTimeout";
/**
* Shared click-to-copy feedback. Returns `copied` (true for `resetMs` after a
* successful copy) and a `copy(text)` action. Used by every copy affordance so
* tooltip + transient confirmation behave identically across the workbench.
*/
export function useCopyToClipboard(resetMs = 1500) {
const [copied, setCopied] = useState(false);
const scheduleReset = useManagedTimeout();
const copy = useCallback(
(text: string) => {
void copyText(text).then(() => {
setCopied(true);
scheduleReset(() => setCopied(false), resetMs);
});
},
[scheduleReset, resetMs],
);
return { copied, copy };
}