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 e82b196c065 Dag run breadcrumb switcher (#74471)
e82b196c065 is described below

commit e82b196c06515a232755e5fbfd1fb5b6f1cef49e
Author: Brent Bovenzi <[email protected]>
AuthorDate: Thu Oct 8 17:57:21 2026 -0400

    Dag run breadcrumb switcher (#74471)
    
    * UI: Let the breadcrumb's Dag Run level switch runs
    
    Switching to a different run meant leaving the page you were looking at, or
    digging the run picker out of the graph view's options popover. The run is
    already named in the breadcrumb next to the Dag it belongs to, so that is
    where picking a different one belongs — the same place, and the same 
two-part
    control, the Dag level already uses.
    
    * UI: Open the breadcrumb switchers on a list that is already loaded
    
    Both panels asked react-select to load their options from a mount effect, 
and
    both unmount when the panel closes, so every open was a round trip that 
started
    as a spinner over an empty list — including when the same Dags or runs had 
just
    been on screen a moment earlier.
    
    What a panel lists is not the panel's own business: it belongs to the level 
that
    opens it, which stays on the page. From there it is loaded before anyone 
opens
    anything, and the run list keeps up with the page's auto-refresh.
    
    Giving the run level a permanent place in the bar follows from the same 
idea: the
    breadcrumb is where runs are switched, so every page of a Dag can reach the
    latest run, not only the pages that already have one in the URL.
    
    * UI: Take the loading affordance out of the breadcrumb searches
    
    Neither panel has anything to wait for any more: what they list is loaded 
by the
    breadcrumb level that opens them, and a typed search replaces the results 
once it
    answers rather than blanking them first. A spinner over a list that is 
already
    there only reads as the panel losing its place.
    
    The run level also had to declare the `dag` namespace to name itself. It 
had not
    needed to while it only appeared on task pages without a run; now that it 
is on
    every page of a Dag, it was showing its translation key.
    
    * UI: Put each breadcrumb search next to the level that opens it
    
    Neither search is reusable — each has exactly one caller, the switcher 
button it
    belongs to — so sitting in `components/` only put distance between the two 
halves
    of one control. The query that feeds a panel was a hook of its own for the 
same
    reason, read once, in a component short enough to hold it.
    
    Only the combo-box chrome is genuinely shared, and a folder holding one file
    behind a re-export is not a module.
    
    * UI: Give the breadcrumb searches' shared values their own module
    
    Folding the option builders and query constants into the panel components 
left
    each one exporting things that are not components, which costs the file its 
fast
    refresh. They are shared between a panel and the level that opens it, so 
they
    belong beside both rather than inside either.
    
    * UI: Address review on the Dag run breadcrumb switcher
    
    Switching runs from a mapped task built its own path and sent `/tasks/<id>`,
    which has no row once the task is expanded, so the page it landed on 
reported no
    task instance found. It also dropped whichever tab was open. The shared 
builder
    the grid and keyboard navigation already use handles both, and knows to 
stop at
    the list of instances.
    
    Suppressing react-select's loading message as well as its spinner went too 
far:
    on the first search after the input is cleared react-select empties the list
    itself, and with nothing to say in that window it rendered no menu at all.
    
    `checkPendingRuns` does not scale the refresh interval back, it switches it 
off,
    so the list stopped noticing runs once the current ones finished — and 
while any
    run was active, every page of a Dag polled for a panel nobody had opened. 
The
    panel is the only thing that reads this list, so it is also the only thing 
that
    needs it kept current.
    
    Rebuilding every option on each poll cost react-select the focused row, 
since it
    tracks focus by reference: arrowing down and pressing Enter could open a
    different run than the one highlighted.
---
 .../airflow/ui/public/i18n/locales/en/common.json  |   2 +
 .../airflow/ui/public/i18n/locales/en/dags.json    |   1 +
 .../ui/src/components/Breadcrumb/CrumbSwitcher.tsx |  85 ++++++++
 .../airflow/ui/src/components/Breadcrumb/index.ts  |   1 +
 .../components/SearchDags/SearchDagsControl.tsx    |  37 ----
 .../airflow/ui/src/components/SearchDags/index.ts  |  20 --
 .../src/airflow/ui/src/components/SearchSelect.tsx | 117 +++++++++++
 .../ui/src/layouts/Details/DagBreadcrumb.test.tsx  |  71 +++++++
 .../ui/src/layouts/Details/DagBreadcrumb.tsx       |  44 +++--
 .../src/layouts/Details/DagRunSwitcherButton.tsx   |  89 +++++++++
 .../ui/src/layouts/Details/DagSwitcherButton.tsx   |  75 +++----
 .../ui/src/layouts/Details/SearchDagRuns.test.tsx  | 215 +++++++++++++++++++++
 .../ui/src/layouts/Details/SearchDagRuns.tsx       | 129 +++++++++++++
 .../Details}/SearchDags.test.tsx                   |   2 +-
 .../SearchDags => layouts/Details}/SearchDags.tsx  |  67 +++----
 .../ui/src/layouts/Details/searchOptions.test.ts   |  50 +++++
 .../ui/src/layouts/Details/searchOptions.ts        |  66 +++++++
 airflow-core/src/airflow/ui/src/utils/option.ts    |   4 +
 18 files changed, 903 insertions(+), 172 deletions(-)

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 4b17fbdca81..61141af84c6 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
@@ -168,6 +168,7 @@
   "generateToken": "Generate Token",
   "includedIn": "Included in",
   "key": "Key",
+  "loading": "Loading…",
   "logLevel": {
     "hide": "Hide Log Levels",
     "hotkey": "l",
@@ -419,6 +420,7 @@
     "upstream_failed": "Upstream Failed"
   },
   "switchDag": "Switch $t(dag_one)",
+  "switchDagRun": "Switch $t(dagRun_one)",
   "table": {
     "completedAt": "Completed at",
     "createdAt": "Created at",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
index 8ab7d375ac3..e39a8194332 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dags.json
@@ -128,6 +128,7 @@
   "search": {
     "advanced": "Advanced Search",
     "clear": "Clear search",
+    "dagRuns": "Search $t(common:dagRun_other)",
     "dags": "Search $t(common:dag_other)",
     "hotkey": "+K",
     "tasks": "Search $t(common:task_other)"
diff --git 
a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx 
b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
new file mode 100644
index 00000000000..573c893e7a3
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
@@ -0,0 +1,85 @@
+/*!
+ * 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 { ReactNode } from "react";
+
+import { Box } from "@chakra-ui/react";
+import { BsChevronExpand } from "react-icons/bs";
+
+import { IconButton, Popover, Tooltip } from "src/system-components";
+
+import { CrumbDivider, CrumbGroup, CrumbLink } from "./Crumb";
+import { CUT_PADDING, type CrumbShape, crumbButtonStyles, getWedgePadding } 
from "./segment";
+
+type Props = {
+  readonly children: ReactNode;
+  /** Names the chevron for the tooltip and for assistive technology. */
+  readonly label: string;
+  readonly onOpenChange: (open: boolean) => void;
+  readonly open: boolean;
+  readonly search: ReactNode;
+  readonly shape: CrumbShape;
+  readonly testId: string;
+  readonly to: string;
+};
+
+/**
+ * A breadcrumb level that also switches between its siblings: a two-part 
control whose left half
+ * navigates to the level and whose right half opens a search, so switching 
happens where the
+ * current one is named. Open state stays at the call site, which owns the 
keyboard shortcut (if
+ * the level has one) and the search the panel holds.
+ */
+export const CrumbSwitcher = ({ children, label, onOpenChange, open, search, 
shape, testId, to }: Props) => (
+  <Popover.Root
+    lazyMount
+    onOpenChange={(event) => onOpenChange(event.open)}
+    open={open}
+    positioning={{ placement: "bottom-start" }}
+    unmountOnExit
+  >
+    {/* Anchored to the whole level rather than the chevron: it shares the 
breadcrumb's start
+        edge and its bottom, so the panel drops clear of the bar and lines up 
with it. */}
+    <Popover.Anchor asChild>
+      <CrumbGroup shape={shape}>
+        <CrumbLink paddingInlineEnd={CUT_PADDING} to={to}>
+          {children}
+        </CrumbLink>
+        <CrumbDivider />
+        {/* The tooltip wraps the trigger rather than coming from IconButton's 
`label`: nesting it
+          inside `asChild` leaves its own trigger ref unset, and it renders 
away from the button. */}
+        <Tooltip content={label} disabled={open} portalled>
+          <Popover.Trigger asChild>
+            <IconButton
+              {...crumbButtonStyles}
+              {...getWedgePadding(shape)}
+              alignSelf="stretch"
+              aria-label={label}
+              data-testid={testId}
+              paddingInlineStart={2}
+            >
+              <BsChevronExpand />
+            </IconButton>
+          </Popover.Trigger>
+        </Tooltip>
+      </CrumbGroup>
+    </Popover.Anchor>
+    <Popover.Content data-testid={`${testId}-popover`} width="sm">
+      <Box p={2}>{search}</Box>
+    </Popover.Content>
+  </Popover.Root>
+);
diff --git a/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts 
b/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
index b36621f0075..fb845f7b685 100644
--- a/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/index.ts
@@ -17,5 +17,6 @@
  * under the License.
  */
 export * from "./Crumb";
+export * from "./CrumbSwitcher";
 export * from "./Row";
 export * from "./segment";
diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx
deleted file mode 100644
index 7687add6b8a..00000000000
--- 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDagsControl.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-/*!
- * 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 { Box } from "@chakra-ui/react";
-import { chakraComponents } from "chakra-react-select";
-import type { ControlProps } from "chakra-react-select";
-import { FiSearch } from "react-icons/fi";
-
-import type { DagSearchOption } from "src/utils/option";
-
-/**
- * Leads the input with the search affordance. react-select only renders 
indicators after the value
- * container, so an icon on the start side has to come from the control itself.
- */
-export const Control = ({ children, ...props }: ControlProps<DagSearchOption, 
false>) => (
-  <chakraComponents.Control {...props}>
-    <Box alignItems="center" as="span" color="fg.muted" display="flex" 
flexShrink={0} pe={1.5}>
-      <FiSearch />
-    </Box>
-    {children}
-  </chakraComponents.Control>
-);
diff --git a/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts 
b/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts
deleted file mode 100644
index 7183a7405a9..00000000000
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/index.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/*!
- * 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 { SearchDags } from "./SearchDags";
diff --git a/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx 
b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
new file mode 100644
index 00000000000..61b4e2fdfef
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/SearchSelect.tsx
@@ -0,0 +1,117 @@
+/*!
+ * 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 { ReactNode } from "react";
+
+import { Box, Field } from "@chakra-ui/react";
+import type {
+  ChakraStylesConfig,
+  ControlProps,
+  GroupBase,
+  OptionsOrGroups,
+  SingleValue,
+} from "chakra-react-select";
+import { AsyncSelect, chakraComponents } from "chakra-react-select";
+import { FiSearch } from "react-icons/fi";
+
+/**
+ * Leads the input with the search affordance. react-select only renders 
indicators after the value
+ * container, so an icon on the start side has to come from the control itself.
+ */
+const SearchControl = <Option,>({ children, ...props }: ControlProps<Option, 
false>) => (
+  <chakraComponents.Control {...props}>
+    <Box alignItems="center" as="span" color="fg.muted" display="flex" 
flexShrink={0} pe={1.5}>
+      <FiSearch />
+    </Box>
+    {children}
+  </chakraComponents.Control>
+);
+
+/** `DropdownIndicator` accepts `null` to opt out; `LoadingIndicator` only 
accepts a component. */
+const NoIndicator = () => null;
+
+type Props<Option> = {
+  /**
+   * The options to list before anything is typed. Always an array, never 
`true`: `true` makes
+   * react-select call `loadOptions` from a mount effect and start out 
`isLoading`, so every open
+   * of a panel that unmounts on close is a spinner over an empty list. A 
fresh array replaces what
+   * is listed, which is how a live query keeps an open panel current.
+   */
+  readonly defaultOptions: Array<Option>;
+  readonly formatOptionLabel: (option: Option) => ReactNode;
+  /**
+   * Shown while a search is in flight. react-select empties the list for the 
first search after
+   * the input is cleared, so without this the panel would render nothing at 
all in that window.
+   */
+  readonly loadingMessage: string;
+  readonly loadOptions: (
+    inputValue: string,
+    callback: (options: OptionsOrGroups<Option, GroupBase<Option>>) => void,
+  ) => void;
+  readonly onChange: (selected: SingleValue<Option>) => void;
+  readonly placeholder: string;
+};
+
+/**
+ * The search a breadcrumb switcher drops open. Its results are listed from 
the moment it mounts,
+ * since the panel exists only to show them.
+ */
+export const SearchSelect = <Option,>({
+  defaultOptions,
+  formatOptionLabel,
+  loadingMessage,
+  loadOptions,
+  onChange,
+  placeholder,
+}: Props<Option>) => {
+  // The popover is the card. Drop the floating menu's own positioning and 
chrome so the results
+  // flow inside it directly under the input, instead of reading as a second 
card.
+  const chakraStyles: ChakraStylesConfig<Option, false, GroupBase<Option>> = {
+    menu: () => ({ marginTop: 2, width: "100%" }),
+    menuList: (provided) => ({
+      ...provided,
+      background: "transparent",
+      borderRadius: 0,
+      boxShadow: "none",
+      paddingInline: 0,
+      zIndex: "auto",
+    }),
+  };
+
+  return (
+    <Field.Root>
+      <AsyncSelect<Option>
+        backspaceRemovesValue={true}
+        chakraStyles={chakraStyles}
+        // No spinner: the listed options are in hand before the panel opens, 
and a search
+        // replaces them once it answers. Only the window where react-select 
empties the list
+        // itself gets an affordance, and a line of text is enough for it.
+        components={{ Control: SearchControl, DropdownIndicator: null, 
LoadingIndicator: NoIndicator }}
+        defaultOptions={defaultOptions}
+        filterOption={undefined}
+        formatOptionLabel={formatOptionLabel}
+        loadingMessage={() => loadingMessage}
+        loadOptions={loadOptions}
+        menuIsOpen
+        onChange={onChange}
+        placeholder={placeholder}
+        value={null} // null is required 
https://github.com/JedWatson/react-select/issues/3066
+      />
+    </Field.Root>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
index 18d80b1b511..a495c8a94eb 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.test.tsx
@@ -23,6 +23,8 @@ import { cleanup, fireEvent, render, screen, waitFor } from 
"@testing-library/re
 import { MemoryRouter, Route, Routes, useLocation, useMatches } from 
"react-router-dom";
 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
 
+import { DagRunService, DagService } from "openapi/requests/services.gen";
+
 import { BaseWrapper } from "src/utils/Wrapper";
 
 import { DagBreadcrumb } from "./DagBreadcrumb";
@@ -118,6 +120,75 @@ describe("DagBreadcrumb", () => {
     );
   });
 
+  it("keeps a run level on a Dag page with no run, so a run can be picked from 
anywhere", async () => {
+    render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, 
"/dags/:dagId") });
+
+    expect(await screen.findByRole("link", { name: /allRuns/u 
})).toHaveAttribute(
+      "href",
+      `/dags/${DAG_ID}/runs`,
+    );
+    expect(screen.getByTestId("switch-dag-run")).toBeInTheDocument();
+  });
+
+  it("loads what both dropdowns list before either one is opened", async () => 
{
+    const getDagsUi = vi.spyOn(DagService, "getDagsUi").mockResolvedValue({ 
dags: [], total_entries: 0 });
+    const getDagRuns = vi
+      .spyOn(DagRunService, "getDagRuns")
+      .mockResolvedValue({ dag_runs: [], total_entries: 0 });
+
+    render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, 
"/dags/:dagId") });
+
+    await waitFor(() => expect(getDagsUi).toHaveBeenCalled());
+    await waitFor(() =>
+      expect(getDagRuns).toHaveBeenCalledWith(
+        expect.objectContaining({ dagId: DAG_ID, limit: 10, orderBy: 
["-run_after"] }),
+      ),
+    );
+
+    // Neither panel was ever opened: what they list is loaded by the 
breadcrumb level itself, so
+    // opening one is not a round trip and never starts out empty.
+    expect(screen.queryByTestId("switch-dag-popover")).not.toBeInTheDocument();
+    
expect(screen.queryByTestId("switch-dag-run-popover")).not.toBeInTheDocument();
+  });
+
+  it("asks for fresh runs when the panel opens, having listed them without 
polling for them", async () => {
+    const getDagRuns = vi
+      .spyOn(DagRunService, "getDagRuns")
+      .mockResolvedValue({ dag_runs: [], total_entries: 0 });
+
+    render(<DagBreadcrumb />, { wrapper: createWrapper(`/dags/${DAG_ID}`, 
"/dags/:dagId") });
+
+    const listCalls = () =>
+      getDagRuns.mock.calls.filter(([params]) => params.limit === 10 && 
params.orderBy !== undefined).length;
+
+    // Loaded up front, so the panel never opens onto an empty list.
+    await waitFor(() => expect(listCalls()).toBe(1));
+
+    fireEvent.click(await screen.findByTestId("switch-dag-run"));
+
+    // Opening catches up whatever changed while the panel was closed and not 
polling.
+    await waitFor(() => expect(listCalls()).toBe(2));
+  });
+
+  it("switches Dag run from the chevron dropdown, keeping the task in view", 
async () => {
+    render(<DagBreadcrumb />, {
+      wrapper: createWrapper(
+        `/dags/${DAG_ID}/runs/run_1/tasks/task_1`,
+        "/dags/:dagId/runs/:runId/tasks/:taskId",
+      ),
+    });
+
+    fireEvent.click(await screen.findByTestId("switch-dag-run"));
+
+    fireEvent.click(await screen.findByText("run_in_range", undefined, { 
timeout: 3000 }));
+
+    await waitFor(() =>
+      expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+        `/dags/${DAG_ID}/runs/run_in_range/tasks/task_1`,
+      ),
+    );
+  });
+
   it("renders one level per URL segment, with only the current one unlinked", 
async () => {
     render(<DagBreadcrumb />, {
       wrapper: createWrapper(
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
index ac570efae33..a209a33fd88 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -32,8 +32,12 @@ import { BreadcrumbRow, CrumbLink, CrumbStack, CrumbText, 
type CrumbShape } from
 
 import { isStatePending, useAutoRefresh } from "src/utils";
 
+import { DagRunSwitcherButton } from "./DagRunSwitcherButton";
 import { DagSwitcherButton } from "./DagSwitcherButton";
 
+/** Levels that stand for a Dag run — both offer the run search. */
+const RUN_LEVEL_KEYS = new Set(["allRuns", "dagRun"]);
+
 type Crumb = {
   readonly caption: string;
   /** Set alongside `state` so the badge keeps its room while the value is 
still loading. */
@@ -48,11 +52,13 @@ const BreadcrumbItem = ({
   crumb,
   dagId,
   isLast,
+  isMapped,
   shape,
 }: {
   readonly crumb: Crumb;
   readonly dagId: string;
   readonly isLast: boolean;
+  readonly isMapped: boolean;
   readonly shape: CrumbShape;
 }) => {
   const content = (
@@ -73,6 +79,14 @@ const BreadcrumbItem = ({
     );
   }
 
+  if (RUN_LEVEL_KEYS.has(crumb.key) && crumb.to !== undefined) {
+    return (
+      <DagRunSwitcherButton dagId={dagId} isMapped={isMapped} shape={shape} 
to={crumb.to}>
+        {content}
+      </DagRunSwitcherButton>
+    );
+  }
+
   if (isLast || crumb.to === undefined) {
     return <CrumbText shape={shape}>{content}</CrumbText>;
   }
@@ -85,7 +99,7 @@ const BreadcrumbItem = ({
 };
 
 export const DagBreadcrumb = () => {
-  const { t: translate } = useTranslation();
+  const { t: translate } = useTranslation(["common", "dag"]);
   const { dagId = "", groupId, mapIndex = "-1", runId, taskId } = useParams();
   const { pathname } = useLocation();
   const refetchInterval = useAutoRefresh({ dagId });
@@ -140,8 +154,16 @@ export const DagBreadcrumb = () => {
     },
   ];
 
-  // Add dag run breadcrumb
-  if (runId !== undefined) {
+  // The run level is always present, even with no run in the URL: it is where 
runs are switched,
+  // so standing on "all runs" is what lets any page of the Dag jump straight 
to one.
+  if (runId === undefined) {
+    crumbs.push({
+      caption: translate("dagRun_one"),
+      key: "allRuns",
+      to: `/dags/${dagId}/runs`,
+      value: translate("dag:allRuns"),
+    });
+  } else {
     crumbs.push({
       caption: translate("dagRun_one"),
       hasState: true,
@@ -154,15 +176,6 @@ export const DagBreadcrumb = () => {
 
   // Add group breadcrumb
   if (groupId !== undefined) {
-    if (runId === undefined) {
-      crumbs.push({
-        caption: translate("dagRun_one"),
-        key: "allRuns",
-        to: `/dags/${dagId}/runs`,
-        value: translate("allRuns", { ns: "dag" }),
-      });
-    }
-
     crumbs.push({
       caption: translate("taskGroup_one"),
       key: "group",
@@ -192,12 +205,6 @@ export const DagBreadcrumb = () => {
   }
 
   if (runId === undefined && taskId !== undefined) {
-    crumbs.push({
-      caption: translate("dagRun_one"),
-      key: "allRuns",
-      to: `/dags/${dagId}/runs`,
-      value: translate("allRuns", { ns: "dag" }),
-    });
     crumbs.push({
       caption: translate("task_one"),
       key: "task",
@@ -222,6 +229,7 @@ export const DagBreadcrumb = () => {
           crumb={crumb}
           dagId={dagId}
           isLast={index === crumbs.length - 1}
+          isMapped={hasExpandedInstances}
           key={crumb.key}
           shape={{ hasNotch: index > 0, hasPoint: index < crumbs.length - 1 }}
         />
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx
new file mode 100644
index 00000000000..d7caa644e9c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagRunSwitcherButton.tsx
@@ -0,0 +1,89 @@
+/*!
+ * 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 ReactNode, useState } from "react";
+
+import { useTranslation } from "react-i18next";
+
+import { useDagRunServiceGetDagRuns } from "openapi/queries";
+
+import { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
+
+import { useAutoRefresh } from "src/utils";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { SearchDagRuns } from "./SearchDagRuns";
+import { NEWEST_FIRST, SEARCH_LIMIT, buildDagRunOption } from 
"./searchOptions";
+
+const NO_RUNS: Array<DagRunSearchOption> = [];
+
+type Props = {
+  readonly children: ReactNode;
+  readonly dagId: string;
+  readonly isMapped: boolean;
+  readonly shape: CrumbShape;
+  readonly to: string;
+};
+
+/**
+ * The Dag run level of the breadcrumb, with a search over the Dag's runs 
behind its chevron. It
+ * stands in for both a named run and the "all runs" level, so `to` comes from 
the crumb itself.
+ *
+ * The runs the panel lists are loaded here rather than inside it: a query 
that only starts when
+ * the panel opens has nothing to show until it answers, so opening would mean 
a spinner over an
+ * empty list. Here it is loaded ahead of that, and opening only asks for a 
fresh answer over the
+ * one already on screen.
+ *
+ * Polling is held to while the panel is open. It cannot be held to while runs 
are pending the way
+ * the grid does, because this list exists to notice runs that do not exist 
yet; left on, it would
+ * poll from every page of a Dag for a panel nobody opened.
+ */
+export const DagRunSwitcherButton = ({ children, dagId, isMapped, shape, to }: 
Props) => {
+  const { t: translate } = useTranslation();
+  const [open, setOpen] = useState(false);
+  const refetchInterval = useAutoRefresh({ dagId });
+  const { data, refetch } = useDagRunServiceGetDagRuns(
+    { dagId, limit: SEARCH_LIMIT, orderBy: NEWEST_FIRST },
+    undefined,
+    { refetchInterval: open && refetchInterval },
+  );
+  const runs = data === undefined ? NO_RUNS : 
data.dag_runs.map(buildDagRunOption);
+
+  const onOpenChange = (next: boolean) => {
+    setOpen(next);
+
+    // The cached runs are shown straight away; this only catches up what 
changed while closed.
+    if (next) {
+      void refetch();
+    }
+  };
+
+  return (
+    <CrumbSwitcher
+      label={translate("switchDagRun")}
+      onOpenChange={onOpenChange}
+      open={open}
+      search={<SearchDagRuns dagId={dagId} isMapped={isMapped} onClose={() => 
setOpen(false)} runs={runs} />}
+      shape={shape}
+      testId="switch-dag-run"
+      to={to}
+    >
+      {children}
+    </CrumbSwitcher>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
index a975f616efa..0000b653c3e 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagSwitcherButton.tsx
@@ -18,25 +18,20 @@
  */
 import { type ReactNode, useState } from "react";
 
-import { Box } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
-import { BsChevronExpand } from "react-icons/bs";
 
-import { IconButton, Popover, Tooltip } from "src/system-components";
+import { useDagServiceGetDagsUi } from "openapi/queries";
 
-import {
-  CUT_PADDING,
-  CrumbDivider,
-  CrumbGroup,
-  CrumbLink,
-  type CrumbShape,
-  crumbButtonStyles,
-  getWedgePadding,
-} from "src/components/Breadcrumb";
-import { SearchDags } from "src/components/SearchDags";
+import { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
 
 import { SHORTCUTS } from "src/context/keyboardShortcuts";
 import { useShortcut } from "src/hooks/useShortcut";
+import type { DagSearchOption } from "src/utils/option";
+
+import { SearchDags } from "./SearchDags";
+import { SEARCH_LIMIT, buildDagOption } from "./searchOptions";
+
+const NO_DAGS: Array<DagSearchOption> = [];
 
 type Props = {
   readonly children: ReactNode;
@@ -45,12 +40,16 @@ type Props = {
 };
 
 /**
- * The Dag level of the breadcrumb: a two-part control whose left half 
navigates to the Dag and
- * whose right half opens the Dag search, so switching Dags happens where the 
current one is named.
+ * The Dag level of the breadcrumb, with the Dag search behind its chevron.
+ *
+ * The Dags the panel lists are loaded here rather than inside it: a query 
that only starts when
+ * the panel opens has nothing to show until it answers.
  */
 export const DagSwitcherButton = ({ children, dagId, shape }: Props) => {
   const { t: translate } = useTranslation();
   const [open, setOpen] = useState(false);
+  const { data } = useDagServiceGetDagsUi({ dagRunsLimit: 1, limit: 
SEARCH_LIMIT });
+  const dags = data === undefined ? NO_DAGS : data.dags.map(buildDagOption);
 
   useShortcut({
     ...SHORTCUTS.search.searchDags,
@@ -60,44 +59,16 @@ export const DagSwitcherButton = ({ children, dagId, shape 
}: Props) => {
   });
 
   return (
-    <Popover.Root
-      lazyMount
-      onOpenChange={(event) => setOpen(event.open)}
+    <CrumbSwitcher
+      label={translate("switchDag")}
+      onOpenChange={setOpen}
       open={open}
-      positioning={{ placement: "bottom-start" }}
-      unmountOnExit
+      search={<SearchDags dags={dags} onClose={() => setOpen(false)} />}
+      shape={shape}
+      testId="switch-dag"
+      to={`/dags/${dagId}`}
     >
-      {/* Anchored to the whole Dag level rather than the chevron: it shares 
the breadcrumb's start
-          edge and its bottom, so the panel drops clear of the bar and lines 
up with it. */}
-      <Popover.Anchor asChild>
-        <CrumbGroup shape={shape}>
-          <CrumbLink paddingInlineEnd={CUT_PADDING} to={`/dags/${dagId}`}>
-            {children}
-          </CrumbLink>
-          <CrumbDivider />
-          {/* The tooltip wraps the trigger rather than coming from 
IconButton's `label`: nesting it
-            inside `asChild` leaves its own trigger ref unset, and it renders 
away from the button. */}
-          <Tooltip content={translate("switchDag")} disabled={open} portalled>
-            <Popover.Trigger asChild>
-              <IconButton
-                {...crumbButtonStyles}
-                {...getWedgePadding(shape)}
-                alignSelf="stretch"
-                aria-label={translate("switchDag")}
-                data-testid="switch-dag"
-                paddingInlineStart={2}
-              >
-                <BsChevronExpand />
-              </IconButton>
-            </Popover.Trigger>
-          </Tooltip>
-        </CrumbGroup>
-      </Popover.Anchor>
-      <Popover.Content data-testid="switch-dag-popover" width="sm">
-        <Box p={2}>
-          <SearchDags onClose={() => setOpen(false)} />
-        </Box>
-      </Popover.Content>
-    </Popover.Root>
+      {children}
+    </CrumbSwitcher>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
new file mode 100644
index 00000000000..f4d2a61df0e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.test.tsx
@@ -0,0 +1,215 @@
+/*!
+ * 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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { DagRunService } from "openapi/requests/services.gen";
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { SearchDagRuns } from "./SearchDagRuns";
+
+const { searched } = vi.hoisted<{ searched: { current: 
Array<DagRunSearchOption> } }>(() => ({
+  searched: { current: [] },
+}));
+
+// Stands in for the real combo box: it lists whatever the panel was handed 
without a search, and
+// offers one button that runs a search, which is the split this component is 
built around.
+vi.mock("chakra-react-select", () => ({
+  AsyncSelect: ({
+    defaultOptions,
+    loadOptions,
+    onChange,
+  }: {
+    readonly defaultOptions: Array<DagRunSearchOption> | true;
+    readonly loadOptions: (input: string, callback: (options: 
Array<DagRunSearchOption>) => void) => void;
+    readonly onChange: (option: DagRunSearchOption) => void;
+  }) => (
+    <>
+      {(Array.isArray(defaultOptions) ? defaultOptions : []).map((option) => (
+        <button key={option.value} onClick={() => onChange(option)} 
type="button">
+          {`${option.label} (${option.state})`}
+        </button>
+      ))}
+      <button
+        onClick={() =>
+          loadOptions("older", (options) => {
+            searched.current = options;
+          })
+        }
+        type="button"
+      >
+        Search Runs
+      </button>
+    </>
+  ),
+}));
+
+const onClose = vi.fn();
+
+const LocationDisplay = () => <output 
data-testid="location">{useLocation().pathname}</output>;
+
+const renderSearch = (initialEntry: string, route: string, isMapped = false) =>
+  render(
+    <BaseWrapper>
+      <MemoryRouter initialEntries={[initialEntry]}>
+        <Routes>
+          <Route
+            element={
+              <SearchDagRuns
+                dagId="my_dag"
+                isMapped={isMapped}
+                onClose={onClose}
+                runs={[{ label: "run_2", state: "failed", value: "run_2" }]}
+              />
+            }
+            path={route}
+          />
+        </Routes>
+        {/* Outside the route, so it still reports where a switch landed when 
the destination no
+            longer matches the path the search was opened from. */}
+        <LocationDisplay />
+      </MemoryRouter>
+    </BaseWrapper>,
+  );
+
+const buildRun = (dagRunId: string): DAGRunResponse => ({
+  bundle_version: null,
+  conf: null,
+  dag_display_name: "my_dag",
+  dag_id: "my_dag",
+  dag_run_id: dagRunId,
+  dag_versions: [],
+  data_interval_end: null,
+  data_interval_start: null,
+  duration: null,
+  end_date: null,
+  last_scheduling_decision: null,
+  logical_date: null,
+  note: null,
+  partition_date: null,
+  partition_key: null,
+  queued_at: null,
+  run_after: "2025-01-15T00:00:00Z",
+  run_type: "manual",
+  start_date: null,
+  state: "failed",
+  team_name: null,
+  triggered_by: null,
+  triggering_user_name: null,
+});
+
+/** The listed run, as the mocked combo box renders it. */
+const RUN_2 = "run_2 (failed)";
+
+describe("SearchDagRuns", () => {
+  beforeEach(() => {
+    searched.current = [];
+    vi.spyOn(DagRunService, "getDagRuns").mockResolvedValue({
+      dag_runs: [buildRun("run_2")],
+      total_entries: 1,
+    });
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it("lists the runs it was handed, by run id and state, without asking for 
any", () => {
+    renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+    expect(screen.getByRole("button", { name: RUN_2 })).toBeInTheDocument();
+    // Opening the panel is not a reason to go to the server: the level above 
it already has these.
+    expect(DagRunService.getDagRuns).not.toHaveBeenCalled();
+  });
+
+  it("reaches past the listed runs when searched", async () => {
+    renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+    fireEvent.click(screen.getByRole("button", { name: "Search Runs" }));
+
+    await waitFor(() =>
+      expect(DagRunService.getDagRuns).toHaveBeenCalledWith(
+        expect.objectContaining({ dagId: "my_dag", runIdPattern: "older" }),
+      ),
+    );
+    expect(searched.current).toEqual([{ label: "run_2", state: "failed", 
value: "run_2" }]);
+  });
+
+  it("switches to the selected run and closes the panel", () => {
+    renderSearch("/dags/my_dag/runs/run_1", "/dags/:dagId/runs/:runId");
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2");
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it("keeps the task in view when switching runs from a task instance", () => {
+    renderSearch("/dags/my_dag/runs/run_1/tasks/task_1", 
"/dags/:dagId/runs/:runId/tasks/:taskId");
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1");
+  });
+
+  it("lands an expanded task on its list of instances, not on a map index", () 
=> {
+    renderSearch(
+      "/dags/my_dag/runs/run_1/tasks/task_1/mapped/3",
+      "/dags/:dagId/runs/:runId/tasks/:taskId/mapped/:mapIndex",
+      true,
+    );
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    // `/tasks/task_1` would be a row the run picked is not guaranteed to 
have, and renders
+    // "No task instance found".
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1/mapped");
+  });
+
+  it("keeps the open task instance tab across the switch", () => {
+    renderSearch("/dags/my_dag/runs/run_1/tasks/task_1/xcom", 
"/dags/:dagId/runs/:runId/tasks/:taskId/xcom");
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1/xcom");
+  });
+
+  it("keeps the task group in view when switching runs from one", () => {
+    renderSearch(
+      "/dags/my_dag/runs/run_1/tasks/group/group_1",
+      "/dags/:dagId/runs/:runId/tasks/group/:groupId",
+    );
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/group/group_1");
+  });
+
+  it("picks a run from a page that has none selected", () => {
+    renderSearch("/dags/my_dag/tasks/task_1", "/dags/:dagId/tasks/:taskId");
+
+    fireEvent.click(screen.getByRole("button", { name: RUN_2 }));
+
+    
expect(screen.getByTestId("location").textContent).toBe("/dags/my_dag/runs/run_2/tasks/task_1");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
new file mode 100644
index 00000000000..a80bb23340d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDagRuns.tsx
@@ -0,0 +1,129 @@
+/*!
+ * 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 { Flex, Text } from "@chakra-ui/react";
+import { useQueryClient } from "@tanstack/react-query";
+import type { GroupBase, OptionsOrGroups, SingleValue } from 
"chakra-react-select";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+import { useDebouncedCallback } from "use-debounce";
+
+import { UseDagRunServiceGetDagRunsKeyFn } from "openapi/queries";
+import { DagRunService } from "openapi/requests/services.gen";
+import type { DAGRunCollectionResponse } from "openapi/requests/types.gen";
+
+import { SearchSelect } from "src/components/SearchSelect";
+import { StateBadge } from "src/components/StateBadge";
+
+import { buildTaskInstanceUrl } from "src/utils/links";
+import type { DagRunSearchOption } from "src/utils/option";
+
+import { NEWEST_FIRST, SEARCH_LIMIT, buildDagRunOption } from 
"./searchOptions";
+
+const formatOptionLabel = (option: DagRunSearchOption) => (
+  <Flex alignItems="center" gap={2} minW={0}>
+    <StateBadge flexShrink={0} state={option.state} />
+    <Text truncate>{option.label}</Text>
+  </Flex>
+);
+
+export const SearchDagRuns = ({
+  dagId,
+  isMapped,
+  onClose,
+  runs,
+}: {
+  readonly dagId: string;
+  /** Whether the task in view has expanded instances, so the switch lands on 
its list, not a row. */
+  readonly isMapped: boolean;
+  readonly onClose: () => void;
+  /** Already loaded by the breadcrumb level, so opening the panel shows them 
straight away. */
+  readonly runs: Array<DagRunSearchOption>;
+}) => {
+  const { t: translate } = useTranslation(["dags", "common"]);
+  const queryClient = useQueryClient();
+  const navigate = useNavigate();
+  const { groupId, taskId } = useParams();
+  const { pathname } = useLocation();
+
+  // The task in view carries across the switch, through the same builder the 
grid and keyboard
+  // navigation use: it keeps the open tab, and sends an expanded task to its 
list of instances
+  // rather than to a map index that the run picked is not guaranteed to have.
+  const onSelect = (selected: SingleValue<DagRunSearchOption>) => {
+    if (!selected) {
+      return;
+    }
+
+    const target =
+      groupId === undefined && taskId === undefined
+        ? `/dags/${dagId}/runs/${selected.value}`
+        : buildTaskInstanceUrl({
+            currentPathname: pathname,
+            dagId,
+            isGroup: groupId !== undefined,
+            isMapped,
+            runId: selected.value,
+            taskId: groupId ?? taskId ?? "",
+          });
+
+    onClose();
+    void Promise.resolve(navigate(target));
+  };
+
+  const searchDagRunsDebounced = useDebouncedCallback(
+    (
+      inputValue: string,
+      callback: (options: OptionsOrGroups<DagRunSearchOption, 
GroupBase<DagRunSearchOption>>) => void,
+    ) => {
+      void queryClient.fetchQuery({
+        queryFn: () =>
+          DagRunService.getDagRuns({
+            dagId,
+            limit: SEARCH_LIMIT,
+            orderBy: NEWEST_FIRST,
+            runIdPattern: inputValue,
+          }).then((matches: DAGRunCollectionResponse) => {
+            const options = matches.dag_runs.map(buildDagRunOption);
+
+            callback(options);
+
+            return options;
+          }),
+        queryKey: UseDagRunServiceGetDagRunsKeyFn({
+          dagId,
+          limit: SEARCH_LIMIT,
+          orderBy: NEWEST_FIRST,
+          runIdPattern: inputValue,
+        }),
+        staleTime: 0,
+      });
+    },
+    300,
+  );
+
+  return (
+    <SearchSelect
+      defaultOptions={runs}
+      formatOptionLabel={formatOptionLabel}
+      loadingMessage={translate("common:loading")}
+      loadOptions={searchDagRunsDebounced}
+      onChange={onSelect}
+      placeholder={translate("search.dagRuns")}
+    />
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
similarity index 99%
rename from 
airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
rename to airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
index b427fdd6ebf..af4371a5875 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.test.tsx
@@ -103,7 +103,7 @@ const renderSearch = ({
   render(
     <BaseWrapper>
       <MemoryRouter initialEntries={[initialEntry]}>
-        <SearchDags onClose={onClose} />
+        <SearchDags dags={[]} onClose={onClose} />
         <LocationDisplay />
       </MemoryRouter>
     </BaseWrapper>,
diff --git 
a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
similarity index 60%
rename from airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
rename to airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
index 6130a2e8b21..257493c7a0d 100644
--- a/airflow-core/src/airflow/ui/src/components/SearchDags/SearchDags.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchDags.tsx
@@ -16,28 +16,25 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Field, Flex, Text } from "@chakra-ui/react";
+import { Flex, Text } from "@chakra-ui/react";
 import { useQueryClient } from "@tanstack/react-query";
 import type { GroupBase, OptionsOrGroups, SingleValue } from 
"chakra-react-select";
-import { AsyncSelect } from "chakra-react-select";
 import { useTranslation } from "react-i18next";
 import { useMatches, useNavigate } from "react-router-dom";
 import { useDebouncedCallback } from "use-debounce";
 
 import { UseDagServiceGetDagsUiKeyFn } from "openapi/queries";
 import { DagService } from "openapi/requests/services.gen";
-import type {
-  DAGWithLatestDagRunsCollectionResponse,
-  DAGWithLatestDagRunsResponse,
-} from "openapi/requests/types.gen";
+import type { DAGWithLatestDagRunsCollectionResponse } from 
"openapi/requests/types.gen";
 
+import { SearchSelect } from "src/components/SearchSelect";
 import { StateBadge } from "src/components/StateBadge";
 
 import { TabEntity } from "src/constants/tab";
 import type { DagSearchOption } from "src/utils/option";
 import { getTabPath } from "src/utils/tab";
 
-import { Control } from "./SearchDagsControl";
+import { SEARCH_LIMIT, buildDagOption } from "./searchOptions";
 
 const formatOptionLabel = (option: DagSearchOption) => (
   <Flex alignItems="center" gap={2} minW={0}>
@@ -46,12 +43,18 @@ const formatOptionLabel = (option: DagSearchOption) => (
   </Flex>
 );
 
-export const SearchDags = ({ onClose }: { readonly onClose: () => void }) => {
-  const { t: translate } = useTranslation("dags");
+export const SearchDags = ({
+  dags,
+  onClose,
+}: {
+  /** Already loaded by the breadcrumb level, so opening the panel shows them 
straight away. */
+  readonly dags: Array<DagSearchOption>;
+  readonly onClose: () => void;
+}) => {
+  const { t: translate } = useTranslation(["dags", "common"]);
   const queryClient = useQueryClient();
   const matches = useMatches();
   const navigate = useNavigate();
-  const SEARCH_LIMIT = 10;
 
   const onSelect = (selected: SingleValue<DagSearchOption>) => {
     if (selected) {
@@ -74,13 +77,8 @@ export const SearchDags = ({ onClose }: { readonly onClose: 
() => void }) => {
             dagDisplayNamePrefixPattern: inputValue,
             dagRunsLimit: 1,
             limit: SEARCH_LIMIT,
-          }).then((data: DAGWithLatestDagRunsCollectionResponse) => {
-            const options = data.dags.map((dag: DAGWithLatestDagRunsResponse) 
=> ({
-              isBackfillable: dag.is_backfillable,
-              label: dag.dag_display_name || dag.dag_id,
-              state: dag.latest_dag_runs[0]?.state ?? null,
-              value: dag.dag_id,
-            }));
+          }).then((found: DAGWithLatestDagRunsCollectionResponse) => {
+            const options = found.dags.map(buildDagOption);
 
             callback(options);
 
@@ -97,32 +95,13 @@ export const SearchDags = ({ onClose }: { readonly onClose: 
() => void }) => {
   );
 
   return (
-    <Field.Root>
-      <AsyncSelect
-        backspaceRemovesValue={true}
-        // The popover is the card. Drop the floating menu's own positioning 
and chrome so the
-        // results flow inside it directly under the input, instead of reading 
as a second card.
-        chakraStyles={{
-          menu: () => ({ marginTop: 2, width: "100%" }),
-          menuList: (provided) => ({
-            ...provided,
-            background: "transparent",
-            borderRadius: 0,
-            boxShadow: "none",
-            paddingInline: 0,
-            zIndex: "auto",
-          }),
-        }}
-        components={{ Control, DropdownIndicator: null }}
-        defaultOptions
-        filterOption={undefined}
-        formatOptionLabel={formatOptionLabel}
-        loadOptions={searchDagDebounced}
-        menuIsOpen
-        onChange={onSelect}
-        placeholder={translate("search.dags")}
-        value={null} // null is required 
https://github.com/JedWatson/react-select/issues/3066
-      />
-    </Field.Root>
+    <SearchSelect
+      defaultOptions={dags}
+      formatOptionLabel={formatOptionLabel}
+      loadingMessage={translate("common:loading")}
+      loadOptions={searchDagDebounced}
+      onChange={onSelect}
+      placeholder={translate("search.dags")}
+    />
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
new file mode 100644
index 00000000000..4a47f818c8f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.test.ts
@@ -0,0 +1,50 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import type { DAGRunResponse, DAGWithLatestDagRunsResponse } from 
"openapi/requests/types.gen";
+
+import { buildDagOption, buildDagRunOption } from "./searchOptions";
+
+const dagRun = { dag_run_id: "run_1", state: "success" } as DAGRunResponse;
+const dag = {
+  dag_display_name: "My Dag",
+  dag_id: "my_dag",
+  latest_dag_runs: [],
+} as unknown as DAGWithLatestDagRunsResponse;
+
+describe("searchOptions", () => {
+  // react-select keeps keyboard focus only while the focused option is still 
in `options` by
+  // reference. TanStack hands back the same row object for an unchanged row, 
so an option built
+  // from it has to come back unchanged too, or arrowing through the list 
resets on every poll.
+  it("gives a run row the same option object every time", () => {
+    expect(buildDagRunOption(dagRun)).toBe(buildDagRunOption(dagRun));
+  });
+
+  it("gives a Dag row the same option object every time", () => {
+    expect(buildDagOption(dag)).toBe(buildDagOption(dag));
+  });
+
+  it("builds a fresh option for a row that changed", () => {
+    const changed = { ...dagRun, state: "failed" } as DAGRunResponse;
+
+    expect(buildDagRunOption(changed)).not.toBe(buildDagRunOption(dagRun));
+    expect(buildDagRunOption(changed).state).toBe("failed");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
new file mode 100644
index 00000000000..71e5c07d9a5
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/searchOptions.ts
@@ -0,0 +1,66 @@
+/*!
+ * 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 { DAGRunResponse, DAGWithLatestDagRunsResponse } from 
"openapi/requests/types.gen";
+
+import type { DagRunSearchOption, DagSearchOption } from "src/utils/option";
+
+/** How many Dags or runs a breadcrumb search offers before anything is typed, 
and finds per search. */
+export const SEARCH_LIMIT = 10;
+
+export const NEWEST_FIRST = ["-run_after"];
+
+// react-select keeps keyboard focus only while the focused option is still in 
`options` by
+// reference, so rebuilding every option on each poll would drop the user back 
to the first row
+// mid-arrow-key. TanStack hands back the same row object for a row that did 
not change, so
+// caching against that object keeps the options for unchanged rows stable too.
+const dagOptions = new WeakMap<DAGWithLatestDagRunsResponse, 
DagSearchOption>();
+const dagRunOptions = new WeakMap<DAGRunResponse, DagRunSearchOption>();
+
+export const buildDagOption = (dag: DAGWithLatestDagRunsResponse): 
DagSearchOption => {
+  const cached = dagOptions.get(dag);
+
+  if (cached !== undefined) {
+    return cached;
+  }
+
+  const option = {
+    isBackfillable: dag.is_backfillable,
+    label: dag.dag_display_name || dag.dag_id,
+    state: dag.latest_dag_runs[0]?.state ?? null,
+    value: dag.dag_id,
+  };
+
+  dagOptions.set(dag, option);
+
+  return option;
+};
+
+export const buildDagRunOption = (dagRun: DAGRunResponse): DagRunSearchOption 
=> {
+  const cached = dagRunOptions.get(dagRun);
+
+  if (cached !== undefined) {
+    return cached;
+  }
+
+  const option = { label: dagRun.dag_run_id, state: dagRun.state, value: 
dagRun.dag_run_id };
+
+  dagRunOptions.set(dagRun, option);
+
+  return option;
+};
diff --git a/airflow-core/src/airflow/ui/src/utils/option.ts 
b/airflow-core/src/airflow/ui/src/utils/option.ts
index ed9f463f161..526fe3f39e7 100644
--- a/airflow-core/src/airflow/ui/src/utils/option.ts
+++ b/airflow-core/src/airflow/ui/src/utils/option.ts
@@ -24,6 +24,10 @@ export type Option = {
   readonly value: string;
 };
 
+export type DagRunSearchOption = {
+  readonly state: DagRunState;
+} & Option;
+
 export type DagSearchOption = {
   readonly isBackfillable: boolean;
   readonly state: DagRunState | null;

Reply via email to