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

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new b7c89dec8d7 Fix Grid horizontal scrolling (#76083)
b7c89dec8d7 is described below

commit b7c89dec8d7aa7c683ef318daf4b4c93fb431f1e
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Nov 9 14:30:28 2026 -0500

    Fix Grid horizontal scrolling (#76083)
    
    * Fix Grid horizontal scrolling
    
    On a Dag with enough runs that the Grid scrolls sideways, the page loaded
    scrolled to the wrong end, the run columns slid over the task names, and the
    pager for older runs was pushed off screen. The Grid now opens on the 
selected
    run, keeps the task names on top while it scrolls, keeps the pager within
    reach, and stays where the user left it when they open a run or task or when
    new runs arrive.
    
    Co-Authored-By: Claude <[email protected]>
    
    * UI: Keep a revealed run clear of the Grid's pager buttons
    
    The newer/reset buttons are pinned over the inline end of the scrollport,
    which is where nearest alignment parks a run that was cut off at that edge.
    The run's duration bar then sat under "Reset to latest", so clicking the bar
    hit the button instead. The layout already reserves that width through the
    scrollbar spacer; the reveal now reads it from the same constant, so the two
    cannot drift apart.
    
    The two fresh-visit centring cases are merged into one parametrized test,
    which keeps the file inside its line budget.
    
    Co-Authored-By: Claude <[email protected]>
    
    * UI: Reserve the Grid's whole end strip when revealing a run
    
    Overlay scrollbars, which macOS uses, sit inside clientWidth rather than
    outside it, so the reveal could park a run under the scrollbar exactly as
    #69447 described before the spacer was added; the details panel's collapse
    button overlaps that edge too. Reserving only the pager buttons' width left
    both uncovered whenever the buttons were hidden, so the reveal now keeps 
clear
    of the same strip the spacer holds open, whatever is sitting in it.
    
    Co-Authored-By: Claude <[email protected]>
    
    ---------
    
    Co-authored-by: Claude <[email protected]>
---
 .../ui/src/layouts/Details/Grid/Grid.test.tsx      |  72 +++
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   |  79 ++-
 .../layouts/Details/Grid/GridPaginationButtons.tsx | 127 +++--
 .../ui/src/layouts/Details/Grid/constants.ts       |  11 +
 .../Details/Grid/useGridScrollRestore.test.ts      | 617 ++++++++++++++++++---
 .../layouts/Details/Grid/useGridScrollRestore.ts   | 183 +++++-
 .../airflow/ui/src/layouts/Details/Grid/utils.ts   |   5 +-
 7 files changed, 934 insertions(+), 160 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.test.tsx
index 74034926261..cbba47469db 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.test.tsx
@@ -28,6 +28,7 @@ import { GroupsProvider } from "src/context/groups";
 import { Wrapper } from "src/utils/Wrapper";
 
 import { Grid } from "./Grid";
+import { TASK_NAME_COLUMN_MIN_WIDTH_PX } from "./constants";
 
 const buildRun = (runId: string, versionNumber: number): GridRunsResponse => ({
   dag_id: "example_dag",
@@ -96,4 +97,75 @@ describe("Grid", () => {
       expect(runContainer).toHaveStyle({ isolation: "isolate" });
     }
   });
+
+  it("keeps the task name column pinned for the full width of the run 
columns", () => {
+    const { container } = render(
+      <Wrapper>
+        <GroupsProvider dagId="example_dag">
+          <GridSelectionProvider>
+            <Grid
+              limit={10}
+              offset={0}
+              onJumpToLatest={vi.fn()}
+              setOffset={vi.fn()}
+              showVersionIndicatorMode={VersionIndicatorOptions.ALL}
+            />
+          </GridSelectionProvider>
+        </GroupsProvider>
+      </Wrapper>,
+    );
+
+    // The run columns live in two rows, header then body; each row also holds 
the sticky task
+    // name column. The virtualizer renders no rows without layout, so reach 
them via the columns.
+    const [, bodyRunColumns] = [
+      ...new 
Set([...container.querySelectorAll("[data-run-id]")].map((element) => 
element.parentElement)),
+    ];
+    const bodyRow = bodyRunColumns?.parentElement;
+    const headerRow = bodyRow?.previousElementSibling;
+
+    // Both rows have to outgrow the scrollport. A row only as wide as the 
scrollport ends the sticky
+    // column's containing block there, so it stops being pinned partway 
through a horizontal scroll.
+    expect(bodyRow).toHaveStyle({ minWidth: "max-content" });
+    expect(headerRow).toHaveStyle({ minWidth: "max-content" });
+  });
+
+  it("pins the pager buttons to the scrollport, on either side of the run 
columns", () => {
+    // limit below the run count gives an older page, and a non-zero offset a 
newer one.
+    const { container } = render(
+      <Wrapper>
+        <GroupsProvider dagId="example_dag">
+          <GridSelectionProvider>
+            <Grid
+              limit={1}
+              offset={10}
+              onJumpToLatest={vi.fn()}
+              setOffset={vi.fn()}
+              showVersionIndicatorMode={VersionIndicatorOptions.ALL}
+            />
+          </GridSelectionProvider>
+        </GroupsProvider>
+      </Wrapper>,
+    );
+
+    const [olderButton, newerButton] = 
[...container.querySelectorAll("button")];
+    const olderPager = olderButton?.parentElement;
+    const newerPager = newerButton?.parentElement?.parentElement;
+
+    // Sticky to the scrollport rather than absolute to the content, so 
scrolling cannot carry the
+    // buttons out of reach; zero width keeps them out of the flex layout 
either way.
+    for (const pager of [olderPager, newerPager]) {
+      expect(pager).toHaveStyle({ position: "sticky", width: "0" });
+    }
+
+    // The older side stops at the task name column's inner edge. Whenever the 
run columns overflow
+    // there is no free space for the column to grow into, so it sits at 
exactly this minimum.
+    expect(olderPager).toHaveStyle({ insetInlineStart: 
`${TASK_NAME_COLUMN_MIN_WIDTH_PX}px` });
+
+    // Sticky parks a box at its natural position until the edge pushes it, so 
each side has to sit
+    // on its own side of the columns in the flow.
+    const runColumn = container.querySelector("[data-run-id]");
+
+    expect(olderPager?.compareDocumentPosition(runColumn as 
Node)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
+    expect(newerPager?.compareDocumentPosition(runColumn as 
Node)).toBe(Node.DOCUMENT_POSITION_PRECEDING);
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
index bfaa3ba5824..c453e30b401 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
@@ -23,6 +23,7 @@ import { Box, Flex } from "@chakra-ui/react";
 import { useVirtualizer } from "@tanstack/react-virtual";
 import dayjs from "dayjs";
 import dayjsDuration from "dayjs/plugin/duration";
+import { useTranslation } from "react-i18next";
 import { useParams, useSearchParams } from "react-router-dom";
 
 import type { DagRunState, DagRunType, GridRunsResponse } from 
"openapi/requests";
@@ -38,11 +39,19 @@ import { useGridTiSummariesStream } from 
"src/queries/useGridTISummaries.ts";
 import { Bar } from "./Bar";
 import { DurationAxis } from "./DurationAxis";
 import { DurationTick } from "./DurationTick";
-import { GridPaginationButtons } from "./GridPaginationButtons";
+import { NewerRunsButtons, OlderRunsButton } from "./GridPaginationButtons";
 import { GridSelectionActionBar } from "./GridSelectionActionBar";
 import { TaskInstancesColumn } from "./TaskInstancesColumn";
 import { TaskNames } from "./TaskNames";
-import { GANTT_ROW_OFFSET_PX, GRID_HEADER_HEIGHT_PX, GRID_HEADER_PADDING_PX, 
ROW_HEIGHT } from "./constants";
+import {
+  GANTT_ROW_OFFSET_PX,
+  GRID_HEADER_HEIGHT_PX,
+  GRID_HEADER_PADDING_PX,
+  PINNED_RUN_BUTTONS_WIDTH_PX,
+  ROW_HEIGHT,
+  SCROLLBAR_SPACER_WIDTH_PX,
+  TASK_NAME_COLUMN_MIN_WIDTH_PX,
+} from "./constants";
 import { useGridPagination } from "./useGridPagination";
 import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
 import { useGridScrollRestore } from "./useGridScrollRestore";
@@ -69,7 +78,7 @@ type Props = {
 
 const GRID_INNER_SCROLL_PADDING_START_PX = GRID_HEADER_PADDING_PX + 
GRID_HEADER_HEIGHT_PX;
 // Reserves right-edge space for the scrollbar, and widens to fit the 
newer/reset pager buttons when shown.
-const ScrollbarSpacer = ({ width = "16px" }: { readonly width?: string }) => (
+const ScrollbarSpacer = ({ width }: { readonly width: string }) => (
   <Box aria-hidden flexShrink={0} minWidth={width} width={width} />
 );
 
@@ -90,6 +99,8 @@ export const Grid = ({
 }: Props) => {
   const gridRef = useRef<HTMLDivElement>(null);
   const scrollContainerRef = useRef<HTMLDivElement | null>(null);
+  const taskNameColumnRef = useRef<HTMLDivElement>(null);
+  const { i18n } = useTranslation();
 
   const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt);
 
@@ -119,11 +130,18 @@ export const Grid = ({
   const { handleNewerRuns, handleOlderRuns, hasNewerRuns, hasOlderRuns, 
latestNotVisible } =
     useGridPagination({ gridRuns: dataGridRuns, limit, offset, setOffset });
 
-  const scrollbarSpacerWidth = hasNewerRuns || latestNotVisible ? "32px" : 
"16px";
+  // The scrollbar and the pager buttons both cover the run columns at this 
edge. The spacer keeps
+  // the newest run clear of them once the Grid is scrolled to the end; the 
reveal below keeps every
+  // other run clear of them too, off the same width.
+  const reservedEndWidth =
+    hasNewerRuns || latestNotVisible ? PINNED_RUN_BUTTONS_WIDTH_PX : 
SCROLLBAR_SPACER_WIDTH_PX;
+  const scrollbarSpacerWidth = `${reservedEndWidth}px`;
+
+  const runIds = gridRuns?.map((dr: GridRunsResponse) => dr.run_id) ?? [];
 
   const { summariesByRunId } = useGridTiSummariesStream({
     dagId,
-    runIds: gridRuns?.map((dr: GridRunsResponse) => dr.run_id) ?? [],
+    runIds,
     states: gridRuns?.map((dr: GridRunsResponse) => dr.state),
   });
 
@@ -177,7 +195,7 @@ export const Grid = ({
       : {
           flexGrow: 1,
           flexShrink: 0,
-          minW: "200px",
+          minW: `${TASK_NAME_COLUMN_MIN_WIDTH_PX}px`,
         };
 
   const { setMode } = useNavigation({
@@ -223,7 +241,20 @@ export const Grid = ({
     scrollPaddingStart: headerPad,
   });
 
-  useGridScrollRestore({ dagId, flatNodes, headerPad, rowVirtualizer, 
selectedGroupId, selectedTaskId });
+  useGridScrollRestore({
+    dagId,
+    flatNodes,
+    headerPad,
+    isRtl: i18n.dir() === "rtl",
+    reservedEndWidth,
+    rowVirtualizer,
+    runIds,
+    selectedGroupId,
+    selectedRunId,
+    selectedTaskId,
+    sharesScroller: usesSharedScroll,
+    taskNameColumnRef,
+  });
 
   const virtualItems = rowVirtualizer.getVirtualItems();
 
@@ -244,7 +275,7 @@ export const Grid = ({
         <Box
           bg="bg"
           borderTopRadius="md"
-          left={0}
+          insetStart={0}
           position="sticky"
           pt={`${GRID_HEADER_PADDING_PX}px`}
           zIndex={1}
@@ -267,6 +298,12 @@ export const Grid = ({
             <DurationAxis top="4px" />
             {/* Isolated so version indicators stay beneath the sticky task 
name column when scrolled
               behind it. Pagination buttons sit outside on purpose: the 
older-runs button overlaps that column. */}
+            <OlderRunsButton
+              hasOlderRuns={hasOlderRuns}
+              isLoading={isLoading}
+              onOlderRuns={handleOlderRuns}
+              taskNameColumnWidthPx={taskNameColumnWidthPx ?? 
TASK_NAME_COLUMN_MIN_WIDTH_PX}
+            />
             <Flex flexDirection="row-reverse" style={{ isolation: "isolate" }}>
               {!showGantt && <ScrollbarSpacer width={scrollbarSpacerWidth} />}
               {runsWithVersionFlags?.map((dr) => (
@@ -279,22 +316,36 @@ export const Grid = ({
                 />
               ))}
             </Flex>
-            <GridPaginationButtons
+            <NewerRunsButtons
               hasNewerRuns={hasNewerRuns}
-              hasOlderRuns={hasOlderRuns}
               isLoading={isLoading}
               latestNotVisible={latestNotVisible}
               onJumpToLatest={onJumpToLatest}
               onNewerRuns={handleNewerRuns}
-              onOlderRuns={handleOlderRuns}
             />
           </Flex>
         </Flex>
       </Flex>
 
-      {/* Grid body */}
-      <Flex bg="bg" height={`${rowVirtualizer.getTotalSize()}px`} 
position="relative">
-        <Box left={0} position="sticky" zIndex={1} {...taskNameColumnStyles}>
+      {/* Grid body. minWidth matches the header: without it the body is only 
as wide as the
+        scrollport, so the sticky task name column runs out of containing 
block partway through a
+        horizontal scroll and slides out of view, leaving the rows unlabelled. 
*/}
+      <Flex
+        bg="bg"
+        height={`${rowVirtualizer.getTotalSize()}px`}
+        minWidth={usesSharedScroll ? undefined : "max-content"}
+        position="relative"
+      >
+        {/* bg is what the run columns scroll behind: the rows themselves only 
paint a background
+          when selected, so without it the cells show through the pinned 
column. */}
+        <Box
+          bg="bg"
+          insetStart={0}
+          position="sticky"
+          ref={taskNameColumnRef}
+          zIndex={1}
+          {...taskNameColumnStyles}
+        >
           <TaskNames
             nodes={flatNodes}
             onRowClick={handleRowClick}
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridPaginationButtons.tsx
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridPaginationButtons.tsx
index b14ea828f6c..d9575e4ad63 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridPaginationButtons.tsx
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridPaginationButtons.tsx
@@ -16,74 +16,109 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Flex } from "@chakra-ui/react";
+/**
+ * Paging buttons for the Grid's run columns, one component per side.
+ *
+ * Both sides are pinned to the edges of the Grid's scrollport, so paging 
stays reachable however
+ * far the runs are scrolled. Each is a zero-width sticky box: sticky keeps it 
at the scrollport's
+ * edge instead of the content's, and zero width keeps it out of the flex 
layout, so the buttons
+ * never affect column alignment.
+ *
+ * Sticky holds a box at its natural position until that position would cross 
the edge, so each side
+ * has to sit where it belongs in the flow: the Grid renders OlderRunsButton 
before the run columns
+ * and NewerRunsButtons after them.
+ */
+import { Box, Flex } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { FiChevronLeft, FiChevronRight, FiChevronsRight } from 
"react-icons/fi";
 
 import { IconButton } from "src/system-components";
 
-type Props = {
+const BUTTON_PROPS = { size: "2xs", variant: "ghost" } as const;
+
+type NewerProps = {
   readonly hasNewerRuns: boolean;
-  readonly hasOlderRuns: boolean;
   readonly isLoading: boolean;
   readonly latestNotVisible: boolean;
   readonly onJumpToLatest: () => void;
   readonly onNewerRuns: () => void;
+};
+
+type OlderProps = {
+  readonly hasOlderRuns: boolean;
+  readonly isLoading: boolean;
   readonly onOlderRuns: () => void;
+  readonly taskNameColumnWidthPx: number;
 };
 
-/**
- * Absolutely-positioned navigation buttons that appear at the left and right
- * edges of the grid header bar area.  They do not participate in the flex
- * layout so they never affect column alignment.
- */
-export const GridPaginationButtons = ({
+export const NewerRunsButtons = ({
   hasNewerRuns,
-  hasOlderRuns,
   isLoading,
   latestNotVisible,
   onJumpToLatest,
   onNewerRuns,
-  onOlderRuns,
-}: Props) => {
+}: NewerProps) => {
   const { t: translate } = useTranslation("dag");
 
-  // Shared props applied to every pagination button.
-  const buttonProps = { loading: isLoading, size: "2xs", variant: "ghost" } as 
const;
+  if (!latestNotVisible && !hasNewerRuns) {
+    return undefined;
+  }
+
+  return (
+    <Box insetEnd={1} position="sticky" width={0} zIndex={1}>
+      <Flex bottom={0} flexDirection="column" gap={1} insetEnd={0} 
position="absolute">
+        {latestNotVisible ? (
+          <IconButton
+            label={translate("grid.buttons.resetToLatest")}
+            loading={isLoading}
+            onClick={onJumpToLatest}
+            {...BUTTON_PROPS}
+          >
+            <FiChevronsRight />
+          </IconButton>
+        ) : undefined}
+        {hasNewerRuns ? (
+          <IconButton
+            label={translate("grid.buttons.newerRuns")}
+            loading={isLoading}
+            onClick={onNewerRuns}
+            {...BUTTON_PROPS}
+          >
+            <FiChevronRight />
+          </IconButton>
+        ) : undefined}
+      </Flex>
+    </Box>
+  );
+};
+
+export const OlderRunsButton = ({
+  hasOlderRuns,
+  isLoading,
+  onOlderRuns,
+  taskNameColumnWidthPx,
+}: OlderProps) => {
+  const { t: translate } = useTranslation("dag");
 
-  const newerLabel = translate("grid.buttons.newerRuns");
-  const resetLabel = translate("grid.buttons.resetToLatest");
-  const olderLabel = translate("grid.buttons.olderRuns");
+  if (!hasOlderRuns) {
+    return undefined;
+  }
 
+  // Stopping at the task name column's inner edge rather than the 
scrollport's keeps the button
+  // overlapping that column by the same few pixels as when nothing is 
scrolled.
   return (
-    <>
-      {latestNotVisible || hasNewerRuns ? (
-        <Flex bottom={0} flexDirection="column" gap={1} position="absolute" 
right={1} zIndex={1}>
-          {latestNotVisible ? (
-            <IconButton label={resetLabel} onClick={onJumpToLatest} 
{...buttonProps}>
-              <FiChevronsRight />
-            </IconButton>
-          ) : undefined}
-          {hasNewerRuns ? (
-            <IconButton label={newerLabel} onClick={onNewerRuns} 
{...buttonProps}>
-              <FiChevronRight />
-            </IconButton>
-          ) : undefined}
-        </Flex>
-      ) : undefined}
-      {hasOlderRuns ? (
-        <IconButton
-          bottom={0}
-          label={olderLabel}
-          left={-6}
-          onClick={onOlderRuns}
-          position="absolute"
-          zIndex={1}
-          {...buttonProps}
-        >
-          <FiChevronLeft />
-        </IconButton>
-      ) : undefined}
-    </>
+    <Box insetStart={`${taskNameColumnWidthPx}px`} position="sticky" width={0} 
zIndex={1}>
+      <IconButton
+        bottom={0}
+        insetStart={-6}
+        label={translate("grid.buttons.olderRuns")}
+        loading={isLoading}
+        onClick={onOlderRuns}
+        position="absolute"
+        {...BUTTON_PROPS}
+      >
+        <FiChevronLeft />
+      </IconButton>
+    </Box>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
index 3bc66094b6c..a85344f70b6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
@@ -22,6 +22,17 @@ export const ROW_HEIGHT = 20;
 /** Height of a task bar / badge within a row — matches the unmapped GridTI 
badge height. */
 export const TASK_BAR_HEIGHT_PX = 14;
 export const GRID_HEADER_PADDING_PX = 16; // pt={4} = 4 * 4 = 16px
+/** Narrowest the task name column gets; it also pins the older-runs button 
when the Grid scrolls. */
+export const TASK_NAME_COLUMN_MIN_WIDTH_PX = 200;
+/**
+ * Strip kept clear at the Grid's inline end, where whatever sits there covers 
the run columns: the
+ * vertical scrollbar, which overlay scrollbars leave inside clientWidth 
instead of outside it
+ * (#69447), and the details panel's collapse button. The spacer holds it open 
at the end of the
+ * content and the run reveal keeps out of it, so both read it from here.
+ */
+export const SCROLLBAR_SPACER_WIDTH_PX = 16;
+/** Widened to this while the pinned newer/reset buttons are shown, which 
cover more of that edge. */
+export const PINNED_RUN_BUTTONS_WIDTH_PX = 32;
 export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars
 
 // Gantt chart's x-axis height (time labels at top of chart)
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
index ab4ee39b5e6..325c9e0d969 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.test.ts
@@ -16,24 +16,61 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { RefObject } from "react";
+
 import type { Virtualizer } from "@tanstack/react-virtual";
 import { renderHook } from "@testing-library/react";
 import { describe, expect, it, vi } from "vitest";
 
-import { ROW_HEIGHT } from "./constants";
+import { PINNED_RUN_BUTTONS_WIDTH_PX, ROW_HEIGHT, SCROLLBAR_SPACER_WIDTH_PX } 
from "./constants";
 import { useGridScrollRestore } from "./useGridScrollRestore";
 import type { GridTask } from "./utils";
 
 const HEADER_PAD = 50;
+const TASK_NAME_COLUMN_WIDTH = 200;
+const VIEWPORT_WIDTH = 500;
+const RUN_COLUMN_WIDTH = 18;
 
-/** Fake scroll container exposing only what the hook touches, plus a way to 
fire a scroll event. */
-const makeScrollEl = (clientHeight: number) => {
-  const handlers: Array<() => void> = [];
+const clampToRange = (value: number, extent: number) => Math.max(0, 
Math.min(value, extent));
 
-  return {
+/**
+ * Fake scroll container exposing only what the hook touches, plus a way to 
fire a scroll event.
+ * ``runColumnLeft`` places the selected run column at that offset within the 
scrolled content; its
+ * rect then tracks ``scrollLeft`` the way a real column does.
+ */
+const makeScrollEl = ({
+  clientHeight = 100,
+  rtl = false,
+  runColumnLeft,
+  scrollHeight = 10_000,
+  scrollWidth = 10_000,
+}: {
+  clientHeight?: number;
+  rtl?: boolean;
+  runColumnLeft?: number;
+  scrollHeight?: number;
+  scrollWidth?: number;
+} = {}) => {
+  const handlers: Array<() => void> = [];
+  const scrollEl = {
     addEventListener: vi.fn((_type: string, cb: () => void) => 
handlers.push(cb)),
     clientHeight,
+    clientWidth: VIEWPORT_WIDTH,
     dispatchScroll: () => handlers.forEach((cb) => cb()),
+    getBoundingClientRect: () => ({ left: 0, right: VIEWPORT_WIDTH }) as 
DOMRect,
+    querySelector: vi.fn((selector: string) => {
+      if (runColumnLeft === undefined || !selector.includes("data-run-id")) {
+        return null;
+      }
+
+      return {
+        getBoundingClientRect: () => {
+          const left = runColumnLeft - scrollEl.scrollLeft;
+
+          return { left, right: left + RUN_COLUMN_WIDTH, width: 
RUN_COLUMN_WIDTH } as DOMRect;
+        },
+      };
+    }),
     removeEventListener: vi.fn((_type: string, cb: () => void) => {
       const index = handlers.indexOf(cb);
 
@@ -41,10 +78,41 @@ const makeScrollEl = (clientHeight: number) => {
         handlers.splice(index, 1);
       }
     }),
+    scrollHeight,
+    scrollLeft: 0,
     scrollTop: 0,
+    scrollWidth,
   };
+
+  // A real container clamps a programmatic offset to its scrollable range, 
which is what keeps a
+  // centred run near either end from overshooting. In RTL that range is 
negative, ending at 0.
+  let currentLeft = 0;
+  let currentTop = 0;
+
+  Object.defineProperties(scrollEl, {
+    scrollLeft: {
+      get: () => currentLeft,
+      set: (value: number) => {
+        const extent = scrollEl.scrollWidth - scrollEl.clientWidth;
+
+        currentLeft = rtl ? -clampToRange(-value, extent) : 
clampToRange(value, extent);
+      },
+    },
+    scrollTop: {
+      get: () => currentTop,
+      set: (value: number) => {
+        currentTop = clampToRange(value, scrollEl.scrollHeight - 
scrollEl.clientHeight);
+      },
+    },
+  });
+
+  return scrollEl;
 };
 
+/** Where centring puts a column that starts at `contentLeft`, given the 
fake's fixed geometry. */
+const centeredScrollLeft = (contentLeft: number) =>
+  contentLeft + RUN_COLUMN_WIDTH / 2 - (TASK_NAME_COLUMN_WIDTH + 
VIEWPORT_WIDTH) / 2;
+
 const makeVirtualizer = (scrollElement: ReturnType<typeof makeScrollEl> | 
null) => {
   const scrollToIndex = vi.fn();
 
@@ -54,143 +122,530 @@ const makeVirtualizer = (scrollElement: 
ReturnType<typeof makeScrollEl> | null)
   };
 };
 
+const taskNameColumnRef = {
+  current: { getBoundingClientRect: () => ({ width: TASK_NAME_COLUMN_WIDTH }) 
},
+} as unknown as RefObject<HTMLDivElement | null>;
+
 const makeTask = (id: string): GridTask => ({ id }) as unknown as GridTask;
 const makeTasks = (count: number) => Array.from({ length: count }, (_, index) 
=> makeTask(`task_${index}`));
 
-describe("useGridScrollRestore", () => {
-  it("does nothing when there is no scroll element yet", () => {
-    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(null);
+type HookArgs = Parameters<typeof useGridScrollRestore>[0];
+type Defaulted = "headerPad" | "isRtl" | "reservedEndWidth" | "sharesScroller" 
| "taskNameColumnRef";
+type RestoreArgs = Omit<HookArgs, Defaulted> & Partial<Pick<HookArgs, 
Defaulted>>;
 
-    renderHook(() =>
+const renderRestore = (args: RestoreArgs) =>
+  renderHook(
+    (props: RestoreArgs) =>
       useGridScrollRestore({
-        dagId: "dag_no_el",
-        flatNodes: makeTasks(3),
         headerPad: HEADER_PAD,
-        rowVirtualizer,
-        selectedTaskId: "task_2",
+        isRtl: false,
+        reservedEndWidth: 0,
+        sharesScroller: false,
+        taskNameColumnRef,
+        ...props,
       }),
-    );
+    { initialProps: args },
+  );
+
+describe("useGridScrollRestore", () => {
+  it("does nothing when there is no scroll element yet", () => {
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(null);
+
+    renderRestore({
+      dagId: "dag_no_el",
+      flatNodes: makeTasks(3),
+      rowVirtualizer,
+      runIds: [],
+      selectedTaskId: "task_2",
+    });
 
     expect(scrollToIndex).not.toHaveBeenCalled();
   });
 
   it("leaves the scroll position untouched on the first visit to a Dag", () => 
{
-    const scrollEl = makeScrollEl(100);
+    const scrollEl = makeScrollEl();
     const { rowVirtualizer } = makeVirtualizer(scrollEl);
 
-    renderHook(() =>
-      useGridScrollRestore({ dagId: "dag_unseen", flatNodes: [], headerPad: 
HEADER_PAD, rowVirtualizer }),
-    );
+    renderRestore({ dagId: "dag_unseen", flatNodes: [], rowVirtualizer, 
runIds: [] });
 
     expect(scrollEl.scrollTop).toBe(0);
+    expect(scrollEl.scrollLeft).toBe(0);
   });
 
-  it("saves the position on scroll and restores it after a remount", () => {
-    const first = makeScrollEl(100);
+  it("saves both offsets on scroll and restores them after a remount", () => {
+    const first = makeScrollEl();
     const before = makeVirtualizer(first);
-    const { unmount } = renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_restore",
-        flatNodes: [],
-        headerPad: HEADER_PAD,
-        rowVirtualizer: before.rowVirtualizer,
-      }),
-    );
+    const { unmount } = renderRestore({
+      dagId: "dag_restore",
+      flatNodes: [],
+      rowVirtualizer: before.rowVirtualizer,
+      runIds: [],
+    });
 
     first.scrollTop = 240;
+    first.scrollLeft = 360;
     first.dispatchScroll();
     unmount();
 
-    const second = makeScrollEl(100);
+    const second = makeScrollEl();
     const after = makeVirtualizer(second);
 
-    renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_restore",
-        flatNodes: [],
-        headerPad: HEADER_PAD,
-        rowVirtualizer: after.rowVirtualizer,
-      }),
-    );
+    renderRestore({
+      dagId: "dag_restore",
+      flatNodes: [],
+      rowVirtualizer: after.rowVirtualizer,
+      runIds: [],
+    });
 
     expect(second.scrollTop).toBe(240);
+    expect(second.scrollLeft).toBe(360);
+  });
+
+  it("keeps the saved offsets when an axis cannot scroll yet", () => {
+    const scrollEl = makeScrollEl();
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+    const { unmount } = renderRestore({
+      dagId: "dag_unscrollable",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: [],
+    });
+
+    scrollEl.scrollLeft = 180;
+    scrollEl.dispatchScroll();
+
+    // The runs have gone away, so the browser clamps the offset to 0 and 
fires a scroll event.
+    scrollEl.scrollWidth = VIEWPORT_WIDTH;
+    scrollEl.scrollLeft = 0;
+    scrollEl.dispatchScroll();
+    unmount();
+
+    const remounted = makeScrollEl();
+    const after = makeVirtualizer(remounted);
+
+    renderRestore({
+      dagId: "dag_unscrollable",
+      flatNodes: [],
+      rowVirtualizer: after.rowVirtualizer,
+      runIds: [],
+    });
+
+    expect(remounted.scrollLeft).toBe(180);
   });
 
   it("centers the selected task when the structure changed and it is out of 
view", () => {
-    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const scrollEl = makeScrollEl({ clientHeight: HEADER_PAD + 2 * ROW_HEIGHT 
});
     const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
 
-    renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_center",
-        flatNodes: makeTasks(20),
-        headerPad: HEADER_PAD,
-        rowVirtualizer,
-        selectedTaskId: "task_10",
-      }),
-    );
+    renderRestore({
+      dagId: "dag_center",
+      flatNodes: makeTasks(20),
+      rowVirtualizer,
+      runIds: [],
+      selectedTaskId: "task_10",
+    });
 
     expect(scrollToIndex).toHaveBeenCalledWith(10, { align: "center" });
   });
 
   it("centers a selected group when no task is selected", () => {
-    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const scrollEl = makeScrollEl({ clientHeight: HEADER_PAD + 2 * ROW_HEIGHT 
});
     const flatNodes = [...makeTasks(15), makeTask("group_x")];
     const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
 
-    renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_group",
-        flatNodes,
-        headerPad: HEADER_PAD,
-        rowVirtualizer,
-        selectedGroupId: "group_x",
-      }),
-    );
+    renderRestore({
+      dagId: "dag_group",
+      flatNodes,
+      rowVirtualizer,
+      runIds: [],
+      selectedGroupId: "group_x",
+    });
 
     expect(scrollToIndex).toHaveBeenCalledWith(15, { align: "center" });
   });
 
   it("does not scroll when the selected task is already visible", () => {
-    const scrollEl = makeScrollEl(HEADER_PAD + 4 * ROW_HEIGHT);
+    const scrollEl = makeScrollEl({ clientHeight: HEADER_PAD + 4 * ROW_HEIGHT 
});
     const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
 
-    renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_visible",
-        flatNodes: makeTasks(20),
-        headerPad: HEADER_PAD,
-        rowVirtualizer,
-        selectedTaskId: "task_1",
-      }),
-    );
+    renderRestore({
+      dagId: "dag_visible",
+      flatNodes: makeTasks(20),
+      rowVirtualizer,
+      runIds: [],
+      selectedTaskId: "task_1",
+    });
 
     expect(scrollToIndex).not.toHaveBeenCalled();
   });
 
   it("does nothing when the selected id is not among the rows", () => {
-    const scrollEl = makeScrollEl(HEADER_PAD + 2 * ROW_HEIGHT);
+    const scrollEl = makeScrollEl({ clientHeight: HEADER_PAD + 2 * ROW_HEIGHT 
});
     const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
 
-    renderHook(() =>
-      useGridScrollRestore({
-        dagId: "dag_missing",
-        flatNodes: makeTasks(20),
-        headerPad: HEADER_PAD,
-        rowVirtualizer,
-        selectedTaskId: "task_missing",
-      }),
-    );
+    renderRestore({
+      dagId: "dag_missing",
+      flatNodes: makeTasks(20),
+      rowVirtualizer,
+      runIds: [],
+      selectedTaskId: "task_missing",
+    });
 
     expect(scrollToIndex).not.toHaveBeenCalled();
   });
 
+  // "Behind the names" is the case a bare left >= 0 check would call visible 
and leave there: the
+  // column is inside the scrollport but under the sticky task name column.
+  it.each([
+    { dagId: "dag_run_right", from: 0, label: "past the right edge", 
runColumnLeft: 800 },
+    { dagId: "dag_run_behind_names", from: 550, label: "behind the names", 
runColumnLeft: 600 },
+  ])("centers a run sitting $label on a fresh visit", ({ dagId, from, 
runColumnLeft }) => {
+    const scrollEl = makeScrollEl({ runColumnLeft });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    scrollEl.scrollLeft = from;
+
+    renderRestore({ dagId, flatNodes: [], rowVirtualizer, runIds: ["run_1"], 
selectedRunId: "run_1" });
+
+    expect(scrollEl.scrollLeft).toBe(centeredScrollLeft(runColumnLeft));
+  });
+
+  it("nudges rather than centers the selected run once a position has been 
saved", () => {
+    const first = makeScrollEl({ runColumnLeft: 800 });
+    const before = makeVirtualizer(first);
+    const { unmount } = renderRestore({
+      dagId: "dag_run_nudge",
+      flatNodes: [],
+      rowVirtualizer: before.rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    first.dispatchScroll();
+    unmount();
+
+    // Remounted with the run drifted left of the restored offset, as new runs 
push the window along.
+    const second = makeScrollEl({ runColumnLeft: 490 });
+    const after = makeVirtualizer(second);
+
+    renderRestore({
+      dagId: "dag_run_nudge",
+      flatNodes: [],
+      rowVirtualizer: after.rowVirtualizer,
+      runIds: ["run_1", "run_2"],
+      selectedRunId: "run_1",
+    });
+
+    expect(second.scrollLeft).toBe(490 - TASK_NAME_COLUMN_WIDTH);
+    expect(second.scrollLeft).not.toBe(centeredScrollLeft(490));
+  });
+
+  // The buttons float over the inline end of the scrollport, which is exactly 
where `nearest` parks
+  // a run that was cut off there. RTL moves them to the left edge along with 
the scrollbar.
+  // Flush against the scrollport leaves the column under whatever is pinned 
there: a click on its
+  // bar hits "Reset to latest", or an overlay scrollbar covers it, since 
those sit inside
+  // clientWidth rather than outside it. RTL moves that edge, and the reserve, 
to the left.
+  it.each([
+    {
+      dagId: "dag_reserve_pager",
+      isRtl: false,
+      label: "the pager buttons",
+      reservedEndWidth: PINNED_RUN_BUTTONS_WIDTH_PX,
+      runColumnLeft: 800,
+    },
+    {
+      dagId: "dag_reserve_rtl_scrollbar",
+      isRtl: true,
+      label: "an RTL overlay scrollbar",
+      reservedEndWidth: SCROLLBAR_SPACER_WIDTH_PX,
+      runColumnLeft: -50,
+    },
+  ])("stops a nudged run short of $label", ({ dagId, isRtl, reservedEndWidth, 
runColumnLeft }) => {
+    const scrollEl = makeScrollEl({ rtl: isRtl, runColumnLeft });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+    const args = {
+      dagId,
+      flatNodes: makeTasks(5),
+      isRtl,
+      reservedEndWidth,
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+      selectedTaskId: "task_0",
+    };
+    const rendered = renderRestore(args);
+
+    // A scroll of the user's own, so the reveal below nudges instead of 
centring.
+    scrollEl.scrollLeft = 0;
+    scrollEl.dispatchScroll();
+
+    rendered.rerender({ ...args, selectedTaskId: "task_1" });
+
+    const columnLeft = runColumnLeft - scrollEl.scrollLeft;
+
+    expect(isRtl ? columnLeft : VIEWPORT_WIDTH - (columnLeft + 
RUN_COLUMN_WIDTH)).toBe(reservedEndWidth);
+  });
+
+  it("leaves the scroll position alone when the selected run is already 
visible", () => {
+    const scrollEl = makeScrollEl({ runColumnLeft: 300 });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    renderRestore({
+      dagId: "dag_run_visible",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    expect(scrollEl.scrollLeft).toBe(0);
+  });
+
+  it("does not scroll horizontally when the selected run has no column", () => 
{
+    const scrollEl = makeScrollEl();
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    renderRestore({
+      dagId: "dag_run_absent",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    expect(scrollEl.scrollLeft).toBe(0);
+  });
+
+  it("centers the selected run when only a vertical position was saved", () => 
{
+    // A deep link to a task scrolls the Grid down before the runs have 
arrived. That scroll must
+    // not count as a horizontal position, or the run is merely nudged to the 
edge afterwards.
+    const beforeRuns = makeScrollEl({ runColumnLeft: 800, scrollWidth: 
VIEWPORT_WIDTH });
+    const before = makeVirtualizer(beforeRuns);
+    const { unmount } = renderRestore({
+      dagId: "dag_vertical_only",
+      flatNodes: [],
+      rowVirtualizer: before.rowVirtualizer,
+      runIds: [],
+      selectedRunId: "run_1",
+    });
+
+    beforeRuns.scrollTop = 400;
+    beforeRuns.dispatchScroll();
+    unmount();
+
+    const withRuns = makeScrollEl({ runColumnLeft: 800 });
+    const after = makeVirtualizer(withRuns);
+
+    renderRestore({
+      dagId: "dag_vertical_only",
+      flatNodes: [],
+      rowVirtualizer: after.rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    expect(withRuns.scrollLeft).toBe(centeredScrollLeft(800));
+    expect(withRuns.scrollTop).toBe(400);
+  });
+
+  it("leaves the horizontal axis alone when the scroller is shared with the 
Gantt", () => {
+    const scrollEl = makeScrollEl({ runColumnLeft: 800 });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    renderRestore({
+      dagId: "dag_shared_scroller",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+      sharesScroller: true,
+    });
+
+    expect(scrollEl.scrollLeft).toBe(0);
+  });
+
+  it("does not move the Grid when a refresh brings the same runs", () => {
+    const scrollEl = makeScrollEl({ runColumnLeft: 800 });
+    const virtualizer = makeVirtualizer(scrollEl);
+    const args = {
+      dagId: "dag_same_runs",
+      flatNodes: [],
+      rowVirtualizer: virtualizer.rowVirtualizer,
+      runIds: ["run_1", "run_2"],
+      selectedRunId: "run_1",
+    };
+    const rendered = renderRestore(args);
+
+    scrollEl.dispatchScroll();
+    scrollEl.scrollLeft = 120;
+    scrollEl.dispatchScroll();
+
+    // The poll rebuilds the array every render; only the ids in it should 
matter.
+    rendered.rerender({ ...args, runIds: ["run_1", "run_2"] });
+
+    expect(scrollEl.scrollLeft).toBe(120);
+  });
+
+  it("does not pull the Grid back to the selection when the run list changes", 
() => {
+    const scrollEl = makeScrollEl({ runColumnLeft: 800 });
+    const { rowVirtualizer, scrollToIndex } = makeVirtualizer(scrollEl);
+    const args = {
+      dagId: "dag_new_runs",
+      flatNodes: makeTasks(50),
+      rowVirtualizer,
+      runIds: ["run_1", "run_2"],
+      selectedRunId: "run_1",
+      selectedTaskId: "task_40",
+    };
+    const rendered = renderRestore(args);
+
+    expect(scrollToIndex).toHaveBeenCalledTimes(1);
+
+    // The user scrolls away from the selected cell on both axes.
+    scrollEl.scrollLeft = 0;
+    scrollEl.scrollTop = 0;
+    scrollEl.dispatchScroll();
+
+    // A poll brings a new run, then the user pages to older ones.
+    rendered.rerender({ ...args, runIds: ["run_0", "run_1", "run_2"] });
+    rendered.rerender({ ...args, runIds: ["run_3", "run_4"] });
+
+    expect(scrollEl.scrollLeft).toBe(0);
+    expect(scrollToIndex).toHaveBeenCalledTimes(1);
+
+    // Selecting something else is a reason to reveal again.
+    rendered.rerender({
+      ...args,
+      runIds: ["run_3", "run_4"],
+      selectedRunId: "run_4",
+      selectedTaskId: "task_45",
+    });
+
+    expect(scrollToIndex).toHaveBeenCalledTimes(2);
+    // Nudged rather than centred, since the user's scroll saved a horizontal 
position.
+    expect(scrollEl.scrollLeft).toBe(800 + RUN_COLUMN_WIDTH - VIEWPORT_WIDTH);
+  });
+
+  it("reveals the run again when another task in the same run is selected", () 
=> {
+    const scrollEl = makeScrollEl({ runColumnLeft: 800 });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+    const args = {
+      dagId: "dag_same_run_other_task",
+      flatNodes: makeTasks(5),
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+      selectedTaskId: "task_0",
+    };
+    const rendered = renderRestore(args);
+
+    scrollEl.scrollLeft = 0;
+    scrollEl.dispatchScroll();
+
+    // Picked from the details panel while the run's column is scrolled out of 
view.
+    rendered.rerender({ ...args, selectedTaskId: "task_1" });
+
+    expect(scrollEl.scrollLeft).toBe(800 + RUN_COLUMN_WIDTH - VIEWPORT_WIDTH);
+  });
+
+  it("reveals the selected run once its column renders after the first paint", 
() => {
+    const scrollEl = makeScrollEl({ runColumnLeft: 800 });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+    const findColumn = scrollEl.querySelector.getMockImplementation();
+
+    // The runs query has not answered yet, so the column is not in the DOM.
+    scrollEl.querySelector.mockImplementation(() => null);
+    const args = {
+      dagId: "dag_runs_late",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: [],
+      selectedRunId: "run_1",
+    };
+    const rendered = renderRestore(args);
+
+    expect(scrollEl.scrollLeft).toBe(0);
+
+    if (findColumn) {
+      scrollEl.querySelector.mockImplementation(findColumn);
+    }
+    rendered.rerender({ ...args, runIds: ["run_1"] });
+
+    expect(scrollEl.scrollLeft).toBe(centeredScrollLeft(800));
+  });
+
+  it("in RTL, counts a run behind the task name column on the right as 
hidden", () => {
+    // RTL pins the task name column to the right edge, so the runs are 
visible on [0, 300].
+    const scrollEl = makeScrollEl({ rtl: true, runColumnLeft: -50 });
+    const { rowVirtualizer } = makeVirtualizer(scrollEl);
+
+    // Scrolled so the run sits at [350, 368]: inside the scrollport, but 
under the names.
+    scrollEl.scrollLeft = -400;
+
+    renderRestore({
+      dagId: "dag_rtl_behind_names",
+      flatNodes: [],
+      isRtl: true,
+      rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    // Centred on the visible part: the column's midpoint lands on 150.
+    const columnLeft = -50 - scrollEl.scrollLeft;
+
+    expect(columnLeft + RUN_COLUMN_WIDTH / 2).toBe((VIEWPORT_WIDTH - 
TASK_NAME_COLUMN_WIDTH) / 2);
+  });
+
+  it("keeps each Dag's position to itself", () => {
+    const dagA = makeScrollEl({ runColumnLeft: 800 });
+    const firstA = makeVirtualizer(dagA);
+    const visitA = renderRestore({
+      dagId: "dag_a",
+      flatNodes: [],
+      rowVirtualizer: firstA.rowVirtualizer,
+      runIds: ["run_1"],
+    });
+
+    dagA.scrollLeft = 300;
+    dagA.dispatchScroll();
+    visitA.unmount();
+
+    const dagB = makeScrollEl({ runColumnLeft: 800 });
+    const firstB = makeVirtualizer(dagB);
+    const visitB = renderRestore({
+      dagId: "dag_b",
+      flatNodes: [],
+      rowVirtualizer: firstB.rowVirtualizer,
+      runIds: ["run_1"],
+      selectedRunId: "run_1",
+    });
+
+    expect(dagB.scrollLeft).toBe(centeredScrollLeft(800));
+    visitB.unmount();
+
+    const backToA = makeScrollEl({ runColumnLeft: 800 });
+    const secondA = makeVirtualizer(backToA);
+
+    renderRestore({
+      dagId: "dag_a",
+      flatNodes: [],
+      rowVirtualizer: secondA.rowVirtualizer,
+      runIds: ["run_1"],
+    });
+
+    expect(backToA.scrollLeft).toBe(300);
+  });
+
   it("removes the scroll listener on unmount", () => {
-    const scrollEl = makeScrollEl(100);
+    const scrollEl = makeScrollEl();
     const { rowVirtualizer } = makeVirtualizer(scrollEl);
-    const { unmount } = renderHook(() =>
-      useGridScrollRestore({ dagId: "dag_cleanup", flatNodes: [], headerPad: 
HEADER_PAD, rowVirtualizer }),
-    );
+    const { unmount } = renderRestore({
+      dagId: "dag_cleanup",
+      flatNodes: [],
+      rowVirtualizer,
+      runIds: [],
+    });
 
     expect(scrollEl.addEventListener).toHaveBeenCalledWith("scroll", 
expect.any(Function), { passive: true });
 
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts
index 00a59eee642..607a0781976 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridScrollRestore.ts
@@ -16,42 +16,136 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useLayoutEffect } from "react";
+import type { RefObject } from "react";
+import { useLayoutEffect, useRef } from "react";
 
 import type { Virtualizer } from "@tanstack/react-virtual";
 
 import { ROW_HEIGHT } from "./constants";
 import type { GridTask } from "./utils";
 
-// Last Grid scrollTop per Dag. Module scope so it outlives the Grid 
remounting.
-const gridScrollTops = new Map<string, number>();
+// Each axis is absent until it has actually been scrollable: a Dag whose runs 
have not arrived
+// reports 0 for an axis that cannot move, and storing that 0 would read back 
as a real position.
+type ScrollPosition = {
+  left?: number;
+  top?: number;
+};
+
+// Last Grid scroll offsets per Dag. Module scope so they outlive the Grid 
remounting.
+const gridScrollPositions = new Map<string, ScrollPosition>();
 
 type Params = {
   dagId: string;
   flatNodes: Array<GridTask>;
   // Height of the sticky header above the rows (also the virtualizer's 
scrollPaddingStart).
   headerPad: number;
+  isRtl: boolean;
+  // Width kept clear at the inline end for the scrollbar and anything pinned 
over that edge.
+  reservedEndWidth: number;
   rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
+  runIds: Array<string>;
   selectedGroupId?: string;
+  selectedRunId?: string;
   selectedTaskId?: string;
+  // Gantt view shares one scroller with the Grid, where the horizontal axis 
is the Gantt's timeline.
+  sharesScroller: boolean;
+  taskNameColumnRef: RefObject<HTMLDivElement | null>;
+};
+
+// Horizontal counterpart to the virtualizer's scrollToIndex, with the same 
meaning of `align`.
+//
+// Run columns scroll beneath the sticky task name column, so a column tucked 
behind it is as hidden
+// as one past the far edge. The visible scrollport is clientWidth wide 
(unlike the bounding rect, it
+// excludes the vertical scrollbar), minus that column: on the left in LTR, on 
the right in RTL, where
+// the scrollbar also moves to the left. The opposite end gives up its 
reserved strip for the same
+// reason: the scrollbar and the buttons pinned there cover the run columns, 
and `nearest` would
+// otherwise park a run right underneath them. Returns whether the run's 
column was found.
+const scrollRunIntoView = ({
+  align,
+  isRtl,
+  reservedEndWidth,
+  scrollEl,
+  selectedRunId,
+  taskNameColumn,
+}: {
+  align: "center" | "nearest";
+  isRtl: boolean;
+  reservedEndWidth: number;
+  scrollEl: HTMLElement;
+  selectedRunId: string | undefined;
+  taskNameColumn: HTMLDivElement | null;
+}) => {
+  if (selectedRunId === undefined) {
+    return false;
+  }
+
+  // Both the duration bar and the task instance column carry the run id; 
either one gives the
+  // column's x position, so take whichever the query finds first.
+  const column = 
scrollEl.querySelector(`[data-run-id="${CSS.escape(selectedRunId)}"]`);
+
+  if (!column) {
+    return false;
+  }
+
+  const columnRect = column.getBoundingClientRect();
+  const viewRect = scrollEl.getBoundingClientRect();
+  const nameColumnWidth = taskNameColumn?.getBoundingClientRect().width ?? 0;
+  const visibleStart = isRtl
+    ? viewRect.right - scrollEl.clientWidth + reservedEndWidth
+    : viewRect.left + nameColumnWidth;
+  const visibleEnd = isRtl
+    ? viewRect.right - nameColumnWidth
+    : viewRect.left + scrollEl.clientWidth - reservedEndWidth;
+
+  if (columnRect.left >= visibleStart && columnRect.right <= visibleEnd) {
+    return true;
+  }
+
+  // Nearest corrects whichever edge is cut off; centre puts the column's 
midpoint on the view's.
+  const nearestDelta =
+    columnRect.left < visibleStart ? columnRect.left - visibleStart : 
columnRect.right - visibleEnd;
+  const centerDelta = columnRect.left + columnRect.width / 2 - (visibleStart + 
visibleEnd) / 2;
+  const delta = align === "center" ? centerDelta : nearestDelta;
+
+  // A delta in CSS pixels is direction-agnostic: scrollLeft grows rightwards 
even in RTL, where it
+  // runs from -(scrollWidth - clientWidth) to 0. The browser clamps an 
overshoot at either end, so a
+  // run near an edge simply lands as close as it can.
+  scrollEl.scrollLeft += delta;
+
+  return true;
 };
 
-// Navigating into a task swaps the route (Dag/Run/TaskInstance each render 
their own Grid), so the
-// Grid unmounts and a fresh one mounts scrolled to the top. We restore the 
last position here so a
-// click doesn't jump the Grid: the rows are unchanged across the remount, so 
the saved pixel offset
-// still points at the same rows.
+// Navigating into a run or task swaps the route (Dag/Run/TaskInstance each 
render their own Grid),
+// so the Grid unmounts and a fresh one mounts scrolled back to the top left. 
Restoring the saved
+// offsets stops a click from jumping the Grid.
 //
-// If the rows did change since we saved (a new version, or groups 
expanded/collapsed) the saved
-// offset no longer lines up, so we fall back to locating the selected task by 
id and centering it.
-// Rows are a fixed height, so the index alone gives the offset — no 
dependency on the stored pixels.
+// If the content changed since we saved (a new version, groups 
expanded/collapsed, runs added or
+// paged) the offsets no longer line up, so we fall back to revealing whatever 
is selected: the task
+// by index, since fixed-height rows make the index alone give the offset, and 
the run by measuring
+// its column.
 export const useGridScrollRestore = ({
   dagId,
   flatNodes,
   headerPad,
+  isRtl,
+  reservedEndWidth,
   rowVirtualizer,
+  runIds,
   selectedGroupId,
+  selectedRunId,
   selectedTaskId,
+  sharesScroller,
+  taskNameColumnRef,
 }: Params) => {
+  // Value dep: the runs array is rebuilt on every render, so its identity 
would re-run the effect
+  // (and re-measure the DOM) far more often than the columns actually change.
+  const runIdsKey = runIds.join("\u0000");
+
+  // What the reveal below last settled on. The run list changes in place 
(polling brings a new run,
+  // the user pages), and re-revealing then would yank the Grid back to the 
selection after the user
+  // scrolled away from it. A remount starts empty, which is the case the 
reveal exists for.
+  const revealed = useRef<{ anchorId?: string; cell?: string; flatNodes?: 
Array<GridTask> }>({});
+
   useLayoutEffect(() => {
     const scrollEl = rowVirtualizer.scrollElement;
 
@@ -59,16 +153,25 @@ export const useGridScrollRestore = ({
       return undefined;
     }
 
-    const saved = gridScrollTops.get(dagId);
+    const saved = gridScrollPositions.get(dagId);
 
-    if (saved !== undefined) {
-      scrollEl.scrollTop = saved;
+    if (saved?.top !== undefined) {
+      scrollEl.scrollTop = saved.top;
+    }
+
+    if (saved?.left !== undefined && !sharesScroller) {
+      scrollEl.scrollLeft = saved.left;
     }
 
     const anchorId = selectedTaskId ?? selectedGroupId;
     const index = flatNodes.findIndex((node) => node.id === anchorId);
 
-    if (index !== -1) {
+    // Rows only move when the structure does (a new version, groups toggled), 
so the task is
+    // re-revealed then or when the selection changes, never because the runs 
did.
+    if (
+      index !== -1 &&
+      (revealed.current.anchorId !== anchorId || revealed.current.flatNodes 
!== flatNodes)
+    ) {
       const rowTop = headerPad + index * ROW_HEIGHT;
       const outOfView =
         rowTop < scrollEl.scrollTop || rowTop + ROW_HEIGHT > 
scrollEl.scrollTop + scrollEl.clientHeight;
@@ -76,12 +179,60 @@ export const useGridScrollRestore = ({
       if (outOfView) {
         rowVirtualizer.scrollToIndex(index, { align: "center" });
       }
+      revealed.current = { ...revealed.current, anchorId, flatNodes };
     }
 
-    const handleScroll = () => gridScrollTops.set(dagId, scrollEl.scrollTop);
+    // Sharing a scroller means moving it horizontally would drag the Gantt's 
timeline along with
+    // the run columns, so the Grid leaves that axis to the Gantt. The run is 
revealed once per
+    // selected cell, as soon as its column has rendered: runs arrive after 
the first paint, which is
+    // why the run ids are a dependency at all. Picking another task in the 
same run (from the details
+    // panel, while the Grid is scrolled away) is a new selection, so it 
reveals the run too.
+    const cell = `${selectedRunId ?? ""}\u0000${anchorId ?? ""}`;
+
+    if (!sharesScroller && revealed.current.cell !== cell) {
+      const found = scrollRunIntoView({
+        // Centre on a first visit, where the run lands somewhere readable 
rather than flush against
+        // an edge; once there is a position worth keeping, move as little as 
possible. A saved
+        // vertical offset says nothing about the runs, so only a horizontal 
one counts as one.
+        align: saved?.left === undefined ? "center" : "nearest",
+        isRtl,
+        reservedEndWidth,
+        scrollEl,
+        selectedRunId,
+        taskNameColumn: taskNameColumnRef.current,
+      });
+
+      if (found || selectedRunId === undefined) {
+        revealed.current = { ...revealed.current, cell };
+      }
+    }
+
+    const handleScroll = () => {
+      const previous = gridScrollPositions.get(dagId);
+      const canScrollX = scrollEl.scrollWidth > scrollEl.clientWidth;
+      const canScrollY = scrollEl.scrollHeight > scrollEl.clientHeight;
+
+      gridScrollPositions.set(dagId, {
+        left: canScrollX ? scrollEl.scrollLeft : previous?.left,
+        top: canScrollY ? scrollEl.scrollTop : previous?.top,
+      });
+    };
 
     scrollEl.addEventListener("scroll", handleScroll, { passive: true });
 
     return () => scrollEl.removeEventListener("scroll", handleScroll);
-  }, [dagId, selectedTaskId, selectedGroupId, flatNodes, headerPad, 
rowVirtualizer]);
+  }, [
+    dagId,
+    flatNodes,
+    headerPad,
+    isRtl,
+    reservedEndWidth,
+    rowVirtualizer,
+    runIdsKey,
+    selectedGroupId,
+    selectedRunId,
+    selectedTaskId,
+    sharesScroller,
+    taskNameColumnRef,
+  ]);
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
index 6625be0d1c0..711c7c7abd5 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
@@ -18,15 +18,14 @@
  */
 import type { GridNodeResponse, NodeResponse } from 
"openapi/requests/types.gen";
 
+import { TASK_NAME_COLUMN_MIN_WIDTH_PX } from "./constants";
+
 export type GridTask = {
   depth: number;
   isGroup?: boolean;
   isOpen?: boolean;
 } & GridNodeResponse;
 
-/** Minimum width for the task-name column (matches prior `minWidth="200px"`). 
*/
-const TASK_NAME_COLUMN_MIN_WIDTH_PX = 200;
-
 /**
  * Chakra `rem` is typically 16px. Must match TaskNames `indent(depth)`:
  * `(depth * 0.75 + 0.5)rem`.

Reply via email to