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 7a6acca54be Allow selecting several task instances in the Grid view 
(#75221)
7a6acca54be is described below

commit 7a6acca54be09a6b95eaf72513df46e3f2d0db91
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Oct 29 20:00:00 2026 +0100

    Allow selecting several task instances in the Grid view (#75221)
    
    * Allow selecting several task instances in the Grid view
    
    Clearing or marking task instances spread over several runs meant opening
    each one from the Grid, or rebuilding the same set by hand in the Task
    Instances list. Ctrl/Cmd + click and Shift + click now select cells
    directly in the Grid, and the selection uses the bulk actions the Task
    Instances list already has.
    
    closes: #33448
    
    * Keep Grid multi-selection actions unambiguous
    
    The Ctrl/Cmd and Shift clicks were only discoverable through the docs, so 
they are now listed in the keyboard shortcuts help, along with the cells that 
cannot be selected.
    
    The Clear dialog started from the options saved in the settings (downstream 
by default), so clearing a selection also cleared task instances that were not 
selected. Grid selections now start with no option checked.
    
    The details panel kept offering Clear and Mark as for the single opened 
task instance or run while a selection was in progress, which was easy to 
mistake for the bulk actions. They are hidden until the selection is cleared.
    
    * Fix docs spelling and Clear dialog default-options test
    
    The default-options test clicked confirm before the dialog had loaded the
    run and Dag details, which main now waits for, and pushed the dialog test
    file past the line limit once merged with main. It now checks the first
    dry run in a file of its own, so it does not depend on that gate.
    
    * Keep the saved Clear options for a Grid selection
    
    Starting the Grid's bulk Clear with no option checked made it behave
    unlike every other Clear in the UI, which all start from the options the
    user saved in the settings. The same choice there is less surprising.
---
 airflow-core/docs/ui.rst                           |   5 +
 .../airflow/ui/public/i18n/locales/en/common.json  |   2 +
 .../ui/src/context/gridSelection/Context.ts        |  56 +++++
 .../gridSelection/GridSelectionProvider.tsx        |  32 +++
 .../airflow/ui/src/context/gridSelection/index.ts  |  21 ++
 .../ui/src/context/keyboardShortcuts/shortcuts.ts  |  10 +
 .../src/airflow/ui/src/layouts/BaseLayout.tsx      |   3 +-
 .../ui/src/layouts/Details/Grid/Grid.test.tsx      |  17 +-
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   |  30 ++-
 .../Details/Grid/GridSelectionActionBar.tsx        |  81 +++++++
 .../ui/src/layouts/Details/Grid/GridTI.test.tsx    |  19 ++
 .../airflow/ui/src/layouts/Details/Grid/GridTI.tsx |   9 +-
 .../layouts/Details/Grid/TaskInstancesColumn.tsx   |  15 +-
 .../Grid/useGridSelectedTaskInstances.test.tsx     |  97 ++++++++
 .../Details/Grid/useGridSelectedTaskInstances.ts   |  77 +++++++
 .../layouts/Details/Grid/useGridSelection.test.tsx | 251 +++++++++++++++++++++
 .../src/layouts/Details/Grid/useGridSelection.ts   | 198 ++++++++++++++++
 .../ui/src/pages/GroupTaskInstance/Header.tsx      |  26 ++-
 .../ui/src/pages/MappedTaskInstance/Header.tsx     |  23 +-
 .../src/airflow/ui/src/pages/Run/Header.tsx        |  13 +-
 .../ui/src/pages/TaskInstance/Header.test.tsx      |  21 ++
 .../airflow/ui/src/pages/TaskInstance/Header.tsx   |  28 ++-
 22 files changed, 993 insertions(+), 41 deletions(-)

diff --git a/airflow-core/docs/ui.rst b/airflow-core/docs/ui.rst
index 4c522b96f33..4cad0fa691f 100644
--- a/airflow-core/docs/ui.rst
+++ b/airflow-core/docs/ui.rst
@@ -167,6 +167,11 @@ Use Grid View to:
 - **Understand the status of recent Dag runs** at a glance
 - **Identify failed or retried tasks** by color and tooltip
 - **Take action** by clicking a task cell to view logs or mark task instances 
as successful, failed, or cleared
+- **Act on several task instances at once**: Ctrl + click (Cmd + click on 
macOS) adds or removes a cell, and
+  Shift + click selects every cell between the last one picked and the clicked 
one. The selected task instances can
+  then be cleared, marked, or deleted together, and the actions of the details 
panel are hidden meanwhile. A mapped
+  task's cell stands for all of its mapped task instances. Task groups and 
tasks inside a loop cannot be selected
+  this way.
 - **Filter tasks** by name or partial ID
 - **Select a run range**, like "last 25 runs" using the dropdown above the grid
 
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index c12d426d6a6..f630664af4c 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -381,10 +381,12 @@
       "scrollBottom": "Scroll to bottom",
       "scrollTop": "Scroll to top",
       "searchDags": "Search $t(dag_other)",
+      "selectGridCellRange": "Select a range of $t(taskInstance_other) (not 
$t(taskGroup_other) or loop tasks)",
       "showHelp": "Show $t(shortcuts.title)",
       "toggleExpand": "Expand or collapse all groups",
       "toggleFullscreen": "Toggle fullscreen",
       "toggleGraphGrid": "Toggle graph / grid view",
+      "toggleGridCellSelection": "Add or remove a $t(taskInstance_one) from 
the selection (not $t(taskGroup_other) or loop tasks)",
       "toggleLogLevel": "Toggle log levels",
       "toggleSource": "Toggle source",
       "toggleTaskGroup": "Expand or collapse $t(taskGroup_one)",
diff --git a/airflow-core/src/airflow/ui/src/context/gridSelection/Context.ts 
b/airflow-core/src/airflow/ui/src/context/gridSelection/Context.ts
new file mode 100644
index 00000000000..0743f72a30a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/gridSelection/Context.ts
@@ -0,0 +1,56 @@
+/*!
+ * 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 { createContext, type Dispatch, type SetStateAction, useContext } from 
"react";
+
+import { useParams } from "react-router-dom";
+
+export type GridCell = {
+  readonly runId: string;
+  readonly taskId: string;
+};
+
+export type GridSelection = ReadonlyMap<string, GridCell>;
+
+// A selection belongs to the Dag it was made in, so opening another Dag 
starts from an empty one.
+export type GridSelectionState = { readonly cells: GridSelection; readonly 
dagId: string };
+
+export type GridSelectionContextType = readonly [
+  GridSelectionState,
+  Dispatch<SetStateAction<GridSelectionState>>,
+];
+
+export const GridSelectionContext = createContext<GridSelectionContextType | 
undefined>(undefined);
+
+export const useGridSelectionState = (): GridSelectionContextType => {
+  const context = useContext(GridSelectionContext);
+
+  if (context === undefined) {
+    throw new Error("useGridSelectionState must be used within a 
GridSelectionProvider");
+  }
+
+  return context;
+};
+
+/** Whether task instances are selected in the grid, in which case their bulk 
actions apply. */
+export const useHasGridSelection = () => {
+  const { dagId } = useParams();
+  const [selection] = useContext(GridSelectionContext) ?? [];
+
+  return selection !== undefined && selection.dagId === dagId && 
selection.cells.size > 0;
+};
diff --git 
a/airflow-core/src/airflow/ui/src/context/gridSelection/GridSelectionProvider.tsx
 
b/airflow-core/src/airflow/ui/src/context/gridSelection/GridSelectionProvider.tsx
new file mode 100644
index 00000000000..3e55b81fdb1
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/context/gridSelection/GridSelectionProvider.tsx
@@ -0,0 +1,32 @@
+/*!
+ * 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 { type PropsWithChildren, useState } from "react";
+
+import { GridSelectionContext, type GridSelectionState } from "./Context";
+
+// Held above the grid so that the details panel can tell whether task 
instances are selected in it.
+export const GridSelectionProvider = ({ children }: PropsWithChildren) => {
+  const [selection, setSelection] = useState<GridSelectionState>({ cells: new 
Map(), dagId: "" });
+
+  return (
+    <GridSelectionContext.Provider value={[selection, setSelection]}>
+      {children}
+    </GridSelectionContext.Provider>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/context/gridSelection/index.ts 
b/airflow-core/src/airflow/ui/src/context/gridSelection/index.ts
new file mode 100644
index 00000000000..51449fcd5a1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/gridSelection/index.ts
@@ -0,0 +1,21 @@
+/*!
+ * 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.
+ */
+
+export * from "./Context";
+export * from "./GridSelectionProvider";
diff --git 
a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts 
b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
index 4482f944eac..b77f38245cd 100644
--- a/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
+++ b/airflow-core/src/airflow/ui/src/context/keyboardShortcuts/shortcuts.ts
@@ -62,11 +62,21 @@ export const SHORTCUTS = {
     toggleWrap: { category: "code", descriptionKey: 
"shortcuts.descriptions.toggleWrap", keys: "w" },
   },
   dagView: {
+    selectGridCellRange: {
+      category: "dagView",
+      descriptionKey: "shortcuts.descriptions.selectGridCellRange",
+      keys: "shift+Click",
+    },
     toggleGraphGrid: {
       category: "dagView",
       descriptionKey: "shortcuts.descriptions.toggleGraphGrid",
       keys: "g",
     },
+    toggleGridCellSelection: {
+      category: "dagView",
+      descriptionKey: "shortcuts.descriptions.toggleGridCellSelection",
+      keys: "mod+Click",
+    },
   },
   filters: {
     openGraphFilters: {
diff --git a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
index 351081a8733..bddf353e7f4 100644
--- a/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/BaseLayout.tsx
@@ -29,6 +29,7 @@ import { ReactPlugin } from "src/pages/ReactPlugin";
 
 import { KeyboardShortcutsModal } from "src/components/KeyboardShortcuts";
 
+import { GridSelectionProvider } from "src/context/gridSelection";
 import { useConfig } from "src/queries/useConfig";
 import { DocumentTitleProvider } from "src/utils";
 
@@ -126,7 +127,7 @@ export const BaseLayout = ({ children }: PropsWithChildren) 
=> {
             {baseReactPlugins.map((plugin) => (
               <ReactPlugin key={plugin.name} reactApp={plugin} />
             ))}
-            {children ?? <Outlet />}
+            <GridSelectionProvider>{children ?? <Outlet 
/>}</GridSelectionProvider>
           </Box>
         </Box>
       </DocumentTitleProvider>
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 cd6b8deffd7..74034926261 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
@@ -23,6 +23,7 @@ import { describe, expect, it, vi } from "vitest";
 import type { GridRunsResponse } from "openapi/requests/types.gen";
 
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
+import { GridSelectionProvider } from "src/context/gridSelection";
 import { GroupsProvider } from "src/context/groups";
 import { Wrapper } from "src/utils/Wrapper";
 
@@ -73,13 +74,15 @@ describe("Grid", () => {
     const { container } = render(
       <Wrapper>
         <GroupsProvider dagId="example_dag">
-          <Grid
-            limit={10}
-            offset={0}
-            onJumpToLatest={vi.fn()}
-            setOffset={vi.fn()}
-            showVersionIndicatorMode={VersionIndicatorOptions.ALL}
-          />
+          <GridSelectionProvider>
+            <Grid
+              limit={10}
+              offset={0}
+              onJumpToLatest={vi.fn()}
+              setOffset={vi.fn()}
+              showVersionIndicatorMode={VersionIndicatorOptions.ALL}
+            />
+          </GridSelectionProvider>
         </GroupsProvider>
       </Wrapper>,
     );
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 f33d98c74a4..c8a827eaac5 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
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { RefObject } from "react";
+import type { MouseEvent, RefObject } from "react";
 import { useCallback, useMemo, useRef } from "react";
 
 import { Box, Flex } from "@chakra-ui/react";
@@ -28,6 +28,7 @@ import { useParams, useSearchParams } from "react-router-dom";
 import type { DagRunState, DagRunType, GridRunsResponse } from 
"openapi/requests";
 
 import type { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
+import type { GridCell } from "src/context/gridSelection";
 import { useGroups } from "src/context/groups";
 import { NavigationModes, useNavigation } from "src/hooks/navigation";
 import { useGridRuns } from "src/queries/useGridRuns.ts";
@@ -38,12 +39,14 @@ import { Bar } from "./Bar";
 import { DurationAxis } from "./DurationAxis";
 import { DurationTick } from "./DurationTick";
 import { GridPaginationButtons } 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 { useGridPagination } from "./useGridPagination";
 import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
 import { useGridScrollRestore } from "./useGridScrollRestore";
+import { useGridSelection } from "./useGridSelection";
 import { estimateTaskNameColumnWidthPx, flattenNodes } from "./utils";
 
 dayjs.extend(dayjsDuration);
@@ -182,8 +185,24 @@ export const Grid = ({
     tasks: flatNodes,
   });
 
+  const { clearSelection, deselectCells, handleSelectionClick, selection } = 
useGridSelection({
+    flatNodes,
+    runIds: gridRuns?.map((dr: GridRunsResponse) => dr.run_id) ?? [],
+    summariesByRunId,
+  });
+
   const handleRowClick = useCallback(() => setMode(NavigationModes.TASK), 
[setMode]);
-  const handleCellClick = useCallback(() => setMode(NavigationModes.TI), 
[setMode]);
+  const handleCellClick = useCallback(
+    (event: MouseEvent<HTMLAnchorElement>, cell: GridCell) => {
+      if (handleSelectionClick(event, cell)) {
+        event.preventDefault();
+
+        return;
+      }
+      setMode(NavigationModes.TI);
+    },
+    [handleSelectionClick, setMode],
+  );
   const handleColumnClick = useCallback(() => setMode(NavigationModes.RUN), 
[setMode]);
 
   const headerPad = usesSharedScroll ? GANTT_ROW_OFFSET_PX : 
GRID_INNER_SCROLL_PADDING_START_PX;
@@ -285,6 +304,7 @@ export const Grid = ({
               nodes={flatNodes}
               onCellClick={handleCellClick}
               run={dr}
+              selection={selection}
               showVersionIndicatorMode={showVersionIndicatorMode}
               tiSummaries={summariesByRunId.get(dr.run_id)}
               virtualItems={virtualItems}
@@ -315,6 +335,12 @@ export const Grid = ({
           {gridHeaderAndBody}
         </Box>
       )}
+      <GridSelectionActionBar
+        clearSelection={clearSelection}
+        dagId={dagId}
+        deselectCells={deselectCells}
+        selection={selection}
+      />
     </Flex>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.tsx
new file mode 100644
index 00000000000..72599d67a3b
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridSelectionActionBar.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 { Spinner } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+
+import { ActionBar } from "src/system-components";
+
+import BulkClearTaskInstancesButton from 
"src/pages/TaskInstances/BulkClearTaskInstancesButton";
+import BulkDeleteTaskInstancesButton from 
"src/pages/TaskInstances/BulkDeleteTaskInstancesButton";
+import BulkMarkTaskInstancesAsButton from 
"src/pages/TaskInstances/BulkMarkTaskInstancesAsButton";
+
+import type { GridSelection } from "src/context/gridSelection";
+import { formatNumber } from "src/utils";
+
+import { useGridSelectedTaskInstances } from "./useGridSelectedTaskInstances";
+
+type Props = {
+  readonly clearSelection: VoidFunction;
+  readonly dagId: string;
+  readonly deselectCells: (keys: Array<string>) => void;
+  readonly selection: GridSelection;
+};
+
+export const GridSelectionActionBar = ({ clearSelection, dagId, deselectCells, 
selection }: Props) => {
+  const { i18n, t: translate } = useTranslation();
+  const { data: selectedTaskInstances = [], isLoading } = 
useGridSelectedTaskInstances(dagId, selection);
+
+  // Bulk actions report each updated task instance as 
`dag_id.run_id.task_id[map_index]`.
+  const deselectKeys = (keys: Array<string>) =>
+    deselectCells(
+      [...selection.entries()]
+        .filter(([, cell]) => keys.some((key) => 
key.startsWith(`${dagId}.${cell.runId}.${cell.taskId}[`)))
+        .map(([cellKey]) => cellKey),
+    );
+
+  return (
+    <ActionBar.Root closeOnInteractOutside={false} open={selection.size > 0}>
+      <ActionBar.Content>
+        {isLoading ? (
+          <Spinner size="sm" />
+        ) : (
+          <>
+            <ActionBar.SelectionTrigger>
+              {formatNumber(selectedTaskInstances.length, i18n.language)} 
{translate("selected")}
+            </ActionBar.SelectionTrigger>
+            <ActionBar.Separator />
+            <BulkClearTaskInstancesButton
+              clearSelections={clearSelection}
+              selectedTaskInstances={selectedTaskInstances}
+            />
+            <BulkMarkTaskInstancesAsButton
+              deselectKeys={deselectKeys}
+              selectedTaskInstances={selectedTaskInstances}
+            />
+            <BulkDeleteTaskInstancesButton
+              deselectKeys={deselectKeys}
+              selectedTaskInstances={selectedTaskInstances}
+            />
+          </>
+        )}
+        <ActionBar.CloseTrigger onClick={clearSelection} />
+      </ActionBar.Content>
+    </ActionBar.Root>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
index 0c6d46f92c8..fbd16188b04 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.test.tsx
@@ -55,6 +55,7 @@ type RenderGridTIOptions = {
   readonly instance?: LightGridTaskInstanceSummary;
   readonly isGroup?: boolean;
   readonly isMapped?: boolean;
+  readonly isMultiSelected?: boolean;
   readonly runId?: string;
   readonly taskId?: string;
 };
@@ -66,6 +67,7 @@ const renderGridTI = (
     instance = taskInstance,
     isGroup,
     isMapped,
+    isMultiSelected,
     runId = SELECTED_RUN_ID,
     taskId = "selected_task",
   }: RenderGridTIOptions = {},
@@ -83,6 +85,7 @@ const renderGridTI = (
                   instance={{ ...instance, task_id: taskId }}
                   isGroup={isGroup}
                   isMapped={isMapped}
+                  isMultiSelected={isMultiSelected}
                   label={taskId}
                   runId={runId}
                   taskId={taskId}
@@ -245,6 +248,22 @@ describe("GridTI", () => {
     );
   });
 
+  it("marks a multi-selected task square that is not the opened one", () => {
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
+      isMultiSelected: true,
+      runId: "other_run",
+      taskId: "other_task",
+    });
+
+    
expect(screen.getByTestId("task-state-badge")).toHaveAttribute("data-selected", 
"true");
+  });
+
+  it("does not mark the opened task square when a selection leaves it out", () 
=> {
+    
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, { 
isMultiSelected: false });
+
+    
expect(screen.getByTestId("task-state-badge")).not.toHaveAttribute("data-selected");
+  });
+
   it("links to the task overview when the run has no task instance", () => {
     
renderGridTI(`/dags/example_dag/runs/${SELECTED_RUN_ID}/tasks/selected_task`, {
       instance: { ...taskInstance, dag_version_number: null },
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
index fbfb1860c6a..bbf8047f304 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridTI.tsx
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { MouseEvent } from "react";
+
 import { Badge, Box, Flex } from "@chakra-ui/react";
 import { Link, useLocation, useParams, useSearchParams } from 
"react-router-dom";
 
@@ -42,9 +44,11 @@ type Props = {
   readonly instance: LightGridTaskInstanceSummary;
   readonly isGroup?: boolean;
   readonly isMapped?: boolean | null;
+  // Set only while cells are multi-selected, and then it alone decides which 
cells are outlined.
+  readonly isMultiSelected?: boolean;
   readonly label: string;
   readonly loopMaxIterations?: number | null;
-  readonly onClick?: () => void;
+  readonly onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
   readonly runId: string;
   readonly taskId: string;
 };
@@ -55,6 +59,7 @@ export const GridTI = ({
   instance,
   isGroup,
   isMapped,
+  isMultiSelected,
   loopMaxIterations,
   onClick,
   runId,
@@ -85,7 +90,7 @@ export const GridTI = ({
   ).toString();
 
   const isSelectedRow = selectedTaskId === taskId || selectedGroupId === 
taskId;
-  const isSelectedTaskInstance = selectedRunId === runId && isSelectedRow;
+  const isSelectedTaskInstance = isMultiSelected ?? (selectedRunId === runId 
&& isSelectedRow);
   const selectedOutlineColor = SELECTED_TASK_OUTLINE_COLOR[colorMode];
   const isStacked = Boolean(isMapped) || Boolean(isGroup);
   const frontCardState = instance.state ?? "none";
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index 85835cab5a4..3f3d7cee61d 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import type { MouseEvent } from "react";
+
 import { Box, type BoxProps } from "@chakra-ui/react";
 import type { VirtualItem } from "@tanstack/react-virtual";
 import { useParams } from "react-router-dom";
@@ -24,16 +26,19 @@ import type { GridRunsResponse, GridTISummaries } from 
"openapi/requests";
 import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
 
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
+import type { GridCell, GridSelection } from "src/context/gridSelection";
 
 import { GridTI } from "./GridTI";
 import { DagVersionIndicator } from "./VersionIndicator";
 import { ROW_HEIGHT } from "./constants";
+import { getCellKey } from "./useGridSelection";
 import type { GridTask } from "./utils";
 
 type Props = {
   readonly nodes: Array<GridTask>;
-  readonly onCellClick?: () => void;
+  readonly onCellClick?: (event: MouseEvent<HTMLAnchorElement>, cell: 
GridCell) => void;
   readonly run: GridRunsResponse;
+  readonly selection?: GridSelection;
   readonly showVersionIndicatorMode?: VersionIndicatorOptions;
   readonly tiSummaries?: GridTISummaries;
   readonly virtualItems?: Array<VirtualItem>;
@@ -54,6 +59,7 @@ export const TaskInstancesColumn = ({
   nodes,
   onCellClick,
   run,
+  selection,
   showVersionIndicatorMode,
   tiSummaries,
   virtualItems,
@@ -150,9 +156,14 @@ export const TaskInstancesColumn = ({
               instance={taskInstance}
               isGroup={node.isGroup}
               isMapped={node.is_mapped}
+              isMultiSelected={
+                selection === undefined || selection.size === 0
+                  ? undefined
+                  : selection.has(getCellKey(run.run_id, node.id))
+              }
               label={node.label}
               loopMaxIterations={node.loop_max_iterations}
-              onClick={onCellClick}
+              onClick={(event) => onCellClick?.(event, { runId: run.run_id, 
taskId: node.id })}
               runId={run.run_id}
               taskId={node.id}
             />
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
new file mode 100644
index 00000000000..d2c2e58713d
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.test.tsx
@@ -0,0 +1,97 @@
+/*!
+ * 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 { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+
+import { TaskInstanceService } from "openapi/requests";
+import { CancelablePromise } from "openapi/requests/core/CancelablePromise";
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+import type { GridCell } from "src/context/gridSelection";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { useGridSelectedTaskInstances } from "./useGridSelectedTaskInstances";
+import { getCellKey } from "./useGridSelection";
+
+afterEach(() => vi.restoreAllMocks());
+
+const taskInstance = (runId: string, taskId: string, mapIndex = -1) =>
+  ({
+    dag_id: "dag",
+    dag_run_id: runId,
+    id: `${runId}-${taskId}-${mapIndex}`,
+    map_index: mapIndex,
+    task_id: taskId,
+  }) as TaskInstanceResponse;
+
+const selectionOf = (cells: Array<GridCell>) =>
+  new Map(cells.map((cell) => [getCellKey(cell.runId, cell.taskId), cell]));
+
+it("loads every page and keeps only the selected cells, with all map indexes 
of a mapped task", async () => {
+  // Selecting (r1, mapped) and (r2, plain) asks for both tasks in both runs.
+  const rows = [
+    ...Array.from({ length: 100 }, (_, mapIndex) => taskInstance("r1", 
"mapped", mapIndex)),
+    taskInstance("r1", "plain"),
+    taskInstance("r2", "mapped", 0),
+    taskInstance("r2", "plain"),
+  ];
+  const batch = vi
+    .spyOn(TaskInstanceService, "getTaskInstancesBatch")
+    .mockImplementation(({ requestBody }) => {
+      const offset = requestBody.page_offset ?? 0;
+
+      return new CancelablePromise((resolve) =>
+        resolve({ task_instances: rows.slice(offset, offset + 100), 
total_entries: rows.length }),
+      );
+    });
+
+  const { result } = renderHook(
+    () =>
+      useGridSelectedTaskInstances(
+        "dag",
+        selectionOf([
+          { runId: "r1", taskId: "mapped" },
+          { runId: "r2", taskId: "plain" },
+        ]),
+      ),
+    { wrapper: Wrapper },
+  );
+
+  await waitFor(() => expect(result.current.isSuccess).toBe(true));
+  expect(batch).toHaveBeenCalledTimes(2);
+  expect(batch.mock.calls[0]?.[0].requestBody).toMatchObject({
+    dag_ids: ["dag"],
+    dag_run_ids: ["r1", "r2"],
+    page_offset: 0,
+    task_ids: ["mapped", "plain"],
+  });
+  expect(batch.mock.calls[1]?.[0].requestBody.page_offset).toBe(100);
+  expect(result.current.data?.map((ti) => ti.id)).toEqual([
+    ...Array.from({ length: 100 }, (_, mapIndex) => `r1-mapped-${mapIndex}`),
+    "r2-plain--1",
+  ]);
+});
+
+it("does not load anything without a selection", () => {
+  const batch = vi.spyOn(TaskInstanceService, "getTaskInstancesBatch");
+
+  renderHook(() => useGridSelectedTaskInstances("dag", new Map()), { wrapper: 
Wrapper });
+
+  expect(batch).not.toHaveBeenCalled();
+});
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
new file mode 100644
index 00000000000..7e1c7c587c6
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelectedTaskInstances.ts
@@ -0,0 +1,77 @@
+/*!
+ * 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 { useQuery } from "@tanstack/react-query";
+
+import { useTaskInstanceServiceGetTaskInstancesKey } from "openapi/queries";
+import { TaskInstanceService } from "openapi/requests/services.gen";
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+import type { GridSelection } from "src/context/gridSelection";
+
+import { getCellKey } from "./useGridSelection";
+
+const PAGE_LIMIT = 100;
+
+/**
+ * Load the task instances behind the selected grid cells. A mapped task's 
cell stands for all of
+ * its map indexes, as it does when the cell is clicked.
+ */
+export const useGridSelectedTaskInstances = (dagId: string, selection: 
GridSelection) => {
+  const cells = [...selection.values()];
+  const runIds = [...new Set(cells.map((cell) => cell.runId))].sort();
+  const taskIds = [...new Set(cells.map((cell) => cell.taskId))].sort();
+
+  return useQuery({
+    enabled: selection.size > 0,
+    queryFn: async () => {
+      const taskInstances: Array<TaskInstanceResponse> = [];
+      let done = false;
+
+      // The request asks for every selected task in every selected run, so a 
sparse selection
+      // returns cells that were not picked and they are dropped below.
+      while (!done) {
+        // eslint-disable-next-line no-await-in-loop
+        const page = await TaskInstanceService.getTaskInstancesBatch({
+          dagId: "~",
+          dagRunId: "~",
+          requestBody: {
+            dag_ids: [dagId],
+            dag_run_ids: runIds,
+            order_by: "id",
+            page_limit: PAGE_LIMIT,
+            page_offset: taskInstances.length,
+            task_ids: taskIds,
+          },
+        });
+
+        taskInstances.push(...page.task_instances);
+        done = page.task_instances.length === 0 || taskInstances.length >= 
(page.total_entries ?? Infinity);
+      }
+
+      return taskInstances.filter((ti) => 
selection.has(getCellKey(ti.dag_run_id, ti.task_id)));
+    },
+    // Under the task instances key so the bulk actions' invalidation 
refreshes the selection too.
+    queryKey: [
+      useTaskInstanceServiceGetTaskInstancesKey,
+      "gridSelection",
+      dagId,
+      [...selection.keys()].sort(),
+    ],
+  });
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
new file mode 100644
index 00000000000..f5faa463e9e
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.test.tsx
@@ -0,0 +1,251 @@
+/*!
+ * 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 type { MouseEvent, PropsWithChildren } from "react";
+
+import { act, renderHook } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { describe, expect, it } from "vitest";
+
+import type { GridTISummaries, LightGridTaskInstanceSummary } from 
"openapi/requests/types.gen";
+
+import { GridSelectionProvider, useHasGridSelection } from 
"src/context/gridSelection";
+import { ShortcutRegistryProvider, useShortcutRegistry } from 
"src/context/keyboardShortcuts";
+
+import { getCellKey, isSelectableCell, useGridSelection } from 
"./useGridSelection";
+import type { GridTask } from "./utils";
+
+const node = (id: string, isGroup = false): GridTask => ({
+  depth: 0,
+  id,
+  is_mapped: false,
+  isGroup,
+  label: id,
+});
+
+const instance = (taskId: string, overrides: 
Partial<LightGridTaskInstanceSummary> = {}) => ({
+  child_states: null,
+  dag_version_number: 1,
+  max_end_date: null,
+  min_start_date: null,
+  state: "success" as const,
+  task_display_name: taskId,
+  task_id: taskId,
+  ...overrides,
+});
+
+const RUN_IDS = ["r1", "r2", "r3"];
+const FLAT_NODES = [node("group", true), node("a"), node("b"), node("c")];
+const LOOPED = { latest_region_id: "11111111-1111-4111-8111-111111111111", 
latest_region_index: 2 };
+
+// "c" is looped in r2 and "b" has no task instance in r3, so neither can be 
selected.
+const SUMMARIES = new Map<string, GridTISummaries>(
+  RUN_IDS.map((runId) => [
+    runId,
+    {
+      dag_id: "dag",
+      run_id: runId,
+      task_instances: [
+        instance("group"),
+        instance("a"),
+        ...(runId === "r3" ? [] : [instance("b")]),
+        instance("c", runId === "r2" ? LOOPED : {}),
+      ],
+    },
+  ]),
+);
+
+const click = (modifiers: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: 
boolean } = {}) =>
+  ({ ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }) as 
unknown as MouseEvent;
+
+const renderSelection = (route = "/dags/dag") =>
+  renderHook(
+    () => ({
+      ...useGridSelection({ flatNodes: FLAT_NODES, runIds: RUN_IDS, 
summariesByRunId: SUMMARIES }),
+      hasGridSelection: useHasGridSelection(),
+      shortcuts: useShortcutRegistry().shortcuts,
+    }),
+    {
+      wrapper: ({ children }: PropsWithChildren) => (
+        <ShortcutRegistryProvider>
+          <GridSelectionProvider>
+            <MemoryRouter initialEntries={[route]}>
+              <Routes>
+                <Route element={children} path="/dags/:dagId" />
+                <Route element={children} 
path="/dags/:dagId/runs/:runId/tasks/:taskId" />
+              </Routes>
+            </MemoryRouter>
+          </GridSelectionProvider>
+        </ShortcutRegistryProvider>
+      ),
+    },
+  );
+
+const selectedKeys = (result: ReturnType<typeof renderSelection>["result"]) =>
+  [...result.current.selection.keys()].sort();
+
+describe("isSelectableCell", () => {
+  it.each([
+    { expected: true, name: "a plain task instance", taskInstance: 
instance("a"), taskNode: node("a") },
+    { expected: false, name: "a group", taskInstance: instance("group"), 
taskNode: node("group", true) },
+    { expected: false, name: "a cell without task instance", taskInstance: 
undefined, taskNode: node("a") },
+    {
+      expected: false,
+      name: "a task that has no task instance in the run yet",
+      taskInstance: instance("a", { dag_version_number: null }),
+      taskNode: node("a"),
+    },
+    { expected: false, name: "a looped task", taskInstance: instance("c", 
LOOPED), taskNode: node("c") },
+  ])("returns $expected for $name", ({ expected, taskInstance, taskNode }) => {
+    expect(isSelectableCell(taskNode, taskInstance)).toBe(expected);
+  });
+});
+
+describe("useGridSelection", () => {
+  it("toggles a cell on Ctrl + click and Cmd + click without navigating", () 
=> {
+    const { result } = renderSelection();
+    let handled = false;
+
+    act(() => {
+      handled = result.current.handleSelectionClick(click({ ctrlKey: true }), 
{ runId: "r1", taskId: "a" });
+    });
+
+    expect(handled).toBe(true);
+    expect(selectedKeys(result)).toEqual([getCellKey("r1", "a")]);
+    expect(result.current.hasGridSelection).toBe(true);
+
+    act(() => {
+      result.current.handleSelectionClick(click({ metaKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+
+    expect(selectedKeys(result)).toEqual([]);
+    expect(result.current.hasGridSelection).toBe(false);
+  });
+
+  it("starts a selection from the opened task instance", () => {
+    const { result } = renderSelection("/dags/dag/runs/r1/tasks/a");
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r2", taskId: "b" });
+    });
+
+    expect(selectedKeys(result)).toEqual([getCellKey("r1", "a"), 
getCellKey("r2", "b")].sort());
+  });
+
+  it("selects only the opened task instance when it is the one Ctrl + 
clicked", () => {
+    const { result } = renderSelection("/dags/dag/runs/r1/tasks/a");
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+
+    expect(selectedKeys(result)).toEqual([getCellKey("r1", "a")]);
+  });
+
+  it("selects the selectable cells of the rectangle on Shift + click", () => {
+    const { result } = renderSelection();
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+    act(() => {
+      result.current.handleSelectionClick(click({ shiftKey: true }), { runId: 
"r3", taskId: "c" });
+    });
+
+    expect(selectedKeys(result)).toEqual(
+      [
+        getCellKey("r1", "a"),
+        getCellKey("r2", "a"),
+        getCellKey("r3", "a"),
+        getCellKey("r1", "b"),
+        getCellKey("r2", "b"),
+        getCellKey("r1", "c"),
+        getCellKey("r3", "c"),
+      ].sort(),
+    );
+  });
+
+  it("anchors a Shift + click range on the opened task instance", () => {
+    const { result } = renderSelection("/dags/dag/runs/r1/tasks/a");
+
+    act(() => {
+      result.current.handleSelectionClick(click({ shiftKey: true }), { runId: 
"r2", taskId: "b" });
+    });
+
+    expect(selectedKeys(result)).toEqual(
+      [getCellKey("r1", "a"), getCellKey("r2", "a"), getCellKey("r1", "b"), 
getCellKey("r2", "b")].sort(),
+    );
+  });
+
+  it("leaves a modified click on a cell that cannot be selected to the 
browser", () => {
+    const { result } = renderSelection();
+    let handled = true;
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+    act(() => {
+      handled = result.current.handleSelectionClick(click({ ctrlKey: true }), 
{ runId: "r2", taskId: "c" });
+    });
+
+    expect(handled).toBe(false);
+    expect(selectedKeys(result)).toEqual([getCellKey("r1", "a")]);
+  });
+
+  it("clears the selection on a plain click and lets it navigate", () => {
+    const { result } = renderSelection();
+    let handled = true;
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+    act(() => {
+      handled = result.current.handleSelectionClick(click(), { runId: "r2", 
taskId: "b" });
+    });
+
+    expect(handled).toBe(false);
+    expect(selectedKeys(result)).toEqual([]);
+  });
+
+  it("lists the selection clicks in the keyboard shortcuts help while 
mounted", () => {
+    const { result, unmount } = renderSelection();
+
+    expect(result.current.shortcuts.map(({ category, keys }) => ({ category, 
keys }))).toEqual([
+      { category: "dagView", keys: ["mod+Click"] },
+      { category: "dagView", keys: ["shift+Click"] },
+    ]);
+
+    unmount();
+  });
+
+  it("deselects the given cells", () => {
+    const { result } = renderSelection();
+
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r1", taskId: "a" });
+    });
+    act(() => {
+      result.current.handleSelectionClick(click({ ctrlKey: true }), { runId: 
"r2", taskId: "b" });
+    });
+    act(() => {
+      result.current.deselectCells([getCellKey("r1", "a")]);
+    });
+
+    expect(selectedKeys(result)).toEqual([getCellKey("r2", "b")]);
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
new file mode 100644
index 00000000000..fea210f4b61
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridSelection.ts
@@ -0,0 +1,198 @@
+/*!
+ * 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 type { MouseEvent } from "react";
+import { useCallback, useEffect, useId, useRef } from "react";
+
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import type { GridTISummaries, LightGridTaskInstanceSummary } from 
"openapi/requests/types.gen";
+
+import { type GridCell, useGridSelectionState } from 
"src/context/gridSelection";
+import { SHORTCUTS, useShortcutRegistry } from "src/context/keyboardShortcuts";
+
+import type { GridTask } from "./utils";
+
+export const getCellKey = (runId: string, taskId: string) => 
JSON.stringify([runId, taskId]);
+
+// A looped task's cell aggregates every pass while its link opens only the 
newest one, so a bulk
+// action on it would be ambiguous. Groups and cells without a task instance 
have nothing to act on.
+export const isSelectableCell = (node: GridTask, taskInstance: 
LightGridTaskInstanceSummary | undefined) =>
+  !node.isGroup &&
+  taskInstance?.dag_version_number !== null &&
+  taskInstance?.dag_version_number !== undefined &&
+  (taskInstance.latest_region_id === null || taskInstance.latest_region_id === 
undefined);
+
+type GridData = {
+  readonly flatNodes: Array<GridTask>;
+  readonly runIds: Array<string>;
+  readonly summariesByRunId: ReadonlyMap<string, GridTISummaries>;
+};
+
+const findTaskInstance = (data: GridData, runId: string, taskId: string) =>
+  data.summariesByRunId.get(runId)?.task_instances.find((ti) => ti.task_id === 
taskId);
+
+export const buildRangeSelection = (data: GridData, from: GridCell, to: 
GridCell): Map<string, GridCell> => {
+  const rows = [from.taskId, to.taskId].map((taskId) =>
+    data.flatNodes.findIndex((node) => node.id === taskId),
+  );
+  const columns = [from.runId, to.runId].map((runId) => 
data.runIds.indexOf(runId));
+  const selection = new Map<string, GridCell>();
+
+  if ([...rows, ...columns].includes(-1)) {
+    return selection;
+  }
+
+  for (const node of data.flatNodes.slice(Math.min(...rows), Math.max(...rows) 
+ 1)) {
+    for (const runId of data.runIds.slice(Math.min(...columns), 
Math.max(...columns) + 1)) {
+      if (isSelectableCell(node, findTaskInstance(data, runId, node.id))) {
+        selection.set(getCellKey(runId, node.id), { runId, taskId: node.id });
+      }
+    }
+  }
+
+  return selection;
+};
+
+/**
+ * Select task instance cells in the grid: Ctrl/Cmd + click toggles a cell, 
Shift + click selects
+ * the rectangle between the last toggled (or currently opened) cell and the 
clicked one, and a
+ * plain click clears the selection and navigates as before.
+ */
+export const useGridSelection = (data: GridData) => {
+  const { dagId = "", runId: openedRunId, taskId: openedTaskId } = useParams();
+  const [selection, setSelection] = useGridSelectionState();
+  const anchorRef = useRef<GridCell | undefined>(undefined);
+  // The click handler is passed to every run column; reading the grid data 
through a ref keeps it
+  // stable while the TI summaries stream in, so a summary update does not 
re-render every column.
+  const dataRef = useRef(data);
+
+  useEffect(() => {
+    dataRef.current = data;
+  });
+
+  const { t: translate } = useTranslation();
+  const { register, unregister } = useShortcutRegistry();
+  const shortcutId = useId();
+  const toggleDescription = 
translate(SHORTCUTS.dagView.toggleGridCellSelection.descriptionKey);
+  const rangeDescription = 
translate(SHORTCUTS.dagView.selectGridCellRange.descriptionKey);
+
+  // Modified clicks are not hotkeys, so they are listed in the help modal 
without going through
+  // `useShortcut`.
+  useEffect(() => {
+    const entries = [
+      {
+        ...SHORTCUTS.dagView.toggleGridCellSelection,
+        description: toggleDescription,
+        id: `${shortcutId}-toggle`,
+      },
+      { ...SHORTCUTS.dagView.selectGridCellRange, description: 
rangeDescription, id: `${shortcutId}-range` },
+    ];
+
+    entries.forEach(({ category, description, id, keys }) =>
+      register({ category, description, id, keys: [keys] }),
+    );
+
+    return () => entries.forEach(({ id }) => unregister(id));
+  }, [rangeDescription, register, shortcutId, toggleDescription, unregister]);
+
+  const cells = selection.dagId === dagId ? selection.cells : new Map<string, 
GridCell>();
+
+  const clearSelection = useCallback(() => {
+    anchorRef.current = undefined;
+    setSelection({ cells: new Map(), dagId });
+  }, [dagId, setSelection]);
+
+  // The selection is only shown, and its bulk actions only offered, while the 
grid is mounted.
+  useEffect(() => () => setSelection({ cells: new Map(), dagId: "" }), 
[setSelection]);
+
+  const deselectCells = useCallback(
+    (keys: Array<string>) =>
+      setSelection((prev) => {
+        const next = new Map(prev.cells);
+
+        keys.forEach((key) => next.delete(key));
+
+        return { cells: next, dagId: prev.dagId };
+      }),
+    [setSelection],
+  );
+
+  // Returns whether the click changed the selection, in which case it must 
not navigate.
+  const handleSelectionClick = useCallback(
+    (event: MouseEvent, cell: GridCell): boolean => {
+      const isSelectable = ({ runId, taskId }: GridCell) => {
+        const node = dataRef.current.flatNodes.find((flatNode) => flatNode.id 
=== taskId);
+
+        return node !== undefined && isSelectableCell(node, 
findTaskInstance(dataRef.current, runId, taskId));
+      };
+      const openedCell =
+        openedRunId !== undefined && openedTaskId !== undefined
+          ? { runId: openedRunId, taskId: openedTaskId }
+          : undefined;
+
+      if (event.shiftKey && isSelectable(cell)) {
+        const anchor = anchorRef.current ?? openedCell ?? cell;
+
+        anchorRef.current = anchor;
+        setSelection({ cells: buildRangeSelection(dataRef.current, anchor, 
cell), dagId });
+
+        return true;
+      }
+
+      if ((event.ctrlKey || event.metaKey) && isSelectable(cell)) {
+        const key = getCellKey(cell.runId, cell.taskId);
+
+        anchorRef.current = cell;
+        setSelection((prev) => {
+          const next = new Map(prev.dagId === dagId ? prev.cells : []);
+
+          // The opened task instance is the starting point of a selection, 
like the active cell
+          // of a spreadsheet.
+          if (next.size === 0 && openedCell !== undefined && 
isSelectable(openedCell)) {
+            const openedKey = getCellKey(openedCell.runId, openedCell.taskId);
+
+            if (openedKey !== key) {
+              next.set(openedKey, openedCell);
+            }
+          }
+
+          if (next.has(key)) {
+            next.delete(key);
+          } else {
+            next.set(key, cell);
+          }
+
+          return { cells: next, dagId };
+        });
+
+        return true;
+      }
+
+      if (!event.shiftKey && !event.ctrlKey && !event.metaKey) {
+        clearSelection();
+      }
+
+      return false;
+    },
+    [clearSelection, dagId, openedRunId, openedTaskId, setSelection],
+  );
+
+  return { clearSelection, deselectCells, handleSelectionClick, selection: 
cells };
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
index 077a4a4a60e..f29808a72fd 100644
--- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -31,12 +31,15 @@ import { HeaderCard } from "src/components/HeaderCard";
 import { MarkTaskGroupAsButton } from "src/components/MarkAs";
 import Time from "src/components/Time";
 
+import { useHasGridSelection } from "src/context/gridSelection";
 import { useLoopGroupNode } from "src/queries/useIsLoopGroup";
 import { formatNumber, useDurationFormat } from "src/utils";
 
 import { useLoopOutcomeStats } from "./useLoopOutcomeStats";
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
LightGridTaskInstanceSummary }) => {
+  // The grid's bulk actions replace these while task instances are selected 
there.
+  const hasGridSelection = useHasGridSelection();
   const { i18n, t: translate } = useTranslation();
   const { formatElapsed } = useDurationFormat();
   const { dagId = "", groupId = "", runId = "" } = useParams();
@@ -78,13 +81,22 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
               text={translate("docs.documentation")}
             />
           )}
-          <ClearTaskInstanceButton
-            bg="bg"
-            groupTaskInstance={taskInstance}
-            isHotkeyEnabled
-            variant="outline"
-          />
-          <MarkTaskGroupAsButton bg="bg" groupTaskInstance={taskInstance} 
isHotkeyEnabled variant="outline" />
+          {hasGridSelection ? undefined : (
+            <>
+              <ClearTaskInstanceButton
+                bg="bg"
+                groupTaskInstance={taskInstance}
+                isHotkeyEnabled
+                variant="outline"
+              />
+              <MarkTaskGroupAsButton
+                bg="bg"
+                groupTaskInstance={taskInstance}
+                isHotkeyEnabled
+                variant="outline"
+              />
+            </>
+          )}
         </>
       }
       icon={<AiOutlineGroup />}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
index f07d9685ebb..25618801bb9 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
@@ -29,9 +29,12 @@ import { ClearTaskInstanceButton } from 
"src/components/Clear";
 import { HeaderCard } from "src/components/HeaderCard";
 import Time from "src/components/Time";
 
+import { useHasGridSelection } from "src/context/gridSelection";
 import { formatNumber, useDurationFormat } from "src/utils";
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
LightGridTaskInstanceSummary }) => {
+  // The grid's bulk actions replace these while task instances are selected 
there.
+  const hasGridSelection = useHasGridSelection();
   const { dagId = "", runId = "" } = useParams();
   const { i18n, t: translate } = useTranslation();
   const { formatElapsed } = useDurationFormat();
@@ -75,15 +78,17 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
   return (
     <HeaderCard
       actions={
-        <ClearTaskInstanceButton
-          allMapped
-          bg="bg"
-          dagId={dagId}
-          dagRunId={runId}
-          isHotkeyEnabled
-          taskId={taskInstance.task_id}
-          variant="outline"
-        />
+        hasGridSelection ? undefined : (
+          <ClearTaskInstanceButton
+            allMapped
+            bg="bg"
+            dagId={dagId}
+            dagRunId={runId}
+            isHotkeyEnabled
+            taskId={taskInstance.task_id}
+            variant="outline"
+          />
+        )
       }
       icon={<MdOutlineTask />}
       state={taskInstance.state}
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index c41f75877a3..8dfb458f473 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -42,6 +42,7 @@ import { TeamName } from "src/components/TeamName";
 import Time from "src/components/Time";
 
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { useHasGridSelection } from "src/context/gridSelection";
 import { usePluginActions } from "src/hooks/usePluginActions";
 import { useShowTeam } from "src/hooks/useShowTeam";
 import { useDagRunNote } from "src/queries/useDagRunNote";
@@ -50,6 +51,8 @@ import { hasDagRunConfig, useDurationFormat } from 
"src/utils";
 import { DeadlineStatus } from "./DeadlineStatus";
 
 export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
+  // The grid's bulk actions replace these while task instances are selected 
there.
+  const hasGridSelection = useHasGridSelection();
   const { t: translate } = useTranslation();
   const { formatElapsed } = useDurationFormat();
   const pluginActions = usePluginActions("dag_run");
@@ -69,9 +72,13 @@ export const Header = ({ dagRun }: { readonly dagRun: 
DAGRunResponse }) => {
         actions={
           <>
             <NeedsReviewButtonWithModal dagId={dagId} runId={dagRunId} 
variant="chip" />
-            <ClearRunButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
-            <MarkRunAsButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
-            <DeleteRunButton bg="bg" dagRun={dagRun} variant="outline" />
+            {hasGridSelection ? undefined : (
+              <>
+                <ClearRunButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
+                <MarkRunAsButton bg="bg" dagRun={dagRun} isHotkeyEnabled 
variant="outline" />
+                <DeleteRunButton bg="bg" dagRun={dagRun} variant="outline" />
+              </>
+            )}
             <PluginActions
               {...pluginActions}
               buttonProps={{ bg: "bg", variant: "outline" }}
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
index 6a638ee95ea..4de5ce95ce8 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.test.tsx
@@ -42,6 +42,12 @@ vi.mock("src/components/NotePreview", () => ({
   NotePreview: () => <button type="button">Edit note</button>,
 }));
 
+const gridSelection = vi.hoisted(() => ({ hasSelection: false }));
+
+vi.mock("src/context/gridSelection", () => ({
+  useHasGridSelection: () => gridSelection.hasSelection,
+}));
+
 const mockConfig: Record<string, unknown> = { multi_team: false };
 const mockLoopSummary: { data?: Partial<LoopSummaryResponse> } = {};
 
@@ -101,6 +107,21 @@ describe("Header", () => {
     expect(screen.getByRole("link", { name: "team-a" 
})).toHaveAttribute("href", "/dags?teams=team-a");
   });
 
+  it.each([
+    { hasSelection: false, shown: true },
+    { hasSelection: true, shown: false },
+  ])(
+    "shows the actions only without a grid selection (selection: 
$hasSelection)",
+    ({ hasSelection, shown }) => {
+      gridSelection.hasSelection = hasSelection;
+      render(<Header taskInstance={baseTaskInstance} />, { wrapper: Wrapper });
+
+      expect(screen.queryByRole("button", { name: "Clear task" }) !== 
null).toBe(shown);
+      expect(screen.queryByRole("button", { name: "Mark task" }) !== 
null).toBe(shown);
+      gridSelection.hasSelection = false;
+    },
+  );
+
   it("hides the team stat when multi-team is disabled", () => {
     render(<Header taskInstance={{ ...baseTaskInstance, team_name: "team-a" }} 
/>, { wrapper: Wrapper });
 
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index 3cd85664484..edf55f64488 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -38,6 +38,7 @@ import ReactMarkdown from "src/components/ReactMarkdown";
 import { TeamName } from "src/components/TeamName";
 import Time from "src/components/Time";
 
+import { useHasGridSelection } from "src/context/gridSelection";
 import { usePluginActions } from "src/hooks/usePluginActions";
 import { useShowTeam } from "src/hooks/useShowTeam";
 import { useLoopSummary } from "src/queries/useLoopSummary";
@@ -68,6 +69,8 @@ export const HistoryHeader = ({ taskInstance }: { readonly 
taskInstance: TaskIns
 };
 
 export const Header = ({ taskInstance }: { readonly taskInstance: 
TaskInstanceResponse }) => {
+  // The grid's bulk actions replace these while task instances are selected 
there.
+  const hasGridSelection = useHasGridSelection();
   const { t: translate } = useTranslation();
   const { formatElapsed, renderDuration } = useDurationFormat();
   const pluginActions = usePluginActions("task_instance");
@@ -166,14 +169,23 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
       <HeaderCard
         actions={
           <>
-            <ClearTaskInstanceButton
-              bg="bg"
-              isHotkeyEnabled
-              onOpen={() => setClearOpen(true)}
-              taskInstance={taskInstance}
-              variant="outline"
-            />
-            <MarkTaskInstanceAsButton bg="bg" isHotkeyEnabled 
taskInstance={taskInstance} variant="outline" />
+            {hasGridSelection ? undefined : (
+              <>
+                <ClearTaskInstanceButton
+                  bg="bg"
+                  isHotkeyEnabled
+                  onOpen={() => setClearOpen(true)}
+                  taskInstance={taskInstance}
+                  variant="outline"
+                />
+                <MarkTaskInstanceAsButton
+                  bg="bg"
+                  isHotkeyEnabled
+                  taskInstance={taskInstance}
+                  variant="outline"
+                />
+              </>
+            )}
             <PluginActions
               {...pluginActions}
               buttonProps={{ bg: "bg", variant: "outline" }}

Reply via email to