This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-4-test by this push:
     new fa6b47b160d [v3-4-test] Clean up Dag Version Tab UX (#75244) (#75318)
fa6b47b160d is described below

commit fa6b47b160d3ebc34bea37603b78c8efd8c992a8
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Thu Oct 22 18:26:59 2026 -0400

    [v3-4-test] Clean up Dag Version Tab UX (#75244) (#75318)
    
    * Wrap long values in Dag version diff table so After stays visible
    
    The Versions tab compares two Dag versions in a table whose path,
    before and after cells show serialized values of up to 120 characters.
    These are mostly JSON or long paths with no spaces, so the browser
    could not wrap them. The table grew far wider than its container
    (2146px in a 1100px window), and since DataTable has no horizontal
    scroll, the rightmost After column was clipped out of view. Narrower
    windows made it worse.
    
    Let those cells wrap with overflow-wrap: anywhere and
    white-space: pre-wrap. overflow-wrap: break-word is not enough: it
    does not lower the cell's min-content width, so the table would still
    overflow. The trade-off is that short paths can break mid-word in
    narrow windows.
    
    * UI: Read a Dag version diff's values as JSON, not as a cut string
    
    A change to a Dag version carries the serialized value on each side, and
    a whole task added or removed carries its entire payload. Flattened into
    a cell and cut at 120 characters, those values said little more than
    that something had changed; as a JSON preview they name the keys that
    differ and open in place when the rest matters.
    
    The cells were also set as code, whose surface sits on the table's own
    striped rows at nearly the same shade, and a path was free to break
    after any character, so a column squeezed by its neighbours read
    vertically, one glyph per line. A side the change does not have now
    leaves its cell empty, since a dash reads as a value the version holds.
    
    Carrying a preview on each side of a comparison asks two things of the
    rendered JSON field that its other callers never did. It sized itself
    for one payload per row, where one long entry — a dotted class path, a
    URL — could widen its column past the pane whatever bound it was given,
    since a badge sets its value on one line. And its copy button, shown
    outright, competes with the values for attention on every row it
    appears in; waiting for the pointer, or for focus so a keyboard can
    still reach it, keeps a list to its content. Both now hold wherever a
    JSON field is rendered.
    
    What the comparison says about itself was spread over three stacked
    rows above the table, and the warning that a bound had cut it short sat
    nowhere near the field that sets that bound — the one control that can
    lift it. The summary now heads the table's own header row, beside the
    columns menu, and the bound carries its own notice.
    
    The tab's components were sharing two files, which left the table, its
    cells and the page's query in the same place as whichever one was being
    read.
    
    
    
    ---------
    (cherry picked from commit 5c021ce54423c022541fdcf72da2537adbf1a1bd)
    
    Co-authored-by: Brent Bovenzi <[email protected]>
    Co-authored-by: jedcunningham 
<[email protected]>
    Co-authored-by: Claude <[email protected]>
---
 .../ui/src/components/JsonPreviewBadges.tsx        |  42 ++++++--
 .../ui/src/components/RenderedJsonField.test.tsx   |  19 ++++
 .../ui/src/components/RenderedJsonField.tsx        |  36 +++++--
 .../pages/Dag/Versions/DiffUnavailableAlert.tsx    |  44 ++++++++
 .../ui/src/pages/Dag/Versions/DiffValue.tsx        |  60 +++++++++++
 .../pages/Dag/Versions/MaxChangesField.test.tsx    |  71 ++++++++++++
 .../ui/src/pages/Dag/Versions/MaxChangesField.tsx  | 119 +++++++++++++++++++++
 .../airflow/ui/src/pages/Dag/Versions/MonoText.tsx |  27 +++++
 .../airflow/ui/src/pages/Dag/Versions/PathText.tsx |  49 +++++++++
 .../pages/Dag/Versions/SelectedVersionsDiff.tsx    |  81 ++++++++++++++
 .../ui/src/pages/Dag/Versions/VersionDiff.test.tsx |  71 +++++++++---
 .../ui/src/pages/Dag/Versions/VersionDiff.tsx      | 116 ++++++--------------
 .../airflow/ui/src/pages/Dag/Versions/Versions.tsx |  98 ++---------------
 13 files changed, 629 insertions(+), 204 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx 
b/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx
index eab98a331a9..d0ec743570c 100644
--- a/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx
+++ b/airflow-core/src/airflow/ui/src/components/JsonPreviewBadges.tsx
@@ -24,6 +24,9 @@ import { Tooltip } from "src/system-components";
 import type { JsonPreviewEntry } from "src/utils/jsonPreview";
 
 const MAX_VALUE_CHARS = 40;
+// Below this a shortened value says nothing, so a narrow cap spends its width 
on fewer badges
+// rather than on unreadable ones.
+const MIN_VALUE_CHARS = 12;
 // Widest advance of one glyph in the 12px badge font, measured across real 
payloads. Estimating
 // high costs a little column width; estimating low wraps the preview onto a 
second line.
 const CHAR_WIDTH = 7.3;
@@ -36,10 +39,12 @@ const MAX_WIDTH = 380;
 
 type Props = {
   readonly entries: Array<JsonPreviewEntry>;
+  /** Lower cap for a table that carries more than one of these side by side. 
*/
+  readonly maxWidth?: number;
   readonly onExpand: () => void;
 };
 
-export const JsonPreviewBadges = ({ entries, onExpand }: Props) => {
+export const JsonPreviewBadges = ({ entries, maxWidth = MAX_WIDTH, onExpand }: 
Props) => {
   const { t: translate } = useTranslation(["components", "common"]);
 
   const previews = entries.map((entry) => {
@@ -47,7 +52,12 @@ export const JsonPreviewBadges = ({ entries, onExpand }: 
Props) => {
       entry.itemCount === undefined
         ? entry.value.replaceAll(/\s+/gu, " ")
         : translate("jsonPreview.items", { count: entry.itemCount });
-    const text = full.length > MAX_VALUE_CHARS ? `${full.slice(0, 
MAX_VALUE_CHARS)}…` : full;
+    // A badge sets its value on one line, so one long entry — a dotted class 
path, a URL — would
+    // carry the whole preview past the cap on its own, whatever the loop 
below decides to show.
+    const labelChars = entry.label === undefined ? 0 : entry.label.length + 1;
+    const roomChars = Math.floor((maxWidth - BADGE_CHROME) / CHAR_WIDTH) - 
labelChars;
+    const valueChars = Math.max(MIN_VALUE_CHARS, Math.min(MAX_VALUE_CHARS, 
roomChars));
+    const text = full.length > valueChars ? `${full.slice(0, valueChars)}…` : 
full;
 
     // A summarised or shortened value only hints at what it stands for, so 
the editor still has
     // something to add even when every entry is on screen.
@@ -67,7 +77,7 @@ export const JsonPreviewBadges = ({ entries, onExpand }: 
Props) => {
 
     if (
       visibleEntries.length > 0 &&
-      usedWidth + badgeWidth + (needsButton ? MORE_BUTTON_WIDTH : 0) > 
MAX_WIDTH
+      usedWidth + badgeWidth + (needsButton ? MORE_BUTTON_WIDTH : 0) > maxWidth
     ) {
       break;
     }
@@ -79,19 +89,39 @@ export const JsonPreviewBadges = ({ entries, onExpand }: 
Props) => {
   const hiddenCount = previews.length - visibleEntries.length;
   // Nothing left to reveal means nothing to expand.
   const canExpand = hiddenCount > 0 || visibleEntries.some((preview) => 
preview.isElided);
-  const previewWidth = Math.min(usedWidth + (canExpand ? MORE_BUTTON_WIDTH : 
0), MAX_WIDTH);
+  const previewWidth = Math.min(usedWidth + (canExpand ? MORE_BUTTON_WIDTH : 
0), maxWidth);
+  // The expand button shares the line, so a badge that filled the cap on its 
own would still carry
+  // the preview — and the column around it — past that cap.
+  const badgeMaxWidth = maxWidth - (canExpand ? MORE_BUTTON_WIDTH : 0);
 
   return (
     <HStack data-testid="json-preview-badges" gap={1} 
minW={`${previewWidth}px`}>
       {visibleEntries.map(({ full, id, isComplex, label, text }) => (
         <Tooltip content={full} disabled={text === full} key={id} portalled>
-          <Badge colorPalette="gray" gap={1} size="sm" variant="surface" 
whiteSpace="nowrap">
+          {/* The character budget above only estimates how wide a badge will 
be, and a font that
+              runs wider than the estimate would carry the badge — and the 
column holding it — past
+              the cap. maxW is what actually holds the line. */}
+          <Badge
+            colorPalette="gray"
+            gap={1}
+            maxW={`${badgeMaxWidth}px`}
+            overflow="hidden"
+            size="sm"
+            variant="surface"
+            whiteSpace="nowrap"
+          >
             {label === undefined ? undefined : (
               <Text as="span" color="fg.muted">
                 {label}:
               </Text>
             )}
-            <Text as="span" color={isComplex ? "fg.muted" : "fg"} 
fontFamily="mono">
+            <Text
+              as="span"
+              color={isComplex ? "fg.muted" : "fg"}
+              fontFamily="mono"
+              overflow="hidden"
+              textOverflow="ellipsis"
+            >
               {text}
             </Text>
           </Badge>
diff --git 
a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx 
b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx
index 4b1557f6f61..228719ab254 100644
--- a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx
+++ b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.test.tsx
@@ -101,6 +101,25 @@ describe("RenderedJsonField", () => {
     expect(container).toBeEmptyDOMElement();
   });
 
+  it.each([
+    ["a collapsed preview", true],
+    ["the open editor", false],
+  ])("keeps the copy button out of the way in %s until hover or focus", 
(_label, collapsed) => {
+    // One copy button per row reads as chrome. It stays mounted rather than 
conditionally
+    // rendered, so it keeps its place in the tab order for a keyboard.
+    render(<RenderedJsonField collapsed={collapsed} content={{ score: 0.99 }} 
/>, { wrapper: Wrapper });
+
+    expect(screen.getByTestId("json-clipboard-reveal")).toBeInTheDocument();
+  });
+
+  it("renders no copy button at all when the caller turns the clipboard off", 
() => {
+    render(<RenderedJsonField collapsed content={{ score: 0.99 }} 
enableClipboard={false} />, {
+      wrapper: Wrapper,
+    });
+
+    
expect(screen.queryByTestId("json-clipboard-reveal")).not.toBeInTheDocument();
+  });
+
   it("renders the editor when the caller never collapses it", () => {
     render(<RenderedJsonField content={{ score: 0.99 }} />, { wrapper: Wrapper 
});
 
diff --git a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx 
b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
index 87f07110dd9..e9d66013300 100644
--- a/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
+++ b/airflow-core/src/airflow/ui/src/components/RenderedJsonField.tsx
@@ -48,11 +48,20 @@ type Props = {
   readonly enableClipboard?: boolean;
   /** Replaces expanding in place — for a preview whose full value lives on 
another page. */
   readonly onExpand?: () => void;
+  /** Narrows the collapsed preview, for a table that carries more than one of 
these per row. */
+  readonly previewMaxWidth?: number;
 } & Omit<FlexProps, "content">;
 
 type EditorInstance = Parameters<OnMount>[0];
 
-const RenderedJsonField = ({ collapsed, content, enableClipboard = true, 
onExpand, ...rest }: Props) => {
+const RenderedJsonField = ({
+  collapsed,
+  content,
+  enableClipboard = true,
+  onExpand,
+  previewMaxWidth,
+  ...rest
+}: Props) => {
   const contentFormatted = JSON.stringify(content, undefined, 2);
   const { t: translate } = useTranslation("common");
   const { beforeMount, theme } = useMonacoTheme();
@@ -113,23 +122,36 @@ const RenderedJsonField = ({ collapsed, content, 
enableClipboard = true, onExpan
     return undefined;
   }
 
+  // A copy button on every row reads as chrome, so it waits for the pointer. 
It stays mounted
+  // rather than conditionally rendered, to keep its place in the tab order 
for a keyboard.
   const clipboardButton = enableClipboard ? (
-    <ClipboardRoot value={contentFormatted}>
-      <ClipboardIconButton h={7} minW={7} />
-    </ClipboardRoot>
+    <Box
+      _focusWithin={{ opacity: 1 }}
+      _groupHover={{ opacity: 1 }}
+      data-testid="json-clipboard-reveal"
+      opacity={0}
+    >
+      <ClipboardRoot value={contentFormatted}>
+        <ClipboardIconButton h={7} minW={7} />
+      </ClipboardRoot>
+    </Box>
   ) : undefined;
 
   if (showBadges) {
     return (
-      <Flex alignItems="center" flex={1} gap={1} minW={`${MIN_WIDTH}px`} 
{...rest}>
-        <JsonPreviewBadges entries={previewEntries} onExpand={onExpand ?? (() 
=> setIsExpanded(true))} />
+      <Flex alignItems="center" className="group" flex={1} gap={1} 
minW={`${MIN_WIDTH}px`} {...rest}>
+        <JsonPreviewBadges
+          entries={previewEntries}
+          maxWidth={previewMaxWidth}
+          onExpand={onExpand ?? (() => setIsExpanded(true))}
+        />
         {clipboardButton}
       </Flex>
     );
   }
 
   return (
-    <Flex flex={1} gap={2} minW={`${MIN_WIDTH}px`} {...rest}>
+    <Flex className="group" flex={1} gap={2} minW={`${MIN_WIDTH}px`} {...rest}>
       <Box flex={1} minW={`${editorWidth}px`} ref={containerRef}>
         <Editor
           beforeMount={beforeMount}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffUnavailableAlert.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffUnavailableAlert.tsx
new file mode 100644
index 00000000000..4985f0e19f4
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffUnavailableAlert.tsx
@@ -0,0 +1,44 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Alert, Code } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+type DiffUnavailableAlertProps = {
+  readonly reason?: string | null;
+};
+
+export const DiffUnavailableAlert = ({ reason }: DiffUnavailableAlertProps) => 
{
+  const { t: translate } = useTranslation("dag");
+
+  return (
+    <Alert.Root status="info" title={translate("versions.unavailable.title")}>
+      <Alert.Description>
+        {reason === null || reason === undefined ? (
+          translate("versions.unavailable.withoutReason")
+        ) : (
+          <>
+            {translate("versions.unavailable.description")}{" "}
+            {/* A machine token, so it is shown as code rather than folded 
into a translated sentence. */}
+            <Code fontSize="sm">{reason}</Code>
+          </>
+        )}
+      </Alert.Description>
+    </Alert.Root>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffValue.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffValue.tsx
new file mode 100644
index 00000000000..0114e2a6b16
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/DiffValue.tsx
@@ -0,0 +1,60 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import RenderedJsonField from "src/components/RenderedJsonField";
+
+import { MonoText } from "./MonoText";
+
+// A scalar can still be long — a templated command, a doc string — and 
nothing below it is worth
+// the vertical space of a whole cell.
+const MAX_VALUE_LENGTH = 120;
+
+// This table is the one place with two JSON previews per row, and the 
component's own cap leaves
+// them demanding more width than the pane has. The rest of a value is one 
expand away.
+const PREVIEW_MAX_WIDTH = 220;
+
+type DiffValueProps = {
+  readonly value: unknown;
+};
+
+export const DiffValue = ({ value }: DiffValueProps) => {
+  // A side the change does not have is left blank: a dash would read as a 
value the version holds.
+  if (value === undefined) {
+    return undefined;
+  }
+
+  // A whole task added or removed carries its entire serialized payload. The 
JSON field names its
+  // top-level keys in badges and opens the editor in place, where a flattened 
string could only be
+  // cut short. An empty object or array has no entries to preview, so it 
falls through to the text
+  // below and shows as `{}` or `[]`.
+  if (typeof value === "object" && value !== null && Object.keys(value).length 
> 0) {
+    return <RenderedJsonField collapsed content={value} 
previewMaxWidth={PREVIEW_MAX_WIDTH} />;
+  }
+
+  // A stored null is a value; only an absent side gets the blank cell above. 
An empty string is
+  // quoted so it reads as a value rather than as a blank cell.
+  const text = typeof value === "string" ? value || '""' : 
JSON.stringify(value);
+
+  // Serialized values have no spaces to break at, so without this a single 
cell widens the table
+  // past the pane and the last ("after") column is clipped.
+  return (
+    <MonoText overflowWrap="anywhere">
+      {text.length > MAX_VALUE_LENGTH ? `${text.slice(0, MAX_VALUE_LENGTH)}…` 
: text}
+    </MonoText>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.test.tsx
new file mode 100644
index 00000000000..b932d7f64de
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.test.tsx
@@ -0,0 +1,71 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import "@testing-library/jest-dom/vitest";
+import { act, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { MaxChangesField } from "./MaxChangesField";
+
+const revealTruncationNotice = async () => {
+  const warning = screen.getByTestId("max-changes-truncated");
+
+  await act(async () => {
+    fireEvent.focus(warning);
+    fireEvent.pointerEnter(warning);
+    await vi.advanceTimersByTimeAsync(500);
+  });
+};
+
+// The test harness passes translation keys through untranslated, so 
assertions on chrome use the
+// key and assertions on content use what the caller passed.
+describe("MaxChangesField", () => {
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it("says nothing about a limit while the comparison fits inside it", () => {
+    render(<MaxChangesField maxChanges={10} onCommit={vi.fn()} 
truncated={false} />, { wrapper: Wrapper });
+
+    
expect(screen.queryByTestId("max-changes-truncated")).not.toBeInTheDocument();
+  });
+
+  it("keeps the truncation notice on the field, behind the warning", async () 
=> {
+    // A banner over the table repeated itself on every comparison and sat 
nowhere near the bound
+    // that caused it; the field is the one control that can lift it.
+    vi.useFakeTimers();
+    render(<MaxChangesField maxChanges={10} onCommit={vi.fn()} truncated />, { 
wrapper: Wrapper });
+
+    
expect(screen.queryByText("versions.limitReached.description")).not.toBeInTheDocument();
+
+    await revealTruncationNotice();
+
+    
expect(screen.getByRole("tooltip")).toHaveTextContent("versions.limitReached.description");
+  });
+
+  it("names the endpoint's own limit when no bound was set", async () => {
+    vi.useFakeTimers();
+    render(<MaxChangesField onCommit={vi.fn()} truncated />, { wrapper: 
Wrapper });
+
+    await revealTruncationNotice();
+
+    
expect(screen.getByRole("tooltip")).toHaveTextContent("versions.limitReached.descriptionDefault");
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.tsx
new file mode 100644
index 00000000000..d646758fb8e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MaxChangesField.tsx
@@ -0,0 +1,119 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from "react";
+
+import { Stack, Text, VStack } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiAlertTriangle } from "react-icons/fi";
+import { useDebouncedCallback } from "use-debounce";
+
+import { NumberInputField, NumberInputRoot, Tooltip } from 
"src/system-components";
+
+// Mirrors MAX_ALLOWED_CHANGES on the diff endpoint, which is the real guard: 
this only spares
+// the caller a rejected request, so a stale value costs a 422 the error alert 
explains.
+export const MAX_CHANGES_LIMIT = 5000;
+// Mirrors DEFAULT_MAX_CHANGES on the diff endpoint, so the truncation notice 
can name the limit
+// that cut the comparison short when the caller left the field empty.
+const DEFAULT_MAX_CHANGES = 500;
+// Every distinct bound is its own request, and each one re-reads and compares 
two whole serialized
+// Dags, so a keystroke is too cheap a trigger.
+const MAX_CHANGES_DEBOUNCE_MS = 400;
+
+type MaxChangesFieldProps = {
+  /** The bound in force; undefined means the endpoint's own default applied. 
*/
+  readonly maxChanges?: number;
+  readonly onCommit: (value: string) => void;
+  /** Whether the comparison in view was cut short by that bound. */
+  readonly truncated: boolean;
+};
+
+export const MaxChangesField = ({ maxChanges, onCommit, truncated }: 
MaxChangesFieldProps) => {
+  const { t: translate } = useTranslation("dag");
+  // The field keeps what was typed while the debounced write settles; the URL 
only ever holds a
+  // bound the endpoint accepts.
+  const [draft, setDraft] = useState(maxChanges === undefined ? "" : 
`${maxChanges}`);
+  const write = useDebouncedCallback(onCommit, MAX_CHANGES_DEBOUNCE_MS);
+
+  // Explicit because the number input otherwise flags an empty field as out 
of range (an empty
+  // value parses to NaN), which drew an error border around the field's 
normal, unset state.
+  const truncationTitle = translate("versions.limitReached.title", {
+    limit: maxChanges ?? DEFAULT_MAX_CHANGES,
+  });
+
+  const draftNumber = Number(draft);
+  const invalid =
+    draft !== "" && (!Number.isInteger(draftNumber) || draftNumber < 1 || 
draftNumber > MAX_CHANGES_LIMIT);
+
+  return (
+    <VStack alignItems="flex-start" gap={1}>
+      <Stack alignItems="center" direction="row" gap={1}>
+        <Text fontSize="xs">{translate("versions.maxChanges")}</Text>
+        {/* The bound that cut the comparison short is the one control that 
can lift it, so the
+            notice sits on the field rather than as a banner over the table it 
describes. */}
+        {truncated ? (
+          <Tooltip
+            content={
+              <Stack gap={1}>
+                <Text fontSize="sm" fontWeight="bold">
+                  {truncationTitle}
+                </Text>
+                <Text fontSize="sm">
+                  {translate(
+                    maxChanges === undefined
+                      ? "versions.limitReached.descriptionDefault"
+                      : "versions.limitReached.description",
+                  )}
+                </Text>
+              </Stack>
+            }
+            contentProps={{ maxW: "320px" }}
+            portalled
+          >
+            <Text
+              aria-label={truncationTitle}
+              as="button"
+              color="warning.fg"
+              data-testid="max-changes-truncated"
+              display="flex"
+            >
+              <FiAlertTriangle />
+            </Text>
+          </Tooltip>
+        ) : undefined}
+      </Stack>
+      <NumberInputRoot
+        invalid={invalid}
+        max={MAX_CHANGES_LIMIT}
+        min={1}
+        onValueChange={({ value }) => {
+          setDraft(value);
+          write(value);
+        }}
+        size="sm"
+        value={draft}
+        w={28}
+      >
+        <NumberInputField
+          aria-label={translate("versions.maxChanges")}
+          placeholder={translate("versions.maxChangesDefault")}
+        />
+      </NumberInputRoot>
+    </VStack>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MonoText.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MonoText.tsx
new file mode 100644
index 00000000000..188e04bb1a3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/MonoText.tsx
@@ -0,0 +1,27 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Text, type TextProps } from "@chakra-ui/react";
+
+// Paths and serialized values are machine text, so they are set in mono. 
Chakra's Code would add
+// its own surface on top of the table's striped rows, which leaves the two 
nearly the same shade.
+export const MonoText = ({ children, ...rest }: TextProps) => (
+  <Text fontFamily="mono" fontSize="sm" whiteSpace="pre-wrap" {...rest}>
+    {children}
+  </Text>
+);
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/PathText.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/PathText.tsx
new file mode 100644
index 00000000000..08a046fb819
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/PathText.tsx
@@ -0,0 +1,49 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { Fragment } from "react";
+
+import { MonoText } from "./MonoText";
+
+type PathTextProps = {
+  readonly path: string;
+};
+
+// A path has no spaces, and `overflow-wrap: anywhere` would let it break 
after any character,
+// which drops the column's min-content width to one glyph — the other columns 
then squeeze it
+// until the path reads vertically. Offering a break after each separator 
instead keeps the column
+// at least as wide as the longest segment and breaks where the path already 
divides.
+export const PathText = ({ path }: PathTextProps) => {
+  let walked = "";
+
+  return (
+    <MonoText overflowWrap="break-word">
+      {path.split("/").map((segment, index) => {
+        walked += index === 0 ? segment : `/${segment}`;
+
+        return (
+          <Fragment key={walked === "" ? "/" : walked}>
+            {index === 0 ? undefined : "/"}
+            <wbr />
+            {segment}
+          </Fragment>
+        );
+      })}
+    </MonoText>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/SelectedVersionsDiff.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/SelectedVersionsDiff.tsx
new file mode 100644
index 00000000000..f968ef507ee
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/SelectedVersionsDiff.tsx
@@ -0,0 +1,81 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect } from "react";
+
+import { useDagVersionServiceGetDagVersionDiff } from "openapi/queries";
+
+import { ProgressBar } from "src/system-components";
+
+import { ErrorAlert } from "src/components/ErrorAlert";
+
+import { VersionDiff } from "./VersionDiff";
+
+type SelectedVersionsDiffProps = {
+  readonly baseVersionNumber: number;
+  readonly dagId: string;
+  readonly maxChanges: number | undefined;
+  /** Reports whether the bound cut this comparison short, for the field that 
sets it. */
+  readonly onTruncatedChange: (truncated: boolean) => void;
+  readonly targetVersionNumber: number;
+};
+
+/** Split out so the query only exists once both versions are chosen, rather 
than being disabled. */
+export const SelectedVersionsDiff = ({
+  baseVersionNumber,
+  dagId,
+  maxChanges,
+  onTruncatedChange,
+  targetVersionNumber,
+}: SelectedVersionsDiffProps) => {
+  const { data, error, isLoading } = useDagVersionServiceGetDagVersionDiff(
+    {
+      baseVersionNumber,
+      dagId,
+      maxChanges,
+      targetVersionNumber,
+    },
+    undefined,
+    // Changing the bound is a new query key; without this the table would 
blank out while it loads.
+    { placeholderData: (previous) => previous },
+  );
+
+  const truncated = data?.truncated ?? false;
+
+  // Cleared on the way out as well, so a bound that cut one comparison short 
does not go on
+  // warning about the next pair of versions.
+  useEffect(() => {
+    onTruncatedChange(truncated);
+
+    return () => onTruncatedChange(false);
+  }, [onTruncatedChange, truncated]);
+
+  return (
+    <>
+      <ErrorAlert error={error} />
+      <ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} />
+      {data === undefined ? undefined : (
+        <VersionDiff
+          baseVersionNumber={baseVersionNumber}
+          diff={data}
+          targetVersionNumber={targetVersionNumber}
+        />
+      )}
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.test.tsx
index 87c28cffb23..18bedaded99 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.test.tsx
@@ -79,10 +79,10 @@ const authorized: DagVersionDiffResponse = {
   values_status: "available",
 };
 
-const renderDiff = (diff: DagVersionDiffResponse, maxChanges?: number) =>
+const renderDiff = (diff: DagVersionDiffResponse) =>
   render(
     <Wrapper>
-      <VersionDiff baseVersionNumber={1} diff={diff} maxChanges={maxChanges} 
targetVersionNumber={2} />
+      <VersionDiff baseVersionNumber={1} diff={diff} targetVersionNumber={2} />
     </Wrapper>,
   );
 
@@ -152,18 +152,12 @@ describe("VersionDiff", () => {
     expect(screen.getByText("bob")).toBeInTheDocument();
   });
 
-  it("says a truncated count is a lower bound and points at the limit that was 
set", () => {
-    renderDiff({ ...redacted, truncated: true }, 10);
-
-    expect(screen.getByText("versions.summaryAtLeast")).toBeInTheDocument();
-    
expect(screen.getByText("versions.limitReached.title")).toBeInTheDocument();
-    
expect(screen.getByText("versions.limitReached.description")).toBeInTheDocument();
-  });
-
-  it("names the default limit when no bound was set", () => {
+  it("says a truncated count is a lower bound", () => {
     renderDiff({ ...redacted, truncated: true });
 
-    
expect(screen.getByText("versions.limitReached.descriptionDefault")).toBeInTheDocument();
+    expect(screen.getByText("versions.summaryAtLeast")).toBeInTheDocument();
+    // What the bound was, and how to lift it, belongs on the field that sets 
it.
+    
expect(screen.queryByText("versions.limitReached.title")).not.toBeInTheDocument();
   });
 
   it("counts changes once, in the summary, not again above the table", () => {
@@ -180,7 +174,7 @@ describe("VersionDiff", () => {
     
expect(screen.queryByText("versions.limitReached.title")).not.toBeInTheDocument();
   });
 
-  it("marks the side an added change does not have", () => {
+  it("leaves the side an added change does not have blank", () => {
     renderDiff({
       ...authorized,
       changes: [
@@ -197,10 +191,14 @@ describe("VersionDiff", () => {
       ],
     });
 
-    expect(screen.getByText("—")).toBeInTheDocument();
+    const cells = within(screen.getAllByRole("row")[1] as 
HTMLElement).getAllByRole("cell");
+
+    // A dash would read as a value the base version holds, rather than as a 
side that is absent.
+    expect(cells[5]).toBeEmptyDOMElement();
+    expect(screen.queryByText("—")).not.toBeInTheDocument();
   });
 
-  it("truncates a value too large to read in a table cell", () => {
+  it("previews a structured value as JSON rather than flattening it into the 
cell", () => {
     const wholeTask = { __type: "operator", __var: { task_id: "extract", 
template_fields: "x".repeat(400) } };
 
     renderDiff({
@@ -217,7 +215,48 @@ describe("VersionDiff", () => {
       ],
     });
 
-    const text = screen.getByText(/^\{"__type"/u).textContent;
+    expect(screen.getByTestId("json-preview-badges")).toBeInTheDocument();
+    expect(screen.getByText("__type:")).toBeInTheDocument();
+    // The serialized payload itself stays in the editor the preview opens.
+    expect(screen.queryByText(/^\{"__type"/u)).not.toBeInTheDocument();
+  });
+
+  it("shows an empty structure as a value, since it has no entries to 
preview", () => {
+    renderDiff({
+      ...authorized,
+      changes: [
+        {
+          after_value: [],
+          before_value: {},
+          category: "task",
+          impact: "execution",
+          occurrence_count: 1,
+          operation: "changed",
+          path: "/dag/tasks/extract/outlets",
+        },
+      ],
+    });
+
+    expect(screen.getByText("{}")).toBeInTheDocument();
+    expect(screen.getByText("[]")).toBeInTheDocument();
+  });
+
+  it("truncates a scalar too long to read in a table cell", () => {
+    renderDiff({
+      ...authorized,
+      changes: [
+        {
+          after_value: `s3://bucket/${"x".repeat(400)}`,
+          category: "task",
+          impact: "execution",
+          occurrence_count: 1,
+          operation: "changed",
+          path: "/dag/tasks/extract/bash_command",
+        },
+      ],
+    });
+
+    const text = screen.getByText(/^s3:\/\/bucket\//u).textContent;
 
     expect(text.endsWith("…")).toBe(true);
     expect(text.length).toBeLessThan(200);
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.tsx
index f388e91c0c1..da395557d79 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/VersionDiff.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Alert, Badge, Box, Code, Flex, Heading, Text } from 
"@chakra-ui/react";
+import { Badge, Box, Heading, Text } from "@chakra-ui/react";
 import type { TFunction } from "i18next";
 import { useTranslation } from "react-i18next";
 import { LuInfo } from "react-icons/lu";
@@ -28,18 +28,16 @@ import { Tooltip } from "src/system-components";
 import { DataTable } from "src/components/DataTable";
 import type { MetaColumn } from "src/components/DataTable/types";
 
+import { DiffUnavailableAlert } from "./DiffUnavailableAlert";
+import { DiffValue } from "./DiffValue";
+import { PathText } from "./PathText";
+
 type VersionDiffProps = {
   readonly baseVersionNumber: number;
   readonly diff: DagVersionDiffResponse;
-  /** The bound the request asked for; undefined means the endpoint's default 
applied. */
-  readonly maxChanges?: number;
   readonly targetVersionNumber: number;
 };
 
-// Mirrors DEFAULT_MAX_CHANGES on the diff endpoint, so the truncation notice 
can name the limit
-// that cut the comparison short when the caller left the field empty.
-const DEFAULT_MAX_CHANGES = 500;
-
 const IMPACT_COLORS: Record<string, string> = {
   authorization: "orange",
   execution: "red",
@@ -48,22 +46,6 @@ const IMPACT_COLORS: Record<string, string> = {
   unknown: "gray",
 };
 
-// A whole task added or removed carries its entire serialized payload, which 
would otherwise
-// unfold into one unreadable table cell.
-const MAX_VALUE_LENGTH = 120;
-
-const renderValue = (value: unknown) => {
-  if (value === undefined) {
-    return "—";
-  }
-
-  // A stored null is a value; only an absent side gets the dash above. An 
empty string is quoted
-  // so it reads as a value rather than as a blank cell.
-  const text = typeof value === "string" ? value || '""' : 
JSON.stringify(value);
-
-  return text.length > MAX_VALUE_LENGTH ? `${text.slice(0, 
MAX_VALUE_LENGTH)}…` : text;
-};
-
 type ChangeColumns = Array<MetaColumn<DagVersionDiffChangeResponse>>;
 
 const buildValueColumns = (translate: TFunction<"dag">): ChangeColumns => [
@@ -71,13 +53,13 @@ const buildValueColumns = (translate: TFunction<"dag">): 
ChangeColumns => [
     accessorKey: "before_value",
     // Read off the record rather than the accessor value, so a key the server 
omitted stays
     // distinguishable from one holding a stored null.
-    cell: ({ row }) => <Code 
fontSize="sm">{renderValue(row.original.before_value)}</Code>,
+    cell: ({ row }) => <DiffValue value={row.original.before_value} />,
     enableSorting: false,
     header: translate("versions.columns.before"),
   },
   {
     accessorKey: "after_value",
-    cell: ({ row }) => <Code 
fontSize="sm">{renderValue(row.original.after_value)}</Code>,
+    cell: ({ row }) => <DiffValue value={row.original.after_value} />,
     enableSorting: false,
     header: translate("versions.columns.after"),
   },
@@ -86,7 +68,7 @@ const buildValueColumns = (translate: TFunction<"dag">): 
ChangeColumns => [
 const buildColumns = (translate: TFunction<"dag">, valuesShown: boolean): 
ChangeColumns => [
   {
     accessorKey: "path",
-    cell: ({ row }) => <Code fontSize="sm">{row.original.path}</Code>,
+    cell: ({ row }) => <PathText path={row.original.path} />,
     enableSorting: false,
     header: translate("versions.columns.path"),
   },
@@ -126,37 +108,22 @@ const buildColumns = (translate: TFunction<"dag">, 
valuesShown: boolean): Change
   ...(valuesShown ? buildValueColumns(translate) : []),
 ];
 
-export const VersionDiff = ({
-  baseVersionNumber,
-  diff,
-  maxChanges,
-  targetVersionNumber,
-}: VersionDiffProps) => {
+export const VersionDiff = ({ baseVersionNumber, diff, targetVersionNumber }: 
VersionDiffProps) => {
   const { t: translate } = useTranslation("dag");
   const valuesShown = diff.values_status === "available";
   const schemaChanged = diff.serializer_versions.base !== 
diff.serializer_versions.target;
 
   if (diff.mode === "unavailable") {
-    return (
-      <Alert.Root status="info" 
title={translate("versions.unavailable.title")}>
-        <Alert.Description>
-          {diff.unavailable_reason === null || diff.unavailable_reason === 
undefined ? (
-            translate("versions.unavailable.withoutReason")
-          ) : (
-            <>
-              {translate("versions.unavailable.description")}{" "}
-              {/* A machine token, so it is shown as code rather than folded 
into a translated sentence. */}
-              <Code fontSize="sm">{diff.unavailable_reason}</Code>
-            </>
-          )}
-        </Alert.Description>
-      </Alert.Root>
-    );
+    return <DiffUnavailableAlert reason={diff.unavailable_reason} />;
   }
 
   return (
-    <Box>
-      <Flex alignItems="center" gap={3} justifyContent="space-between" mb={2}>
+    <DataTable
+      columns={buildColumns(translate, valuesShown)}
+      data={diff.changes}
+      // The summary heads the table's own header row, so the comparison, what 
it leaves out and
+      // the control over its columns read as one line rather than three 
stacked ones.
+      headingExtra={
         <Box>
           <Heading size="md">
             {translate("versions.heading", { base: baseVersionNumber, target: 
targetVersionNumber })}
@@ -175,54 +142,31 @@ export const VersionDiff = ({
               : undefined}
           </Text>
         </Box>
-        <Flex alignItems="center" gap={2}>
+      }
+      // The summary above already counts the changes. A second "N Records" 
count only invites a
+      // comparison that does not hold: a redacted record stands for every 
change sharing its path.
+      hideRowCountHeading
+      modelName="dag:versions.record"
+      noRowsMessage={translate("versions.noChanges")}
+      presentationActions={
+        <>
           {/* The hover says what grants values, since the viewer cannot grant 
it here: the server
-              decides from the caller's access to Dag code. */}
+                decides from the caller's access to Dag code. */}
           {valuesShown ? undefined : (
             <Tooltip content={translate("versions.codeAccess")} portalled>
               <Badge 
colorPalette="gray">{translate("versions.valuesHidden")}</Badge>
             </Tooltip>
           )}
           {/* Above the table rather than below it: a long diff would 
otherwise push the caveat
-              off-screen exactly when it matters most. */}
+                off-screen exactly when it matters most. */}
           <Tooltip content={translate("versions.observedStateBoundary")} 
portalled>
             <Box aria-label={translate("versions.aboutThisComparison")} 
as="button" color="fg.muted" p={1}>
               <LuInfo />
             </Box>
           </Tooltip>
-        </Flex>
-      </Flex>
-
-      {diff.truncated ? (
-        // The title is a child, not a `title` prop: Chakra's Alert.Root 
passes that through as the
-        // HTML attribute, so it only ever showed as a hover tooltip.
-        <Alert.Root mb={2} status="warning">
-          <Alert.Indicator />
-          <Alert.Content>
-            <Alert.Title>
-              {translate("versions.limitReached.title", { limit: maxChanges ?? 
DEFAULT_MAX_CHANGES })}
-            </Alert.Title>
-            <Alert.Description>
-              {translate(
-                maxChanges === undefined
-                  ? "versions.limitReached.descriptionDefault"
-                  : "versions.limitReached.description",
-              )}
-            </Alert.Description>
-          </Alert.Content>
-        </Alert.Root>
-      ) : undefined}
-
-      <DataTable
-        columns={buildColumns(translate, valuesShown)}
-        data={diff.changes}
-        // The summary above already counts the changes. A second "N Records" 
count only invites a
-        // comparison that does not hold: a redacted record stands for every 
change sharing its path.
-        hideRowCountHeading
-        modelName="dag:versions.record"
-        noRowsMessage={translate("versions.noChanges")}
-        total={diff.changes.length}
-      />
-    </Box>
+        </>
+      }
+      total={diff.changes.length}
+    />
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/Versions.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/Versions.tsx
index f2f35151b27..7b97f443957 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Versions/Versions.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Versions/Versions.tsx
@@ -18,70 +18,16 @@
  */
 import { useState } from "react";
 
-import { Box, Flex, Text, VStack } from "@chakra-ui/react";
+import { Box, Flex, Text } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { useParams, useSearchParams } from "react-router-dom";
-import { useDebouncedCallback } from "use-debounce";
 
-import { useDagVersionServiceGetDagVersionDiff } from "openapi/queries";
-
-import { NumberInputField, NumberInputRoot, ProgressBar } from 
"src/system-components";
-
-import { ErrorAlert } from "src/components/ErrorAlert";
 import { VersionCompareSelect } from "src/components/VersionCompareSelect";
 
 import { SearchParamsKeys } from "src/constants/searchParams";
 
-import { VersionDiff } from "./VersionDiff";
-
-// Mirrors MAX_ALLOWED_CHANGES on the diff endpoint, which is the real guard: 
this only spares
-// the caller a rejected request, so a stale value costs a 422 the error alert 
explains.
-const MAX_CHANGES_LIMIT = 5000;
-// Every distinct bound is its own request, and each one re-reads and compares 
two whole serialized
-// Dags, so a keystroke is too cheap a trigger.
-const MAX_CHANGES_DEBOUNCE_MS = 400;
-
-type SelectedVersionsProps = {
-  readonly baseVersionNumber: number;
-  readonly dagId: string;
-  readonly maxChanges: number | undefined;
-  readonly targetVersionNumber: number;
-};
-
-/** Split out so the query only exists once both versions are chosen, rather 
than being disabled. */
-const SelectedVersionsDiff = ({
-  baseVersionNumber,
-  dagId,
-  maxChanges,
-  targetVersionNumber,
-}: SelectedVersionsProps) => {
-  const { data, error, isLoading } = useDagVersionServiceGetDagVersionDiff(
-    {
-      baseVersionNumber,
-      dagId,
-      maxChanges,
-      targetVersionNumber,
-    },
-    undefined,
-    // Changing the bound is a new query key; without this the table would 
blank out while it loads.
-    { placeholderData: (previous) => previous },
-  );
-
-  return (
-    <>
-      <ErrorAlert error={error} />
-      <ProgressBar size="xs" visibility={isLoading ? "visible" : "hidden"} />
-      {data === undefined ? undefined : (
-        <VersionDiff
-          baseVersionNumber={baseVersionNumber}
-          diff={data}
-          maxChanges={maxChanges}
-          targetVersionNumber={targetVersionNumber}
-        />
-      )}
-    </>
-  );
-};
+import { MAX_CHANGES_LIMIT, MaxChangesField } from "./MaxChangesField";
+import { SelectedVersionsDiff } from "./SelectedVersionsDiff";
 
 const parsePositiveInt = (raw: string | null) => {
   const parsed = Number(raw);
@@ -101,16 +47,8 @@ export const Versions = () => {
   // request. An empty bound means the endpoint's own default applies.
   const maxChanges =
     requestedMaxChanges === undefined ? undefined : 
Math.min(requestedMaxChanges, MAX_CHANGES_LIMIT);
-  // The field keeps what was typed while the debounced write settles; the URL 
only ever holds a
-  // bound the endpoint accepts.
-  const [maxChangesDraft, setMaxChangesDraft] = useState(maxChanges === 
undefined ? "" : `${maxChanges}`);
-
-  // Explicit because the number input otherwise flags an empty field as out 
of range (an empty
-  // value parses to NaN), which drew an error border around the field's 
normal, unset state.
-  const draftNumber = Number(maxChangesDraft);
-  const maxChangesInvalid =
-    maxChangesDraft !== "" &&
-    (!Number.isInteger(draftNumber) || draftNumber < 1 || draftNumber > 
MAX_CHANGES_LIMIT);
+  // Only the comparison knows whether its bound cut it short, and only the 
field can lift it.
+  const [truncated, setTruncated] = useState(false);
 
   const updateParam = (key: string, value: string) =>
     setSearchParams(
@@ -128,14 +66,14 @@ export const Versions = () => {
       { replace: true },
     );
 
-  const writeMaxChanges = useDebouncedCallback((value: string) => {
+  const writeMaxChanges = (value: string) => {
     const parsed = parsePositiveInt(value);
 
     updateParam(
       SearchParamsKeys.MAX_CHANGES,
       parsed === undefined ? "" : `${Math.min(parsed, MAX_CHANGES_LIMIT)}`,
     );
-  }, MAX_CHANGES_DEBOUNCE_MS);
+  };
 
   return (
     <Box p={2}>
@@ -154,26 +92,7 @@ export const Versions = () => {
           }
           selectedVersionNumber={targetVersionNumber}
         />
-        <VStack alignItems="flex-start" gap={1}>
-          <Text fontSize="xs">{translate("versions.maxChanges")}</Text>
-          <NumberInputRoot
-            invalid={maxChangesInvalid}
-            max={MAX_CHANGES_LIMIT}
-            min={1}
-            onValueChange={({ value }) => {
-              setMaxChangesDraft(value);
-              writeMaxChanges(value);
-            }}
-            size="sm"
-            value={maxChangesDraft}
-            w={28}
-          >
-            <NumberInputField
-              aria-label={translate("versions.maxChanges")}
-              placeholder={translate("versions.maxChangesDefault")}
-            />
-          </NumberInputRoot>
-        </VStack>
+        <MaxChangesField maxChanges={maxChanges} onCommit={writeMaxChanges} 
truncated={truncated} />
       </Flex>
 
       {baseVersionNumber === undefined || targetVersionNumber === undefined ? (
@@ -183,6 +102,7 @@ export const Versions = () => {
           baseVersionNumber={baseVersionNumber}
           dagId={dagId}
           maxChanges={maxChanges}
+          onTruncatedChange={setTruncated}
           targetVersionNumber={targetVersionNumber}
         />
       )}

Reply via email to