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

pierrejeambrun pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 28e20e1684f Show the current page name in the browser tab title 
(#69622) (#69656)
28e20e1684f is described below

commit 28e20e1684fa9e0428df3cb8e28d82b3398676e1
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Thu Jul 9 16:36:57 2026 +0200

    Show the current page name in the browser tab title (#69622) (#69656)
    
    Only Dag pages set a page-specific browser tab title today, so every other
    page shows just the instance name and multiple open Airflow tabs cannot be
    told apart. Set a localized, page-aware title on the remaining pages so each
    tab identifies the page it is showing.
    
    (cherry picked from commit 493312b9f9ce3fc70b946c725182e2a9ff4f460c)
---
 .../src/airflow/ui/src/pages/Asset/AssetLayout.tsx |  3 +
 .../airflow/ui/src/pages/AssetsList/AssetsList.tsx |  4 ++
 .../src/airflow/ui/src/pages/Configs/Configs.tsx   |  4 ++
 .../ui/src/pages/Connections/Connections.tsx       |  4 ++
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |  6 +-
 .../src/airflow/ui/src/pages/DagsList/DagsList.tsx |  4 ++
 .../airflow/ui/src/pages/Dashboard/Dashboard.tsx   |  4 ++
 .../src/airflow/ui/src/pages/Deadlines/index.tsx   |  5 +-
 .../src/airflow/ui/src/pages/Events/Events.tsx     |  5 ++
 .../pages/GroupTaskInstance/GroupTaskInstance.tsx  |  4 ++
 .../pages/HITLTaskInstances/HITLTaskInstances.tsx  |  6 +-
 airflow-core/src/airflow/ui/src/pages/Jobs.tsx     |  5 +-
 .../MappedTaskInstance/MappedTaskInstance.tsx      |  4 ++
 airflow-core/src/airflow/ui/src/pages/Plugins.tsx  |  4 ++
 .../src/airflow/ui/src/pages/Pools/Pools.tsx       |  3 +
 .../src/airflow/ui/src/pages/Providers.tsx         |  4 ++
 airflow-core/src/airflow/ui/src/pages/Run/Run.tsx  |  4 +-
 airflow-core/src/airflow/ui/src/pages/Security.tsx |  5 ++
 .../src/airflow/ui/src/pages/Task/Task.tsx         |  3 +
 .../ui/src/pages/TaskInstance/TaskInstance.tsx     |  5 +-
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   |  6 +-
 .../airflow/ui/src/pages/Variables/Variables.tsx   |  4 ++
 .../src/airflow/ui/src/pages/XCom/XCom.tsx         |  4 ++
 .../airflow/ui/src/pages/documentTitle.test.tsx    | 73 ++++++++++++++++++++++
 24 files changed, 166 insertions(+), 7 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
index d53e2029703..ade558468cd 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
@@ -29,6 +29,7 @@ import { BreadcrumbStats } from 
"src/components/BreadcrumbStats";
 import { ProgressBar } from "src/components/ui";
 import { GroupsProvider } from "src/context/groups";
 import { NavTabs } from "src/layouts/Details/NavTabs";
+import { useDocumentTitle } from "src/utils";
 
 import { AssetGraph } from "./AssetGraph";
 import { AssetPanelButtons } from "./AssetPanelButtons";
@@ -49,6 +50,8 @@ export const AssetLayout = () => {
     },
   );
 
+  useDocumentTitle(asset?.name);
+
   const links = [
     {
       label: asset?.name,
diff --git a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
index 9ddbe3d77ee..150b57ba968 100644
--- a/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/AssetsList/AssetsList.tsx
@@ -34,6 +34,7 @@ import { RouterLink } from "src/components/ui";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
 import { CreateAssetEvent } from "src/pages/Asset/CreateAssetEvent";
+import { useDocumentTitle } from "src/utils";
 
 import { DependencyPopover } from "./DependencyPopover";
 
@@ -117,6 +118,9 @@ const { NAME_PATTERN, OFFSET }: SearchParamsKeysType = 
SearchParamsKeys;
 
 export const AssetsList = () => {
   const { t: translate } = useTranslation(["assets", "common"]);
+
+  useDocumentTitle(translate("common:nav.assets"));
+
   const [searchParams, setSearchParams] = useSearchParams();
 
   const namePattern = searchParams.get(NAME_PATTERN) ?? "";
diff --git a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx 
b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
index 9c12e10a49e..4e7cc724bf7 100644
--- a/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Configs/Configs.tsx
@@ -25,6 +25,7 @@ import { useConfigServiceGetConfig } from "openapi/queries";
 import type { ConfigOption } from "openapi/requests/types.gen";
 import { DataTable } from "src/components/DataTable";
 import { ErrorAlert } from "src/components/ErrorAlert";
+import { useDocumentTitle } from "src/utils";
 
 type ConfigColums = {
   section: string;
@@ -50,6 +51,9 @@ const createColumns = (translate: TFunction): 
Array<ColumnDef<ConfigColums>> =>
 
 export const Configs = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
+
+  useDocumentTitle(translate("common:admin.Config"));
+
   const { data, error } = useConfigServiceGetConfig();
 
   const columns = createColumns(translate);
diff --git a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx 
b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
index 42a56a8fac1..44a86347f70 100644
--- a/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Connections/Connections.tsx
@@ -42,6 +42,7 @@ import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searc
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig.tsx";
 import { useConnectionTypeMeta } from "src/queries/useConnectionTypeMeta";
+import { useDocumentTitle } from "src/utils";
 
 import AddConnectionButton from "./AddConnectionButton";
 import DeleteConnectionButton from "./DeleteConnectionButton";
@@ -132,6 +133,9 @@ const getColumns = ({
 
 export const Connections = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
+
+  useDocumentTitle(translate("common:admin.Connections"));
+
   const { setTableURLState, tableURLState } = useTableURLState();
   const [searchParams, setSearchParams] = useSearchParams();
   const { NAME_PATTERN, OFFSET }: SearchParamsKeysType = SearchParamsKeys;
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
index 06374d10b27..ac4d1b9a886 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -47,7 +47,7 @@ import { RouterLink } from "src/components/ui";
 import { ActionBar } from "src/components/ui/ActionBar";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
-import { renderDuration, useAutoRefresh, isStatePending } from "src/utils";
+import { renderDuration, useAutoRefresh, isStatePending, useDocumentTitle } 
from "src/utils";
 
 import BulkClearDagRunsButton from "./BulkClearDagRunsButton";
 import BulkDeleteDagRunsButton from "./BulkDeleteDagRunsButton";
@@ -221,6 +221,10 @@ const runColumns = ({ dagId, translate }: ColumnProps): 
Array<ColumnDef<DAGRunRe
 export const DagRuns = () => {
   const { t: translate } = useTranslation();
   const { dagId } = useParams();
+
+  // Only the standalone list page owns the tab title; the Dag-scoped tab 
inherits the Dag page's title.
+  useDocumentTitle(dagId === undefined ? translate("common:dagRun_other") : 
undefined);
+
   const [searchParams] = useSearchParams();
 
   const { setTableURLState, tableURLState } = useTableURLState({
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index eaedd94406a..f9eb2884c0f 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -41,6 +41,7 @@ import { useAdvancedSearch } from 
"src/hooks/useAdvancedSearch";
 import { DagsLayout } from "src/layouts/DagsLayout";
 import { useConfig } from "src/queries/useConfig";
 import { useDags } from "src/queries/useDags";
+import { useDocumentTitle } from "src/utils";
 
 import { DagImportErrors } from "../Dashboard/Stats/DagImportErrors";
 import { DagCard } from "./DagCard";
@@ -188,6 +189,9 @@ const cardDef: CardDef<DAGWithLatestDagRunsResponse> = {
 
 export const DagsList = () => {
   const { t: translate } = useTranslation();
+
+  useDocumentTitle(translate("common:nav.dags"));
+
   const [searchParams, setSearchParams] = useSearchParams();
   const [display, setDisplay] = useLocalStorage<"card" | 
"table">(DAGS_LIST_DISPLAY_KEY, "card");
   const dagRunsLimit = display === "card" ? 14 : 1;
diff --git a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
index a5867947d15..8af92bb6f4d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dashboard/Dashboard.tsx
@@ -29,6 +29,7 @@ import TimeRangeSelector from 
"src/components/TimeRangeSelector";
 import { IconButton } from "src/components/ui";
 import { COLLAPSED_UI_ALERTS_KEY } from "src/constants/localStorage";
 import { useConfig } from "src/queries/useConfig";
+import { useDocumentTitle } from "src/utils";
 
 import { ReactPlugin } from "../ReactPlugin";
 import { AlertContent } from "./AlertContent";
@@ -44,6 +45,9 @@ const defaultHour = "24";
 export const Dashboard = () => {
   const alerts = useConfig("dashboard_alert") as Array<UIAlert>;
   const { t: translate } = useTranslation("dashboard");
+
+  useDocumentTitle(translate("common:nav.home"));
+
   const instanceName = useConfig("instance_name");
 
   const now = dayjs();
diff --git a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx 
b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
index 720648c4247..54a01f5cdb0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Deadlines/index.tsx
@@ -31,7 +31,7 @@ import { FilterBar } from "src/components/FilterBar";
 import Time from "src/components/Time";
 import { TruncatedText } from "src/components/TruncatedText";
 import { SearchParamsKeys } from "src/constants/searchParams";
-import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils";
+import { useDocumentTitle, useFiltersHandler, type FilterableSearchParamsKeys 
} from "src/utils";
 
 type DeadlineRow = { row: { original: DeadlineResponse } };
 
@@ -100,6 +100,9 @@ const deadlinesFilterKeys: 
Array<FilterableSearchParamsKeys> = [
 
 export const Deadlines = () => {
   const { t: translate } = useTranslation(["browse", "common"]);
+
+  useDocumentTitle(translate("common:browse.deadlines"));
+
   const { setTableURLState, tableURLState } = useTableURLState();
   const [searchParams] = useSearchParams();
 
diff --git a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx 
b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
index dcc8adf784e..1b79cc771bb 100644
--- a/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Events/Events.tsx
@@ -32,6 +32,7 @@ import RenderedJsonField from 
"src/components/RenderedJsonField";
 import Time from "src/components/Time";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
+import { useDocumentTitle } from "src/utils";
 
 import { EventsFilters } from "./EventsFilters";
 
@@ -161,6 +162,10 @@ const {
 export const Events = () => {
   const { t: translate } = useTranslation(["browse", "common"]);
   const { dagId, runId, taskId } = useParams();
+
+  // Only the standalone audit-log page owns the tab title; nested tabs 
inherit their parent page's title.
+  useDocumentTitle(dagId === undefined ? translate("common:browse.auditLog") : 
undefined);
+
   const [searchParams] = useSearchParams();
   const { setTableURLState, tableURLState } = useTableURLState();
   const { pagination, sorting } = tableURLState;
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 abb741e9498..3f819b64c89 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/GroupTaskInstance.tsx
@@ -23,12 +23,16 @@ import { useParams } from "react-router-dom";
 
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
+import { useDocumentTitle } from "src/utils";
 
 import { Header } from "./Header";
 
 export const GroupTaskInstance = () => {
   const { dagId = "", groupId = "", runId = "" } = useParams();
   const { t: translate } = useTranslation("dag");
+
+  useDocumentTitle(groupId);
+
   const { summariesByRunId } = useGridTiSummariesStream({ dagId, runIds: runId 
? [runId] : [] });
   const gridTISummaries = summariesByRunId.get(runId);
   const taskInstance = gridTISummaries?.task_instances.find((ti) => ti.task_id 
=== groupId);
diff --git 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
index 71a0faaaf7b..46667420dbd 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/HITLTaskInstances/HITLTaskInstances.tsx
@@ -37,7 +37,7 @@ import { TruncatedText } from "src/components/TruncatedText";
 import { IconButton, RouterLink } from "src/components/ui";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
-import { useAutoRefresh } from "src/utils";
+import { useAutoRefresh, useDocumentTitle } from "src/utils";
 import { getHITLState, isHITLPending } from "src/utils/hitl";
 import { getTaskInstanceLink } from "src/utils/links";
 
@@ -222,6 +222,10 @@ export const HITLTaskInstances = ({
 }) => {
   const { t: translate } = useTranslation("hitl");
   const { dagId, runId, taskId } = useParams();
+
+  // Only the standalone required-actions page owns the tab title; nested tabs 
inherit their parent's.
+  useDocumentTitle(enableHITLReviewDrawer ? 
translate("common:browse.requiredActions") : undefined);
+
   const { closeHITLReviewDrawer, isHITLReviewDrawerOpen, openHITLReviewDrawer, 
selectedDetail } =
     useHITLReviewDrawer();
   const [searchParams, setSearchParams] = useSearchParams();
diff --git a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx 
b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
index 568c023d818..992328c4cd0 100644
--- a/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Jobs.tsx
@@ -31,7 +31,7 @@ import { FilterBar } from "src/components/FilterBar";
 import { StateBadge } from "src/components/StateBadge";
 import Time from "src/components/Time";
 import { SearchParamsKeys } from "src/constants/searchParams";
-import { useFiltersHandler, type FilterableSearchParamsKeys } from "src/utils";
+import { useDocumentTitle, useFiltersHandler, type FilterableSearchParamsKeys 
} from "src/utils";
 
 const createColumns = (translate: TFunction): Array<ColumnDef<JobResponse>> => 
[
   {
@@ -95,6 +95,9 @@ const jobsFilterKeys: Array<FilterableSearchParamsKeys> = [
 
 export const Jobs = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
+
+  useDocumentTitle(translate("common:browse.jobs"));
+
   const { setTableURLState, tableURLState } = useTableURLState();
   const [searchParams] = useSearchParams();
 
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 2bc1daa3ed6..a7cee933b47 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/MappedTaskInstance.tsx
@@ -24,12 +24,16 @@ import { useParams } from "react-router-dom";
 import { useDagRunServiceGetDagRun } from "openapi/queries";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
+import { useDocumentTitle } from "src/utils";
 
 import { Header } from "./Header";
 
 export const MappedTaskInstance = () => {
   const { dagId = "", runId = "", taskId = "" } = useParams();
   const { t: translate } = useTranslation("dag");
+
+  useDocumentTitle(taskId);
+
   // Pass the run state so the summaries stream keeps auto-refreshing while 
the run is running;
   // without it the Header and Details tab would freeze on the first fetch.
   const { data: dagRun } = useDagRunServiceGetDagRun({ dagId, dagRunId: runId 
}, undefined, {
diff --git a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx 
b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
index 623a7176c85..cde75c253d1 100644
--- a/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Plugins.tsx
@@ -23,11 +23,15 @@ import { usePluginServiceGetPlugins } from 
"openapi/queries";
 import { DataTable } from "src/components/DataTable";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
 import { ErrorAlert } from "src/components/ErrorAlert";
+import { useDocumentTitle } from "src/utils";
 
 import { PluginImportErrors } from "./Dashboard/Stats/PluginImportErrors";
 
 export const Plugins = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
+
+  useDocumentTitle(translate("common:admin.Plugins"));
+
   const { setTableURLState, tableURLState } = useTableURLState();
   const { pagination } = tableURLState;
   const { data, error } = usePluginServiceGetPlugins({
diff --git a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx 
b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
index 89c08c22d12..1fab831ac49 100644
--- a/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Pools/Pools.tsx
@@ -33,6 +33,7 @@ import { Select } from "src/components/ui";
 import type { SearchParamsKeysType } from "src/constants/searchParams";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
+import { useDocumentTitle } from "src/utils";
 
 import AddPoolButton from "./AddPoolButton";
 import PoolBarCard from "./PoolBarCard";
@@ -47,6 +48,8 @@ const cardDef = (): CardDef<PoolResponse> => ({
 export const Pools = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
 
+  useDocumentTitle(translate("common:admin.Pools"));
+
   const poolSortOptions = createListCollection({
     items: [
       { label: translate("pools.sort.asc"), value: "name" },
diff --git a/airflow-core/src/airflow/ui/src/pages/Providers.tsx 
b/airflow-core/src/airflow/ui/src/pages/Providers.tsx
index e8e762fcb9b..96834c948b1 100644
--- a/airflow-core/src/airflow/ui/src/pages/Providers.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Providers.tsx
@@ -27,6 +27,7 @@ import { DataTable } from "src/components/DataTable";
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
 import { ErrorAlert } from "src/components/ErrorAlert";
 import { urlRegex } from "src/constants/urlRegex";
+import { useDocumentTitle } from "src/utils";
 
 const createColumns = (translate: TFunction): 
Array<ColumnDef<ProviderResponse>> => [
   {
@@ -77,6 +78,9 @@ const createColumns = (translate: TFunction): 
Array<ColumnDef<ProviderResponse>>
 
 export const Providers = () => {
   const { t: translate } = useTranslation(["admin", "common"]);
+
+  useDocumentTitle(translate("common:admin.Providers"));
+
   const { setTableURLState, tableURLState } = useTableURLState();
 
   const columns = createColumns(translate);
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
index ea49ef73b7c..dcb6e4e1397 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Run.tsx
@@ -25,7 +25,7 @@ import { useParams } from "react-router-dom";
 import { useDagRunServiceGetDagRun } from "openapi/queries";
 import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
-import { isStatePending, useAutoRefresh } from "src/utils";
+import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils";
 
 import { Header } from "./Header";
 
@@ -33,6 +33,8 @@ export const Run = () => {
   const { t: translate } = useTranslation(["dag", "hitl"]);
   const { dagId = "", runId = "" } = useParams();
 
+  useDocumentTitle(runId);
+
   // Get external views with dag_run destination
   const externalTabs = usePluginTabs("dag_run");
 
diff --git a/airflow-core/src/airflow/ui/src/pages/Security.tsx 
b/airflow-core/src/airflow/ui/src/pages/Security.tsx
index 9a56996dcbc..f6ccf567f6d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Security.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Security.tsx
@@ -18,10 +18,12 @@
  */
 import { Box } from "@chakra-ui/react";
 import { useRef } from "react";
+import { useTranslation } from "react-i18next";
 import { useNavigate, useParams } from "react-router-dom";
 
 import { useAuthLinksServiceGetAuthMenus } from "openapi/queries";
 import { ProgressBar } from "src/components/ui";
+import { useDocumentTitle } from "src/utils";
 
 import { ErrorPage } from "./Error";
 
@@ -33,6 +35,9 @@ const SANDBOX = "allow-scripts allow-same-origin allow-forms";
 
 export const Security = () => {
   const { page } = useParams();
+  const { t: translate } = useTranslation("common");
+
+  useDocumentTitle(translate("nav.security"));
 
   const { data: authLinks, isLoading } = useAuthLinksServiceGetAuthMenus();
 
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 3d84c55c114..c53d18c2ee3 100644
--- a/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Task/Task.tsx
@@ -28,6 +28,7 @@ import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridStructure } from "src/queries/useGridStructure.ts";
+import { useDocumentTitle } from "src/utils";
 import { getGroupTask } from "src/utils/groupTask";
 
 import { GroupTaskHeader } from "./GroupTaskHeader";
@@ -37,6 +38,8 @@ export const Task = () => {
   const { t: translate } = useTranslation(["dag", "hitl"]);
   const { dagId = "", groupId, runId, taskId } = useParams();
 
+  useDocumentTitle(groupId ?? taskId);
+
   // Get external views with task destination
   const externalTabs = usePluginTabs("task");
 
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 1d390f14596..c6a53a5ece1 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -30,13 +30,16 @@ import { usePluginTabs } from "src/hooks/usePluginTabs";
 import { useRequiredActionTabs } from "src/hooks/useRequiredActionTabs";
 import { DetailsLayout } from "src/layouts/Details/DetailsLayout";
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
-import { isStatePending, useAutoRefresh } from "src/utils";
+import { isStatePending, useAutoRefresh, useDocumentTitle } from "src/utils";
 
 import { Header } from "./Header";
 
 export const TaskInstance = () => {
   const { t: translate } = useTranslation(["dag", "common", "hitl"]);
   const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
+
+  useDocumentTitle(taskId);
+
   // Get external views with task_instance destination
   const externalTabs = usePluginTabs("task_instance");
 
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
index 3249b876621..f09d7f07a22 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -44,7 +44,7 @@ import { RouterLink } from "src/components/ui";
 import { ActionBar } from "src/components/ui/ActionBar";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
-import { useAutoRefresh, isStatePending, renderDuration } from "src/utils";
+import { useAutoRefresh, isStatePending, renderDuration, useDocumentTitle } 
from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links";
 
 import BulkClearTaskInstancesButton from "./BulkClearTaskInstancesButton";
@@ -253,6 +253,10 @@ const taskInstanceColumns = ({
 export const TaskInstances = () => {
   const { t: translate } = useTranslation();
   const { dagId, groupId, runId, taskId } = useParams();
+
+  // Only the standalone list page owns the tab title; nested tabs inherit 
their parent page's title.
+  useDocumentTitle(dagId === undefined ? 
translate("common:taskInstance_other") : undefined);
+
   const [searchParams] = useSearchParams();
 
   const { setTableURLState, tableURLState } = useTableURLState({
diff --git a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx 
b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
index bff5770bb81..fbb44813cf4 100644
--- a/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Variables/Variables.tsx
@@ -42,6 +42,7 @@ import { ActionBar } from "src/components/ui/ActionBar";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearch } from "src/hooks/useAdvancedSearch";
 import { useConfig } from "src/queries/useConfig.tsx";
+import { useDocumentTitle } from "src/utils";
 import { TrimText } from "src/utils/TrimText";
 
 import DeleteVariablesButton from "./DeleteVariablesButton";
@@ -136,6 +137,9 @@ const getColumns = ({
 
 export const Variables = () => {
   const { t: translate } = useTranslation("admin");
+
+  useDocumentTitle(translate("common:admin.Variables"));
+
   const { setTableURLState, tableURLState } = useTableURLState({
     pagination: { pageIndex: 0, pageSize: 30 },
     sorting: [{ desc: false, id: "key" }],
diff --git a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx 
b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
index da6582398cf..851095c182d 100644
--- a/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/XCom/XCom.tsx
@@ -32,6 +32,7 @@ import { TruncatedText } from "src/components/TruncatedText";
 import { RouterLink } from "src/components/ui";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useAdvancedSearchArg } from "src/hooks/useAdvancedSearch";
+import { useDocumentTitle } from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links";
 
 import AddXComButton from "./AddXComButton";
@@ -150,6 +151,9 @@ const getColumns = ({
 export const XCom = () => {
   const { dagId = "~", mapIndex = "-1", runId = "~", taskId = "~" } = 
useParams();
   const { t: translate } = useTranslation(["browse", "common"]);
+
+  // Only the standalone list page owns the tab title; the task-instance tab 
inherits that page's title.
+  useDocumentTitle(dagId === "~" ? translate("common:browse.xcoms") : 
undefined);
   const { setTableURLState, tableURLState } = useTableURLState();
   const { pagination, sorting } = tableURLState;
   const [sort] = sorting;
diff --git a/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx
new file mode 100644
index 00000000000..5312ea23e24
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/documentTitle.test.tsx
@@ -0,0 +1,73 @@
+/*!
+ * 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 { render, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { AppWrapper } from "src/utils/AppWrapper";
+
+// The detail pages open a task-instance summaries stream that would otherwise 
stay active when the
+// tiny title-only assertion resolves and the tree unmounts. Stub it so no 
stream is left open.
+vi.mock("src/queries/useGridTISummaries.ts", () => ({
+  useGridTiSummariesStream: () => ({ isLoading: false, summariesByRunId: new 
Map() }),
+}));
+
+// instance_name in the mocked config is "Airflow" 
(src/mocks/handlers/config.ts). The test i18n
+// backend is not loaded, so translations resolve to their keys — asserting on 
the key still proves
+// each page wires the intended translation key into the browser tab title.
+const INSTANCE = "Airflow";
+
+describe("document title", () => {
+  beforeEach(() => {
+    document.title = "";
+  });
+
+  it.each([
+    ["/dags", "nav.dags"],
+    ["/assets", "nav.assets"],
+    ["/dag_runs", "dagRun_other"],
+    ["/task_instances", "taskInstance_other"],
+    ["/connections", "admin.Connections"],
+    ["/variables", "admin.Variables"],
+    ["/pools", "admin.Pools"],
+    ["/providers", "admin.Providers"],
+    ["/plugins", "admin.Plugins"],
+    ["/configs", "admin.Config"],
+    ["/deadlines", "browse.deadlines"],
+    ["/jobs", "browse.jobs"],
+    ["/xcoms", "browse.xcoms"],
+    ["/events", "browse.auditLog"],
+    ["/required_actions", "browse.requiredActions"],
+    ["/security/users", "nav.security"],
+  ])("shows the page name before the instance name on %s", async (route, 
pageTitle) => {
+    render(<AppWrapper initialEntries={[route]} />);
+
+    await waitFor(() => expect(document.title).toBe(`${pageTitle} - 
${INSTANCE}`));
+  });
+
+  it.each([
+    ["/dags/log_grouping/runs/manual__2025-02-18T12:19", 
"manual__2025-02-18T12:19"],
+    ["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source", 
"log_source"],
+    // Nested list/audit tabs are gated, so the detail page keeps ownership of 
the title.
+    
["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source/events", 
"log_source"],
+  ])("uses the entity identifier for detail page %s", async (route, pageTitle) 
=> {
+    render(<AppWrapper initialEntries={[route]} />);
+
+    await waitFor(() => expect(document.title).toBe(`${pageTitle} - 
${INSTANCE}`));
+  });
+});

Reply via email to