From f5b15d9d14c542301abd2561181b4694d4df1f70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Thu, 29 Jan 2026 20:59:59 -0500 Subject: [PATCH 1/3] feat(ui): Preserve active tabs with entity-based local storage --- .../src/airflow/ui/src/hooks/useTabMemory.ts | 92 +++++++++++++++++++ .../ui/src/layouts/Details/DetailsLayout.tsx | 23 ++++- .../GroupTaskInstance/GroupTaskInstance.tsx | 3 +- .../MappedTaskInstance/MappedTaskInstance.tsx | 3 +- .../src/airflow/ui/src/pages/Task/Task.tsx | 3 +- .../src/pages/TaskInstance/TaskInstance.tsx | 8 +- 6 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts diff --git a/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts b/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts new file mode 100644 index 0000000000000..41bd5bec67077 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts @@ -0,0 +1,92 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useLocalStorage } from "usehooks-ts"; + +import type { TabItem } from "./useRequiredActionTabs"; + +export const TabStorageKeys = { + DAG: "tab_view_dag", + MAPPED_TASK_INSTANCE: "tab_view_mapped_ti", + RUN: "tab_view_run", + TASK: "tab_view_task", + TASK_GROUP: "tab_view_task_group", + TASK_INSTANCE: "tab_view_ti", +} as const; + +export type TabStorageKey = (typeof TabStorageKeys)[keyof typeof TabStorageKeys]; + +type UseTabMemoryOptions = { + currentPath: string; + enabled?: boolean; + storageKey: TabStorageKey; + tabs: Array; +}; + +export const useTabMemory = (options: UseTabMemoryOptions) => { + const { currentPath, enabled = true, storageKey, tabs } = options; + const navigate = useNavigate(); + + const normalizedPath = currentPath.replace(/\/$/u, ""); + const segments = normalizedPath.split("/"); + const lastSegment = segments[segments.length - 1] ?? ""; + const isTabSegment = tabs.some((tab) => tab.value === lastSegment); + const baseUrl = isTabSegment && lastSegment !== "" ? segments.slice(0, -1).join("/") : normalizedPath; + + // Use entity-based storage key instead of URL-based key + const [lastTab, setLastTab] = useLocalStorage(storageKey, tabs[0]?.value ?? ""); + + useEffect(() => { + if (!enabled || tabs.length === 0) { + return; + } + + const normalizedCurrentPath = currentPath.replace(/\/$/u, ""); + const normalizedBase = baseUrl.replace(/\/$/u, ""); + + const pathSegments = normalizedCurrentPath.split("/"); + const baseSegments = normalizedBase.split("/"); + const currentTab = pathSegments[baseSegments.length] ?? ""; + + const isValidTab = tabs.some((tab) => tab.value === currentTab); + const isLastTabInCurrentTabs = !lastTab || tabs.some((tab) => tab.value === lastTab); + + // Only update localStorage if both currentTab is valid AND lastTab is also in current tabs + // This prevents different page types (e.g., Task Instance vs Task Group) from overwriting each other's tab memory + if (isValidTab && isLastTabInCurrentTabs) { + setLastTab(currentTab); + } + + const isAtBaseUrl = + normalizedCurrentPath === normalizedBase || normalizedCurrentPath === `${normalizedBase}/`; + const hasValidSavedTab = Boolean(lastTab) && tabs.some((tab) => tab.value === lastTab); + const shouldRedirect = isAtBaseUrl && hasValidSavedTab && lastTab !== tabs[0]?.value; + + if (shouldRedirect) { + const redirectPath = lastTab ? `${normalizedBase}/${lastTab}` : normalizedBase; + + void Promise.resolve( + navigate(redirectPath, { + replace: true, + }), + ); + } + }, [baseUrl, currentPath, enabled, lastTab, navigate, setLastTab, tabs]); +}; diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx index dd197787e390d..e1d6de4558f54 100644 --- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx +++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx @@ -16,6 +16,8 @@ * specific language governing permissions and limitations * under the License. */ + +/* eslint-disable max-lines */ import { Box, HStack, Flex, useDisclosure, IconButton } from "@chakra-ui/react"; import { useReactFlow } from "@xyflow/react"; import { useRef, useState } from "react"; @@ -29,7 +31,7 @@ import { PanelGroup, PanelResizeHandle, } from "react-resizable-panels"; -import { Outlet, useParams } from "react-router-dom"; +import { Outlet, useLocation, useParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; import { useDagServiceGetDag, useDagWarningServiceListDagWarnings } from "openapi/queries"; @@ -43,6 +45,8 @@ import { Toaster } from "src/components/ui"; import { Tooltip } from "src/components/ui/Tooltip"; import { HoverProvider } from "src/context/hover"; import { OpenGroupsProvider } from "src/context/openGroups"; +import type { TabStorageKey } from "src/hooks/useTabMemory"; +import { TabStorageKeys, useTabMemory } from "src/hooks/useTabMemory"; import { DagBreadcrumb } from "./DagBreadcrumb"; import { Gantt } from "./Gantt/Gantt"; @@ -54,14 +58,29 @@ import { PanelButtons } from "./PanelButtons"; type Props = { readonly error?: unknown; readonly isLoading?: boolean; + readonly storageKey?: TabStorageKey; readonly tabs: Array<{ icon: ReactNode; label: string; value: string }>; } & PropsWithChildren; -export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => { +export const DetailsLayout = ({ + children, + error, + isLoading, + storageKey: providedStorageKey, + tabs, +}: Props) => { const { t: translate } = useTranslation(); const { dagId = "", runId } = useParams(); + const location = useLocation(); const { data: dag } = useDagServiceGetDag({ dagId }); const [defaultDagView] = useLocalStorage<"graph" | "grid">("default_dag_view", "grid"); + const storageKey = providedStorageKey ?? (Boolean(runId) ? TabStorageKeys.RUN : TabStorageKeys.DAG); + + useTabMemory({ + currentPath: location.pathname, + storageKey, + tabs, + }); const panelGroupRef = useRef(null); const [dagView, setDagView] = useLocalStorage<"graph" | "grid">(`dag_view-${dagId}`, defaultDagView); const [limit, setLimit] = useLocalStorage(`dag_runs_limit-${dagId}`, 10); diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx index a4efef7726514..7637baa5ba59b 100644 --- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx @@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next"; import { MdOutlineTask } from "react-icons/md"; import { useParams } from "react-router-dom"; +import { TabStorageKeys } from "src/hooks/useTabMemory"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts"; @@ -36,7 +37,7 @@ export const GroupTaskInstance = () => { return ( - + {taskInstance === undefined ? undefined :
} diff --git a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx index 6c231c35bcd38..5037ab4df5797 100644 --- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx @@ -21,6 +21,7 @@ import { useTranslation } from "react-i18next"; import { MdOutlineTask } from "react-icons/md"; import { useParams } from "react-router-dom"; +import { TabStorageKeys } from "src/hooks/useTabMemory"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts"; @@ -44,7 +45,7 @@ export const MappedTaskInstance = () => { return ( - + {taskInstance === undefined ? undefined :
} diff --git a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx index 6fff8ab723e08..f612011bc936d 100644 --- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx +++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx @@ -26,6 +26,7 @@ import { useParams } from "react-router-dom"; import { useTaskServiceGetTask } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; +import { TabStorageKeys } from "src/hooks/useTabMemory"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridStructure } from "src/queries/useGridStructure.ts"; import { getGroupTask } from "src/utils/groupTask"; @@ -77,7 +78,7 @@ export const Task = () => { return ( - + {task === undefined ? undefined :
} {groupTask ? : undefined} diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx index dd956a8d4dd7a..c9f18bb99f726 100644 --- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx +++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx @@ -27,6 +27,7 @@ import { useParams } from "react-router-dom"; import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries"; import { usePluginTabs } from "src/hooks/usePluginTabs"; import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs"; +import { TabStorageKeys } from "src/hooks/useTabMemory"; import { DetailsLayout } from "src/layouts/Details/DetailsLayout"; import { useGridTiSummaries } from "src/queries/useGridTISummaries.ts"; import { isStatePending, useAutoRefresh } from "src/utils"; @@ -103,9 +104,14 @@ export const TaskInstance = () => { refetchInterval: isStatePending(taskInstance?.state) ? refetchInterval : false, }); + const storageKey = + taskInstance && taskInstance.map_index > -1 + ? TabStorageKeys.MAPPED_TASK_INSTANCE + : TabStorageKeys.TASK_INSTANCE; + return ( - + {taskInstance === undefined ? ( {translate("common:noItemsFound", { modelName: translate("common:taskInstance_one") })} From 620536e8e1557d3c449fb7b7250fbcf526746400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Fri, 30 Jan 2026 19:05:38 -0500 Subject: [PATCH 2/3] feat: refine tab preservation logic and fix url parsing contexts --- .../ui/src/hooks/navigation/useNavigation.ts | 9 +- .../src/airflow/ui/src/hooks/useTabMemory.ts | 61 ++++++------ .../src/airflow/ui/src/utils/links.test.ts | 98 ++++++++++++++++++- .../src/airflow/ui/src/utils/links.ts | 26 +++-- 4 files changed, 156 insertions(+), 38 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts index 2098271f84b24..9004fe98c8bea 100644 --- a/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts +++ b/airflow-core/src/airflow/ui/src/hooks/navigation/useNavigation.ts @@ -21,7 +21,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import type { GridRunsResponse } from "openapi/requests"; import type { GridTask } from "src/layouts/Details/Grid/utils"; -import { buildTaskInstanceUrl } from "src/utils/links"; +import { buildTaskInstanceUrl, getTaskAdditionalPath } from "src/utils/links"; import { NavigationModes, @@ -77,8 +77,11 @@ const buildPath = (params: { switch (mode) { case NavigationModes.RUN: return `/dags/${dagId}/runs/${run.run_id}`; - case NavigationModes.TASK: - return `/dags/${dagId}/tasks/${groupPath}${task.id}`; + case NavigationModes.TASK: { + const additionalPath = getTaskAdditionalPath(pathname); + + return `/dags/${dagId}/tasks/${groupPath}${task.id}${additionalPath}`; + } case NavigationModes.TI: return buildTaskInstanceUrl({ currentPathname: pathname, diff --git a/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts b/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts index 41bd5bec67077..fcb8b459eef1d 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useTabMemory.ts @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; @@ -43,50 +43,55 @@ type UseTabMemoryOptions = { export const useTabMemory = (options: UseTabMemoryOptions) => { const { currentPath, enabled = true, storageKey, tabs } = options; const navigate = useNavigate(); + const defaultTab = tabs[0]?.value ?? ""; + const [savedTab, setSavedTab] = useLocalStorage(storageKey, defaultTab); - const normalizedPath = currentPath.replace(/\/$/u, ""); - const segments = normalizedPath.split("/"); - const lastSegment = segments[segments.length - 1] ?? ""; - const isTabSegment = tabs.some((tab) => tab.value === lastSegment); - const baseUrl = isTabSegment && lastSegment !== "" ? segments.slice(0, -1).join("/") : normalizedPath; - - // Use entity-based storage key instead of URL-based key - const [lastTab, setLastTab] = useLocalStorage(storageKey, tabs[0]?.value ?? ""); + // Track previous baseUrl to detect entity changes + const previousBaseUrlRef = useRef(null); useEffect(() => { if (!enabled || tabs.length === 0) { return; } - const normalizedCurrentPath = currentPath.replace(/\/$/u, ""); - const normalizedBase = baseUrl.replace(/\/$/u, ""); + const normalizedPath = currentPath.replace(/\/$/u, ""); + const pathSegments = normalizedPath.split("/"); + const lastSegment = pathSegments[pathSegments.length - 1] ?? ""; + const isTabSegment = tabs.some((tab) => tab.value === lastSegment); + + const baseUrl = isTabSegment && lastSegment !== "" ? pathSegments.slice(0, -1).join("/") : normalizedPath; + const currentTab = isTabSegment ? lastSegment : ""; + + const isAtBase = normalizedPath === baseUrl; + const isSavedTabValid = Boolean(savedTab) && tabs.some((tab) => tab.value === savedTab); - const pathSegments = normalizedCurrentPath.split("/"); - const baseSegments = normalizedBase.split("/"); - const currentTab = pathSegments[baseSegments.length] ?? ""; + // Check if we've switched to a different entity (different baseUrl) + const hasEntityChanged = previousBaseUrlRef.current !== null && previousBaseUrlRef.current !== baseUrl; + const isFirstVisit = previousBaseUrlRef.current === null; - const isValidTab = tabs.some((tab) => tab.value === currentTab); - const isLastTabInCurrentTabs = !lastTab || tabs.some((tab) => tab.value === lastTab); + previousBaseUrlRef.current = baseUrl; - // Only update localStorage if both currentTab is valid AND lastTab is also in current tabs - // This prevents different page types (e.g., Task Instance vs Task Group) from overwriting each other's tab memory - if (isValidTab && isLastTabInCurrentTabs) { - setLastTab(currentTab); + // Check if current tab is valid (including empty string for default tab) + const isCurrentTabValid = tabs.some((tab) => tab.value === currentTab); + + // Update saved preference when viewing a valid tab + if (isCurrentTabValid) { + const isPreviousSavedTabValid = !savedTab || tabs.some((tab) => tab.value === savedTab); + + if (isPreviousSavedTabValid && currentTab !== savedTab) { + setSavedTab(currentTab); + } } - const isAtBaseUrl = - normalizedCurrentPath === normalizedBase || normalizedCurrentPath === `${normalizedBase}/`; - const hasValidSavedTab = Boolean(lastTab) && tabs.some((tab) => tab.value === lastTab); - const shouldRedirect = isAtBaseUrl && hasValidSavedTab && lastTab !== tabs[0]?.value; + const shouldRedirect = + isAtBase && isSavedTabValid && savedTab !== defaultTab && (isFirstVisit || hasEntityChanged); if (shouldRedirect) { - const redirectPath = lastTab ? `${normalizedBase}/${lastTab}` : normalizedBase; - void Promise.resolve( - navigate(redirectPath, { + navigate(`${baseUrl}/${savedTab}`, { replace: true, }), ); } - }, [baseUrl, currentPath, enabled, lastTab, navigate, setLastTab, tabs]); + }, [currentPath, defaultTab, enabled, navigate, savedTab, setSavedTab, tabs]); }; diff --git a/airflow-core/src/airflow/ui/src/utils/links.test.ts b/airflow-core/src/airflow/ui/src/utils/links.test.ts index 47dd032ebc519..165fc2537726d 100644 --- a/airflow-core/src/airflow/ui/src/utils/links.test.ts +++ b/airflow-core/src/airflow/ui/src/utils/links.test.ts @@ -16,11 +16,18 @@ * specific language governing permissions and limitations * under the License. */ + +/* eslint-disable max-lines */ import { describe, it, expect } from "vitest"; import type { TaskInstanceResponse } from "openapi/requests/types.gen"; -import { buildTaskInstanceUrl, getTaskInstanceAdditionalPath, getTaskInstanceLink } from "./links"; +import { + buildTaskInstanceUrl, + getTaskAdditionalPath, + getTaskInstanceAdditionalPath, + getTaskInstanceLink, +} from "./links"; describe("getTaskInstanceLink", () => { const testCases = [ @@ -148,6 +155,59 @@ describe("getTaskInstanceAdditionalPath", () => { getTaskInstanceAdditionalPath("/dags/my-dag_v2/runs/run_1-test/tasks/task.1/rendered_templates"), ).toBe("/rendered_templates"); }); + + it("should NOT preserve sub-routes from Task pages (without /runs/)", () => { + // Task page with task_instances tab - should NOT be preserved + expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/task_1/task_instances")).toBe(""); + + // Task page with overview/events tab - should NOT be preserved + expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/task_1/events")).toBe(""); + + // Task group page - should NOT be preserved + expect(getTaskInstanceAdditionalPath("/dags/my_dag/tasks/group/my_group/task_instances")).toBe(""); + + // Only TaskInstance pages (with /runs/) should preserve sub-routes + expect(getTaskInstanceAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/task_instances")).toBe( + "/task_instances", + ); + }); +}); + +describe("getTaskAdditionalPath", () => { + it("should return empty string for basic task path", () => { + const result = getTaskAdditionalPath("/dags/my_dag/tasks/task_1"); + + expect(result).toBe(""); + }); + + it("should extract sub-route from task path", () => { + const result = getTaskAdditionalPath("/dags/my_dag/tasks/task_1/task_instances"); + + expect(result).toBe("/task_instances"); + }); + + it("should extract sub-route from group task path", () => { + const result = getTaskAdditionalPath("/dags/my_dag/tasks/group/my_group/events"); + + expect(result).toBe("/events"); + }); + + it("should handle all known task routes", () => { + const knownRoutes = ["task_instances", "required_actions", "events"]; + + for (const route of knownRoutes) { + const result = getTaskAdditionalPath(`/dags/test/tasks/task_1/${route}`); + + expect(result).toBe(`/${route}`); + } + }); + + it("should also work for TaskInstance paths (backward compatibility)", () => { + // getTaskAdditionalPath is more permissive and will extract from any /tasks/ path + // This provides backward compatibility if needed + expect(getTaskAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/details")).toBe("/details"); + expect(getTaskAdditionalPath("/dags/my_dag/tasks/task_1/task_instances")).toBe("/task_instances"); + }); }); describe("buildTaskInstanceUrl", () => { @@ -255,5 +315,41 @@ describe("buildTaskInstanceUrl", () => { taskId: "new_group", }), ).toBe("/dags/new_dag/runs/new_run/tasks/group/new_group/mapped/3"); + + // Mapped task overview (no mapIndex) should never preserve tabs + expect( + buildTaskInstanceUrl({ + currentPathname: "/dags/old/runs/old/tasks/old_task/xcom", + dagId: "new_dag", + isMapped: true, + mapIndex: undefined, + runId: "new_run", + taskId: "mapped_task", + }), + ).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped"); + + // Mapped task overview with mapIndex="-1" should also not preserve + expect( + buildTaskInstanceUrl({ + currentPathname: "/dags/old/runs/old/tasks/old_task/details", + dagId: "new_dag", + isMapped: true, + mapIndex: "-1", + runId: "new_run", + taskId: "mapped_task", + }), + ).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped"); + + // But specific mapped instance (with mapIndex) SHOULD preserve tabs + expect( + buildTaskInstanceUrl({ + currentPathname: "/dags/old/runs/old/tasks/old_task/mapped/0/xcom", + dagId: "new_dag", + isMapped: true, + mapIndex: "5", + runId: "new_run", + taskId: "mapped_task", + }), + ).toBe("/dags/new_dag/runs/new_run/tasks/mapped_task/mapped/5/xcom"); }); }); diff --git a/airflow-core/src/airflow/ui/src/utils/links.ts b/airflow-core/src/airflow/ui/src/utils/links.ts index 3beafb06afea1..3cd07b434b628 100644 --- a/airflow-core/src/airflow/ui/src/utils/links.ts +++ b/airflow-core/src/airflow/ui/src/utils/links.ts @@ -49,16 +49,16 @@ export const getRedirectPath = (targetPath: string): string => { export const getTaskInstanceAdditionalPath = (pathname: string): string => { const subRoutes = taskInstanceRoutes.filter((route) => route.path !== undefined).map((route) => route.path); - // Look for patterns like /tasks/{taskId}/mapped/{mapIndex}/{sub-route} - const mappedRegex = /\/tasks\/[^/]+\/mapped\/[^/]+\/(?.+)$/u; + + const mappedRegex = /\/runs\/[^/]+\/tasks\/[^/]+\/mapped\/[^/]+\/(?.+)$/u; const mappedMatch = mappedRegex.exec(pathname); if (mappedMatch?.groups?.subRoute !== undefined) { return `/${mappedMatch.groups.subRoute}`; } - // Look for patterns like /tasks/{taskId}/{sub-route} or /tasks/group/{groupId}/{sub-route} - const taskRegex = /\/tasks\/(?:group\/)?[^/]+\/(?.+)$/u; + // Must include /runs/ to avoid matching Task pages (/dags/xxx/tasks/yyy/task_instances) + const taskRegex = /\/runs\/[^/]+\/tasks\/(?:group\/)?[^/]+\/(?.+)$/u; const taskMatch = taskRegex.exec(pathname); if (taskMatch?.groups?.subRoute !== undefined) { @@ -73,6 +73,17 @@ export const getTaskInstanceAdditionalPath = (pathname: string): string => { return ""; }; +export const getTaskAdditionalPath = (pathname: string): string => { + const taskPageRegex = /\/dags\/[^/]+\/tasks\/(?:group\/)?[^/]+\/(?.+)$/u; + const match = taskPageRegex.exec(pathname); + + if (match?.groups?.subRoute !== undefined) { + return `/${match.groups.subRoute}`; + } + + return ""; +}; + export const buildTaskInstanceUrl = (params: { currentPathname: string; dagId: string; @@ -84,8 +95,11 @@ export const buildTaskInstanceUrl = (params: { }): string => { const { currentPathname, dagId, isGroup = false, isMapped = false, mapIndex, runId, taskId } = params; const groupPath = isGroup ? "group/" : ""; - // Task groups only have "Task Instances" tab, so never preserve tabs for groups - const additionalPath = isGroup ? "" : getTaskInstanceAdditionalPath(currentPathname); + + // Only preserve tabs for specific task instances, excluding groups and mapped lists + const isMappedOverview = isMapped && (mapIndex === undefined || mapIndex === "-1"); + const shouldPreserveTabs = !isGroup && !isMappedOverview; + const additionalPath = shouldPreserveTabs ? getTaskInstanceAdditionalPath(currentPathname) : ""; let basePath = `/dags/${dagId}/runs/${runId}/tasks/${groupPath}${taskId}`; From d84c2b287c2a81680019270cd3035c32556bb4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=96=86=E5=AE=B8?= Date: Fri, 30 Jan 2026 19:17:25 -0500 Subject: [PATCH 3/3] test: Modify tests for entity-based tab memory functionality --- airflow-core/src/airflow/ui/src/utils/links.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/utils/links.test.ts b/airflow-core/src/airflow/ui/src/utils/links.test.ts index 165fc2537726d..2ebd7bfa48deb 100644 --- a/airflow-core/src/airflow/ui/src/utils/links.test.ts +++ b/airflow-core/src/airflow/ui/src/utils/links.test.ts @@ -202,10 +202,12 @@ describe("getTaskAdditionalPath", () => { } }); - it("should also work for TaskInstance paths (backward compatibility)", () => { - // getTaskAdditionalPath is more permissive and will extract from any /tasks/ path - // This provides backward compatibility if needed - expect(getTaskAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/details")).toBe("/details"); + it("should NOT extract from TaskInstance paths (with /runs/)", () => { + // getTaskAdditionalPath is specifically for Task pages (without /runs/) + // TaskInstance paths should use getTaskInstanceAdditionalPath instead + expect(getTaskAdditionalPath("/dags/my_dag/runs/run_1/tasks/task_1/details")).toBe(""); + + // But it should work for Task pages expect(getTaskAdditionalPath("/dags/my_dag/tasks/task_1/task_instances")).toBe("/task_instances"); }); });