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:
parent
5474a15205
commit
8303c3c51e
7 changed files with 161 additions and 28 deletions
79
workbench-ui/src/app/StatusFooter.test.tsx
Normal file
79
workbench-ui/src/app/StatusFooter.test.tsx
Normal 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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
25
workbench-ui/src/design/hooks/useCopyToClipboard.ts
Normal file
25
workbench-ui/src/design/hooks/useCopyToClipboard.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue