This is an automated email from the ASF dual-hosted git repository.
jedcunningham 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 ffac35fc92b Add a task switcher to the Dag breadcrumb (#76850)
ffac35fc92b is described below
commit ffac35fc92be40c651df0e7e07682e3ad1cab503
Author: Jed Cunningham <[email protected]>
AuthorDate: Tue Nov 10 15:41:11 2026 -0700
Add a task switcher to the Dag breadcrumb (#76850)
The Dag, Dag run and map index levels of the breadcrumb each open a
search behind a chevron, but the task level had none. In a large Dag
there was no quick way to jump to another task by name.
The task and task instance levels now open a search over the Dag's
tasks, listed by the name the grid shows with their group path, with
the same prefix / match-anywhere toggle as the Dag search. The list
comes from the grid structure, which is the only source that marks the
tasks of a mapped task group as mapped.
Within a run, only tasks the run has an instance of are listed, each
with its state in that run. Picking one keeps the run, the tab and the
other URL filters; a mapped task goes to its /mapped summary and a task
inside a loop lands on its newest pass, as from the grid. On a task
page with no run, it goes to the task across runs on the same tab.
The list and the run's states load when the panel opens, so badges are
current and a page whose switcher is never opened makes no extra
requests.
---
.../airflow/ui/public/i18n/locales/en/common.json | 1 +
.../ui/src/components/Breadcrumb/CrumbSwitcher.tsx | 7 +-
.../ui/src/layouts/Details/DagBreadcrumb.test.tsx | 213 ++++++++++++++++++++-
.../ui/src/layouts/Details/DagBreadcrumb.tsx | 22 +++
.../airflow/ui/src/layouts/Details/SearchTasks.tsx | 190 ++++++++++++++++++
.../ui/src/layouts/Details/TaskSwitcherButton.tsx | 64 +++++++
6 files changed, 493 insertions(+), 4 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 7075f40fa72..07219ff6f2f 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
@@ -440,6 +440,7 @@
"switchDag": "Switch $t(dag_one)",
"switchDagRun": "Switch $t(dagRun_one)",
"switchMapIndex": "Switch $t(mapIndex)",
+ "switchTask": "Switch $t(task_one)",
"table": {
"completedAt": "Completed at",
"createdAt": "Created at",
diff --git
a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
index 05703225b1b..74e0e2660a5 100644
--- a/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Breadcrumb/CrumbSwitcher.tsx
@@ -30,6 +30,8 @@ type Props = {
readonly children: ReactNode;
/** Width of the panel. The default suits a search; a short fixed list wants
to shrink to fit. */
readonly contentWidth?: BoxProps["width"];
+ /** Marks the naming half as the page the user is on, as `CrumbText` does
for a plain level. */
+ readonly isCurrent?: boolean;
/** Names the chevron for the tooltip and for assistive technology. */
readonly label: string;
readonly onOpenChange: (open: boolean) => void;
@@ -51,6 +53,7 @@ type Props = {
export const CrumbSwitcher = ({
children,
contentWidth = "sm",
+ isCurrent = false,
label,
onOpenChange,
open,
@@ -71,7 +74,9 @@ export const CrumbSwitcher = ({
<Popover.Anchor asChild>
<CrumbGroup shape={shape}>
{to === undefined ? (
- <CrumbStatic paddingInlineEnd={CUT_PADDING}>{children}</CrumbStatic>
+ <CrumbStatic aria-current={isCurrent ? "page" : undefined}
paddingInlineEnd={CUT_PADDING}>
+ {children}
+ </CrumbStatic>
) : (
<CrumbLink paddingInlineEnd={CUT_PADDING} to={to}>
{children}
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 80215afb5d8..4a97817fea8 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
@@ -19,12 +19,16 @@
import type { PropsWithChildren } from "react";
import "@testing-library/jest-dom/vitest";
-import { cleanup, fireEvent, render, screen, waitFor } from
"@testing-library/react";
+import { cleanup, fireEvent, render, screen, waitFor, within } from
"@testing-library/react";
+import { http, HttpResponse } from "msw";
import { MemoryRouter, Route, Routes, matchRoutes, useLocation, useMatches }
from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import { DagRunService, DagService, TaskInstanceService } from
"openapi/requests/services.gen";
+import { DagRunService, DagService, GridService, TaskInstanceService } from
"openapi/requests/services.gen";
+import { StateIcon } from "src/components/StateIcon";
+
+import { server } from "src/mocks/server";
import { routerConfig } from "src/router";
import { BaseWrapper } from "src/utils/Wrapper";
@@ -49,6 +53,13 @@ const LOOP_PASSES = [
// Most levels have no loop in scope; the loop tests opt in by setting this.
let mockLoopOptions: Array<unknown> = [];
+// Empty unless a test gives the run on screen some task instance states.
+let mockSummaries = new Map<string, unknown>();
+
+vi.mock("src/queries/useGridTISummaries.ts", () => ({
+ useGridTiSummariesStream: () => ({ summariesByRunId: mockSummaries }),
+}));
+
vi.mock("src/queries/useLoopFilterOptions", async (importOriginal) => {
const original = await importOriginal();
@@ -60,7 +71,49 @@ const DAG_ID = "tutorial_taskflow_api";
const leadsToErrorPage = (href: string) => matchRoutes(routerConfig,
href)?.at(-1)?.route.path === "*";
-const LocationProbe = () => <div
data-testid="location-pathname">{useLocation().pathname}</div>;
+const LocationProbe = () => {
+ const { pathname, search } = useLocation();
+
+ return (
+ <>
+ <div data-testid="location-pathname">{pathname}</div>
+ <div data-testid="location-search">{search}</div>
+ </>
+ );
+};
+
+const TASK_PATH = `/dags/${DAG_ID}/runs/run_1/tasks/task_1`;
+const TASK_ROUTE = "/dags/:dagId/runs/:runId/tasks/:taskId";
+
+// What the grid structure says, which is the only source that marks a mapped
group's tasks mapped.
+const STRUCTURE = [
+ { children: null, id: "task_1", is_mapped: false, label: "task_1" },
+ { children: null, id: "load_orders", is_mapped: false, label: "Load orders"
},
+ { children: null, id: "load_users", is_mapped: true, label: "Load users" },
+ {
+ children: [{ children: null, id: "per_region.fetch", is_mapped: true,
label: "Fetch region" }],
+ id: "per_region",
+ is_mapped: true,
+ label: "per_region",
+ },
+ {
+ children: [{ children: null, id: "accumulate.increment", is_mapped: false,
label: "Increment counter" }],
+ id: "accumulate",
+ is_loop: true,
+ is_mapped: false,
+ label: "accumulate",
+ },
+];
+
+const buildRunSummaries = (taskInstances: Array<Record<string, unknown>>) =>
+ new Map<string, unknown>([["run_1", { dag_id: DAG_ID, run_id: "run_1",
task_instances: taskInstances }]]);
+
+/** Opens the task switcher and waits for its list, returning the option named
`name`. */
+const openTaskSearch = async (name: RegExp = /Load orders/u) => {
+ fireEvent.click(await screen.findByTestId("switch-task"));
+
+ return screen.findByRole("option", { name });
+};
const createWrapper =
(path: string, route: string) =>
@@ -83,7 +136,10 @@ const createWrapper =
);
beforeEach(() => {
+ server.use(http.get("/ui/grid/structure/:dagId", () =>
HttpResponse.json(STRUCTURE)));
mockLoopOptions = [];
+ mockSummaries = new Map();
+ localStorage.clear();
vi.mocked(useMatches).mockReturnValue([]);
});
@@ -215,6 +271,157 @@ describe("DagBreadcrumb", () => {
);
});
+ it("loads nothing for the task switcher until it is opened", async () => {
+ const getDagStructure = vi.spyOn(GridService, "getDagStructure");
+
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH, TASK_ROUTE)
});
+
+ // The task level's own crumb resolves through a request too, so once it
shows its name the
+ // page has had every chance to ask for the switcher's list.
+ await screen.findByTestId("switch-task");
+ await screen.findByText("task_1");
+
+ expect(getDagStructure).not.toHaveBeenCalled();
+
+ await openTaskSearch();
+
+ expect(getDagStructure).toHaveBeenCalledTimes(1);
+ });
+
+ it("names the task level as the current page when it is the last one", async
() => {
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH, TASK_ROUTE)
});
+
+ await screen.findByTestId("switch-task");
+
+
expect(screen.getByText("task_1").closest("[aria-current='page']")).not.toBeNull();
+ });
+
+ it("switches task by display name prefix, keeping the run and the tab",
async () => {
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(`${TASK_PATH}/xcom`, `${TASK_ROUTE}/*`),
+ });
+
+ await openTaskSearch();
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "LOAD O"
} });
+
+ expect(screen.queryByRole("option", { name: /Load users/u
})).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("option", { name: /Load orders/u }));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+ `/dags/${DAG_ID}/runs/run_1/tasks/load_orders/xcom`,
+ ),
+ );
+ });
+
+ it("matches a task's display name or group anywhere once the toggle is on",
async () => {
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH, TASK_ROUTE)
});
+
+ await openTaskSearch();
+ fireEvent.change(screen.getByRole("combobox"), { target: { value: "region"
} });
+
+ expect(screen.queryByRole("option")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("advanced-search-toggle"));
+
+ // "per_region" is the group, so its task is found by either its own name
or the group's.
+ expect(await screen.findByRole("option", { name: /Fetch region/u
})).toHaveTextContent("per_region");
+ });
+
+ it("sends a task inside a mapped group to its expanded instances", async ()
=> {
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH,
`${TASK_ROUTE}/*`) });
+
+ await openTaskSearch();
+ fireEvent.click(screen.getByRole("option", { name: /Fetch region/u }));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+ `/dags/${DAG_ID}/runs/run_1/tasks/per_region.fetch/mapped`,
+ ),
+ );
+ });
+
+ it("lands a task inside a loop on its newest pass", async () => {
+ mockSummaries = buildRunSummaries([
+ { state: "success", task_id: "task_1" },
+ {
+ latest_region_id: "region-1",
+ latest_region_index: 2,
+ state: "success",
+ task_id: "accumulate.increment",
+ },
+ ]);
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(`${TASK_PATH}?try_number=2&dag_run_state=failed`,
`${TASK_ROUTE}/*`),
+ });
+
+ await openTaskSearch(/Increment counter/u);
+ fireEvent.click(screen.getByRole("option", { name: /Increment counter/u
}));
+
+ await waitFor(() =>
+ expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+ `/dags/${DAG_ID}/runs/run_1/tasks/accumulate.increment`,
+ ),
+ );
+ const search = new
URLSearchParams(screen.getByTestId("location-search").textContent);
+
+ expect(search.get("region_id")).toBe("region-1");
+ expect(search.get("region_index")).toBe("2");
+ // The try belonged to the task being left; the other filters still apply.
+ expect(search.get("try_number")).toBeNull();
+ expect(search.get("dag_run_state")).toBe("failed");
+ });
+
+ it("lists only the run's tasks, each with its state in that run", async ()
=> {
+ mockSummaries = buildRunSummaries([
+ { state: "failed", task_id: "load_orders" },
+ { state: "success", task_id: "load_users" },
+ ]);
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH, TASK_ROUTE)
});
+
+ const option = await openTaskSearch();
+ const { container: failedIcon } = render(<StateIcon state="failed" />);
+
+
expect(within(option).getByTestId("state-badge").innerHTML).toBe(failedIcon.innerHTML);
+ // A task the run has no instance of, such as one added in a later Dag
version, would open a 404.
+ expect(screen.queryByRole("option", { name: /Increment counter/u
})).not.toBeInTheDocument();
+ });
+
+ it("ignores another Dag's run that shares the run id", async () => {
+ mockSummaries = new Map([
+ [
+ "run_1",
+ {
+ dag_id: "other_dag",
+ run_id: "run_1",
+ task_instances: [{ state: "failed", task_id: "load_orders" }],
+ },
+ ],
+ ]);
+ render(<DagBreadcrumb />, { wrapper: createWrapper(TASK_PATH, TASK_ROUTE)
});
+
+ const option = await openTaskSearch();
+
+
expect(within(option).queryByTestId("state-badge")).not.toBeInTheDocument();
+ });
+
+ it("switches task across runs on a task page with no run, keeping the tab",
async () => {
+ render(<DagBreadcrumb />, {
+ wrapper: createWrapper(`/dags/${DAG_ID}/tasks/task_1/events`,
"/dags/:dagId/tasks/:taskId/*"),
+ });
+
+ const option = await openTaskSearch();
+
+
expect(within(option).queryByTestId("state-badge")).not.toBeInTheDocument();
+ fireEvent.click(option);
+
+ await waitFor(() =>
+ expect(screen.getByTestId("location-pathname")).toHaveTextContent(
+ `/dags/${DAG_ID}/tasks/load_orders/events`,
+ ),
+ );
+ });
+
it("offers the instance search at the map index level", 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 20fb4508831..b0e62402286 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DagBreadcrumb.tsx
@@ -40,6 +40,7 @@ import { DagRunSwitcherButton } from "./DagRunSwitcherButton";
import { DagSwitcherButton } from "./DagSwitcherButton";
import { IterationSwitcherButton } from "./IterationSwitcherButton";
import { MapIndexSwitcherButton } from "./MapIndexSwitcherButton";
+import { TaskSwitcherButton } from "./TaskSwitcherButton";
/** Levels that stand for a Dag run — both offer the run search. */
const RUN_LEVEL_KEYS = new Set(["allRuns", "dagRun"]);
@@ -61,7 +62,9 @@ const BreadcrumbItem = ({
isLast,
isMapped,
loopOptions,
+ runId,
shape,
+ taskId,
}: {
readonly crumb: Crumb;
readonly dagId: string;
@@ -69,7 +72,9 @@ const BreadcrumbItem = ({
readonly isLast: boolean;
readonly isMapped: boolean;
readonly loopOptions: Array<LoopOption>;
+ readonly runId: string | undefined;
readonly shape: CrumbShape;
+ readonly taskId: string | undefined;
}) => {
const content = (
<CrumbStack
@@ -105,6 +110,21 @@ const BreadcrumbItem = ({
);
}
+ if (crumb.key === "task") {
+ return (
+ <TaskSwitcherButton
+ dagId={dagId}
+ isCurrent={isLast}
+ runId={runId}
+ shape={shape}
+ taskId={taskId ?? ""}
+ to={isLast ? undefined : crumb.to}
+ >
+ {content}
+ </TaskSwitcherButton>
+ );
+ }
+
if (crumb.key === "mapIndex") {
return (
<MapIndexSwitcherButton dagId={dagId} shape={shape}>
@@ -306,7 +326,9 @@ export const DagBreadcrumb = () => {
isMapped={hasExpandedInstances}
key={crumb.key}
loopOptions={loopOptions}
+ runId={runId}
shape={{ hasNotch: index > 0, hasPoint: index < crumbs.length - 1 }}
+ taskId={taskId}
/>
))}
</BreadcrumbRow>
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/SearchTasks.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/SearchTasks.tsx
new file mode 100644
index 00000000000..d72107c43e4
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/SearchTasks.tsx
@@ -0,0 +1,190 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from "react";
+
+import { Flex, Text } from "@chakra-ui/react";
+import type { SingleValue } from "chakra-react-select";
+import { useTranslation } from "react-i18next";
+import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
+
+import { useGridServiceGetDagStructure } from "openapi/queries";
+import type { GridNodeResponse, LightGridTaskInstanceSummary } from
"openapi/requests/types.gen";
+
+import { AdvancedSearchToggle } from "src/components/AdvancedSearchToggle";
+import { SearchSelect } from "src/components/SearchSelect";
+import { StateBadge } from "src/components/StateBadge";
+
+import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
+import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
+import { setExecutionCoordinates, stripExecutionParams } from
"src/utils/executionParams";
+import { buildTaskInstanceUrl } from "src/utils/links";
+
+import { SEARCH_LIMIT } from "./searchOptions";
+
+const ADVANCED_SEARCH_KEY = "breadcrumb-tasks";
+
+type TaskSearchOption = {
+ /** The groups the task sits in, which tells apart tasks that share a name
in different groups. */
+ readonly groupPath: string;
+ readonly isMapped: boolean;
+ readonly label: string;
+ readonly summary?: LightGridTaskInstanceSummary;
+ readonly value: string;
+};
+
+type TaskNode = Omit<TaskSearchOption, "summary">;
+
+const formatOptionLabel = (option: TaskSearchOption) => (
+ <Flex alignItems="center" gap={2} minW={0}>
+ {option.summary === undefined ? undefined : <StateBadge flexShrink={0}
state={option.summary.state} />}
+ <Text flexShrink={0} truncate>
+ {option.label}
+ </Text>
+ {option.groupPath === "" ? undefined : (
+ <Text color="fg.muted" fontSize="xs" truncate>
+ {option.groupPath}
+ </Text>
+ )}
+ </Flex>
+);
+
+/**
+ * The tasks of the grid's structure, groups flattened away. The structure,
not the tasks endpoint,
+ * is what says a task inside a mapped group is expanded: the task itself
reports `is_mapped: false`.
+ */
+const flattenTasks = (nodes: Array<GridNodeResponse>, groups: Array<string> =
[]): Array<TaskNode> =>
+ nodes.flatMap((node) =>
+ node.children === null || node.children === undefined
+ ? [
+ {
+ groupPath: groups.join(" / "),
+ isMapped: Boolean(node.is_mapped),
+ label: node.label,
+ value: node.id,
+ },
+ ]
+ : flattenTasks(node.children, [...groups, node.label]),
+ );
+
+const matchesTask = (option: TaskSearchOption, text: string, matchAnywhere:
boolean) => {
+ const needle = text.toLowerCase();
+
+ return matchAnywhere
+ ? `${option.label} ${option.groupPath}`.toLowerCase().includes(needle)
+ : option.label.toLowerCase().startsWith(needle);
+};
+
+/** The sub-route of a task page (`events`, `task_instances`, ...), which a
switch carries over. */
+const getTaskTabPath = (pathname: string, dagId: string, taskId: string) => {
+ const base = `/dags/${dagId}/tasks/${taskId}`;
+
+ return pathname.startsWith(`${base}/`) ? pathname.slice(base.length) : "";
+};
+
+export const SearchTasks = ({
+ currentTaskId,
+ dagId,
+ onClose,
+ runId,
+}: {
+ readonly currentTaskId: string;
+ readonly dagId: string;
+ readonly onClose: () => void;
+ readonly runId: string | undefined;
+}) => {
+ const { t: translate } = useTranslation(["dags", "common"]);
+ const [text, setText] = useState("");
+ // Its own key, like the Dag search's, so toggling here leaves other
searches as they are.
+ const { enabled: matchAnywhere, onToggle } =
useAdvancedSearch(ADVANCED_SEARCH_KEY);
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const [searchParams] = useSearchParams();
+ // Loaded when the panel opens rather than with the page, so a page whose
switcher is never opened
+ // pays for neither, and every opening shows states as they are now.
+ const { data: structure } = useGridServiceGetDagStructure({ dagId });
+ const { summariesByRunId } = useGridTiSummariesStream({
+ dagId,
+ runIds: runId === undefined ? [] : [runId],
+ });
+ const runSummary = runId === undefined ? undefined :
summariesByRunId.get(runId);
+ // The stream keys by run id alone, so an answer for another Dag's run of
the same id is not ours.
+ const summaries =
+ runSummary?.dag_id === dagId
+ ? new Map(runSummary.task_instances.map((summary) => [summary.task_id,
summary]))
+ : undefined;
+
+ // Within a run, only tasks the run has an instance of: the structure spans
several Dag versions,
+ // and a task the run never had would open onto a missing instance.
+ const options: Array<TaskSearchOption> = flattenTasks(structure ??
[]).flatMap((task) => {
+ if (summaries === undefined) {
+ return [task];
+ }
+ const summary = summaries.get(task.value);
+
+ return summary === undefined ? [] : [{ ...task, summary }];
+ });
+
+ const onSelect = (selected: SingleValue<TaskSearchOption>) => {
+ if (!selected) {
+ return;
+ }
+
+ if (runId === undefined) {
+ onClose();
+ void Promise.resolve(
+
navigate(`/dags/${dagId}/tasks/${selected.value}${getTaskTabPath(pathname,
dagId, currentTaskId)}`),
+ );
+
+ return;
+ }
+
+ const path = buildTaskInstanceUrl({
+ currentPathname: pathname,
+ dagId,
+ isMapped: selected.isMapped,
+ runId,
+ taskId: selected.value,
+ });
+ // A task inside a loop has a row per pass and the API needs to be told
which; the grid lands on
+ // the newest, and so does this.
+ const search = setExecutionCoordinates(
+ stripExecutionParams(searchParams),
+ selected.summary?.latest_region_id,
+ selected.summary?.latest_region_index,
+ ).toString();
+
+ onClose();
+ void Promise.resolve(navigate(`${path}${search === "" ? "" :
`?${search}`}`));
+ };
+
+ const matched =
+ text === "" ? options : options.filter((option) => matchesTask(option,
text, matchAnywhere));
+
+ return (
+ <SearchSelect
+ controlEnd={<AdvancedSearchToggle enabled={matchAnywhere}
onToggle={onToggle} variant="addon" />}
+ formatOptionLabel={formatOptionLabel}
+ inputValue={text}
+ onChange={onSelect}
+ onInputChange={setText}
+ options={matched.slice(0, SEARCH_LIMIT)}
+ placeholder={translate("search.tasks")}
+ />
+ );
+};
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Details/TaskSwitcherButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/TaskSwitcherButton.tsx
new file mode 100644
index 00000000000..a570b226d85
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/TaskSwitcherButton.tsx
@@ -0,0 +1,64 @@
+/*!
+ * 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 { CrumbSwitcher, type CrumbShape } from "src/components/Breadcrumb";
+
+import { SearchTasks } from "./SearchTasks";
+
+type Props = {
+ readonly children: ReactNode;
+ readonly dagId: string;
+ readonly isCurrent: boolean;
+ readonly runId: string | undefined;
+ readonly shape: CrumbShape;
+ readonly taskId: string;
+ readonly to?: string;
+};
+
+/**
+ * The task level of the breadcrumb, with a search over the Dag's tasks behind
its chevron.
+ *
+ * Within a run, each task carries its state in that run, and picking one
keeps the run, the open
+ * tab where the picked task has it too, and the pass of a loop. Without a
run, it goes to the task
+ * across runs, on the same tab.
+ */
+export const TaskSwitcherButton = ({ children, dagId, isCurrent, runId, shape,
taskId, to }: Props) => {
+ const { t: translate } = useTranslation();
+ const [open, setOpen] = useState(false);
+
+ return (
+ <CrumbSwitcher
+ isCurrent={isCurrent}
+ label={translate("switchTask")}
+ onOpenChange={setOpen}
+ open={open}
+ search={
+ <SearchTasks currentTaskId={taskId} dagId={dagId} onClose={() =>
setOpen(false)} runId={runId} />
+ }
+ shape={shape}
+ testId="switch-task"
+ to={to}
+ >
+ {children}
+ </CrumbSwitcher>
+ );
+};