diff --git a/js/packages/ui/src/components/lineage/LineageViewOss.tsx b/js/packages/ui/src/components/lineage/LineageViewOss.tsx
index ae4c1d59c..5e55b2e4f 100644
--- a/js/packages/ui/src/components/lineage/LineageViewOss.tsx
+++ b/js/packages/ui/src/components/lineage/LineageViewOss.tsx
@@ -8,11 +8,6 @@ import {
createLineageDiffCheck,
createSchemaDiffCheck,
getCll,
- isHistogramDiffRun,
- isProfileDiffRun,
- isTopKDiffRun,
- isValueDiffDetailRun,
- isValueDiffRun,
type LineageDiffViewOptions,
select,
} from "../../api";
@@ -125,7 +120,10 @@ import { LineageLegend } from "./legend";
import { toReactFlow } from "./lineage";
import { NodeViewOss as NodeView } from "./NodeViewOss";
import type { NodeChangeStatus } from "./nodes/LineageNode";
-import { shouldCloseOrphanedRunResult } from "./runResultVisibility";
+import {
+ isNodeBoundRunResult,
+ shouldCloseOrphanedRunResult,
+} from "./runResultVisibility";
import SetupConnectionBanner from "./SetupConnectionBannerOss";
import { BaseEnvironmentSetupNotification } from "./SingleEnvironmentQueryView";
import {
@@ -1195,16 +1193,9 @@ export function PrivateLineageView(
return;
}
- let selectedRunModel = undefined;
- if (
- isTopKDiffRun(run) ||
- isProfileDiffRun(run) ||
- isHistogramDiffRun(run) ||
- isValueDiffRun(run) ||
- isValueDiffDetailRun(run)
- ) {
- selectedRunModel = run.params?.model;
- }
+ const selectedRunModel = isNodeBoundRunResult(run)
+ ? run.params?.model
+ : undefined;
const intentKey = JSON.stringify([
runId ?? run.run_id,
diff --git a/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts b/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts
index c7e5dbba2..d23e910bd 100644
--- a/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts
+++ b/js/packages/ui/src/components/lineage/__tests__/runResultVisibility.test.ts
@@ -7,8 +7,10 @@ import {
} from "../runResultVisibility";
// Minimal stand-ins carrying only the fields the predicates / guard read.
-const profileRun = (model: string) =>
+const profileDiffRun = (model: string) =>
({ type: "profile_diff", params: { model } }) as unknown as Run;
+const profileRun = (model: string) =>
+ ({ type: "profile", params: { model } }) as unknown as Run;
const rowCountRun = () =>
({ type: "row_count_diff", params: {} }) as unknown as Run;
const node = (name: string) =>
@@ -20,6 +22,10 @@ const node = (name: string) =>
describe("isNodeBoundRunResult", () => {
it("is true for a node-bound run (profile_diff)", () => {
+ expect(isNodeBoundRunResult(profileDiffRun("customers"))).toBe(true);
+ });
+
+ it("is true for a single-environment profile run", () => {
expect(isNodeBoundRunResult(profileRun("customers"))).toBe(true);
});
@@ -35,13 +41,15 @@ describe("isNodeBoundRunResult", () => {
describe("shouldCloseOrphanedRunResult", () => {
it("closes when the run's model is absent from a built graph", () => {
expect(
- shouldCloseOrphanedRunResult(profileRun("customers"), [node("orders")]),
+ shouldCloseOrphanedRunResult(profileDiffRun("customers"), [
+ node("orders"),
+ ]),
).toBe(true);
});
it("keeps the result open when the run's model is present in the graph", () => {
expect(
- shouldCloseOrphanedRunResult(profileRun("customers"), [
+ shouldCloseOrphanedRunResult(profileDiffRun("customers"), [
node("customers"),
]),
).toBe(false);
@@ -58,7 +66,7 @@ describe("shouldCloseOrphanedRunResult", () => {
// shut and the page shows bare lineage ("Ready to run query"), flaky across
// reloads. Once nodes exist, an absent model closes the pane as before.
it("does NOT close while the lineage graph has not been built yet (no nodes)", () => {
- expect(shouldCloseOrphanedRunResult(profileRun("customers"), [])).toBe(
+ expect(shouldCloseOrphanedRunResult(profileDiffRun("customers"), [])).toBe(
false,
);
});
diff --git a/js/packages/ui/src/components/lineage/runResultVisibility.ts b/js/packages/ui/src/components/lineage/runResultVisibility.ts
index 86428a905..22b7e6788 100644
--- a/js/packages/ui/src/components/lineage/runResultVisibility.ts
+++ b/js/packages/ui/src/components/lineage/runResultVisibility.ts
@@ -1,11 +1,4 @@
-import {
- isHistogramDiffRun,
- isProfileDiffRun,
- isTopKDiffRun,
- isValueDiffDetailRun,
- isValueDiffRun,
- type Run,
-} from "../../api";
+import type { Run } from "../../api";
import {
isLineageGraphNode,
type LineageGraphNodes,
@@ -15,6 +8,7 @@ import {
type NodeBoundRun = Run & {
type:
| "top_k_diff"
+ | "profile"
| "profile_diff"
| "histogram_diff"
| "value_diff"
@@ -22,11 +16,11 @@ type NodeBoundRun = Run & {
};
/**
- * A run result whose content is bound to a specific model node. Profile /
- * value / top-k / histogram / value-detail diffs render inside that model's
- * NodeView, so they only make sense while the model is part of the rendered
- * lineage. (row_count / query / query_diff results are NOT node-bound — they
- * render in their own pane regardless of the visible nodes.)
+ * A run result whose content is bound to a specific model node. Profile,
+ * value, top-k, histogram, and value-detail results carry a single model, so
+ * they only make sense while that model is part of the rendered lineage.
+ * (row_count / query / query_diff results are NOT node-bound — they render in
+ * their own pane regardless of the visible nodes.)
*
* Typed as a type guard so callers narrow `run` to `NodeBoundRun` and can read
* `run.params?.model` without casting away the discriminated-union typing.
@@ -34,14 +28,17 @@ type NodeBoundRun = Run & {
export function isNodeBoundRunResult(
run: Run | undefined,
): run is NodeBoundRun {
- return (
- !!run &&
- (isTopKDiffRun(run) ||
- isProfileDiffRun(run) ||
- isHistogramDiffRun(run) ||
- isValueDiffRun(run) ||
- isValueDiffDetailRun(run))
- );
+ switch (run?.type) {
+ case "top_k_diff":
+ case "profile":
+ case "profile_diff":
+ case "histogram_diff":
+ case "value_diff":
+ case "value_diff_detail":
+ return true;
+ default:
+ return false;
+ }
}
/**
diff --git a/js/src/components/lineage/__tests__/LineageView.component.test.tsx b/js/src/components/lineage/__tests__/LineageView.component.test.tsx
index 361def81b..071e51d5d 100644
--- a/js/src/components/lineage/__tests__/LineageView.component.test.tsx
+++ b/js/src/components/lineage/__tests__/LineageView.component.test.tsx
@@ -38,7 +38,11 @@ if (typeof Object.groupBy === "undefined") {
}
import type { LineageGraph, LineageGraphNode } from "@datarecce/ui";
-import type { ColumnLineageData, ServerInfoResult } from "@datarecce/ui/api";
+import type {
+ ColumnLineageData,
+ Run,
+ ServerInfoResult,
+} from "@datarecce/ui/api";
import {
act,
fireEvent,
@@ -193,19 +197,9 @@ vi.mock("@xyflow/react", () => ({
// Mock @datarecce/ui contexts
const mockRefetchLineageGraph = vi.fn();
const mockRefetchRunsAggregated = vi.fn();
-let mockActiveRun:
- | {
- type: "schema_diff" | "profile_diff";
- run_id: string;
- run_at: string;
- params?: {
- model: string;
- };
- }
- | undefined;
+let mockActiveRun: Run | undefined;
let mockActiveRunId: string | undefined;
let mockIsRunResultOpen = false;
-let mockIsProfileDiffRun = false;
const mockLineageGraphContext = {
lineageGraph: undefined as LineageGraph | undefined,
@@ -326,11 +320,6 @@ vi.mock("@datarecce/ui/api", () => ({
select: vi.fn().mockResolvedValue({ nodes: [] }),
createLineageDiffCheck: vi.fn().mockResolvedValue({ check_id: "test-check" }),
createSchemaDiffCheck: vi.fn().mockResolvedValue({ check_id: "test-check" }),
- isHistogramDiffRun: vi.fn(() => false),
- isProfileDiffRun: vi.fn(() => mockIsProfileDiffRun),
- isTopKDiffRun: vi.fn(() => false),
- isValueDiffDetailRun: vi.fn(() => false),
- isValueDiffRun: vi.fn(() => false),
}));
// Mock @datarecce/ui/components/lineage
@@ -883,16 +872,41 @@ function setupWithLineageGraph(lineageGraph?: LineageGraph) {
});
}
-function setupOpenProfileRun(model: string) {
- mockActiveRunId = `profile-${model}`;
+type ModelScopedRunType =
+ | "profile"
+ | "profile_diff"
+ | "top_k_diff"
+ | "histogram_diff"
+ | "value_diff"
+ | "value_diff_detail";
+
+function setupOpenModelRun(type: ModelScopedRunType, model: string) {
+ mockActiveRunId = `${type}-${model}`;
mockIsRunResultOpen = true;
- mockIsProfileDiffRun = true;
mockActiveRun = {
- type: "profile_diff",
+ type,
run_id: mockActiveRunId,
run_at: "2026-07-28T00:00:00Z",
params: { model },
- };
+ } as Run;
+}
+
+function setupOpenProfileRun(model: string) {
+ setupOpenModelRun("profile_diff", model);
+}
+
+function setupOpenRowCountRun(
+ type: "row_count" | "row_count_diff",
+ model: string,
+) {
+ mockActiveRunId = `${type}-${model}`;
+ mockIsRunResultOpen = true;
+ mockActiveRun = {
+ type,
+ run_id: mockActiveRunId,
+ run_at: "2026-07-28T00:00:00Z",
+ params: { node_names: [model] },
+ } as Run;
}
// ============================================================================
@@ -937,7 +951,6 @@ describe("LineageView Component", () => {
mockActiveRun = undefined;
mockActiveRunId = undefined;
mockIsRunResultOpen = false;
- mockIsProfileDiffRun = false;
// Reset node state mock
mockUseNodesStateReturnValue = [[], vi.fn(), vi.fn()];
@@ -1783,6 +1796,94 @@ describe("LineageView Component", () => {
});
});
+ describe("run focus synchronization", () => {
+ it.each([
+ "profile",
+ "profile_diff",
+ "top_k_diff",
+ "histogram_diff",
+ "value_diff",
+ "value_diff_detail",
+ ] as const)(
+ "detaches an open %s result when the user focuses another node",
+ async (runType) => {
+ const lineageGraph = createMockLineageGraph();
+ setupWithLineageGraph(lineageGraph);
+ setupOpenModelRun(runType, "node1");
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("node-view")).toHaveAttribute(
+ "data-node-id",
+ "model.test.node1",
+ ),
+ );
+
+ fireEvent.click(screen.getByTestId("click-model.test.node2"));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(screen.getByTestId("node-view")).toHaveAttribute(
+ "data-node-id",
+ "model.test.node2",
+ );
+ expect(mockCloseRunResult).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(["row_count", "row_count_diff"] as const)(
+ "keeps node navigation independent of an open %s result",
+ async (runType) => {
+ const lineageGraph = createMockLineageGraph();
+ setupWithLineageGraph(lineageGraph);
+ const view = () => (
+
+
+
+ );
+ const { rerender } = render(view());
+
+ await waitFor(() =>
+ expect(
+ screen.getByTestId("click-model.test.node1"),
+ ).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByTestId("click-model.test.node1"));
+ expect(screen.getByTestId("node-view")).toHaveAttribute(
+ "data-node-id",
+ "model.test.node1",
+ );
+
+ setupOpenRowCountRun(runType, "node1");
+ rerender(view());
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ expect(screen.getByTestId("node-view")).toHaveAttribute(
+ "data-node-id",
+ "model.test.node1",
+ );
+
+ fireEvent.click(screen.getByTestId("click-model.test.node2"));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(screen.getByTestId("node-view")).toHaveAttribute(
+ "data-node-id",
+ "model.test.node2",
+ );
+ expect(mockCloseRunResult).not.toHaveBeenCalled();
+ },
+ );
+ });
+
describe("async layout ownership", () => {
it("invalidates a deferred CLL request on unmount before it patches or lays out", async () => {
const lineageGraph = createMockLineageGraph();