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 744ff2e6443 Add asset and task store UI (#67292)
744ff2e6443 is described below

commit 744ff2e64430327df04ba3e7f81ef93cc9b6a96d
Author: Brent Bovenzi <[email protected]>
AuthorDate: Mon Jun 8 17:11:07 2026 -0400

    Add asset and task store UI (#67292)
    
    * Add asset and task state UI
    
    Self pr review
    
    Add json validation
    
    pnpm format
    
    Rename state to store
    
    Fix more state->store names
    
    Clean up translations
    
    * Single storevalue
    
    * add useStoreMutation hook
---
 .../airflow/ui/public/i18n/locales/en/assets.json  |  14 ++
 .../airflow/ui/public/i18n/locales/en/common.json  |   5 +-
 .../src/airflow/ui/public/i18n/locales/en/dag.json |  22 +++
 .../src/airflow/ui/src/components/DeleteDialog.tsx |   9 +-
 .../ui/src/components/StoreValueCell.test.tsx      |  86 +++++++++
 .../airflow/ui/src/components/StoreValueCell.tsx   |  54 ++++++
 .../ui/src/layouts/Details/DetailsLayout.tsx       |   4 +-
 .../src/airflow/ui/src/layouts/Details/NavTabs.tsx |  77 +++++---
 .../src/airflow/ui/src/layouts/StorageLayout.tsx   |  40 +++++
 .../src/airflow/ui/src/pages/Asset/AssetEvents.tsx |  79 +++++++++
 .../src/airflow/ui/src/pages/Asset/AssetLayout.tsx |  63 ++-----
 .../pages/Asset/AssetStore/AddAssetStoreButton.tsx |  42 +++++
 .../ui/src/pages/Asset/AssetStore/AssetStore.tsx   | 110 ++++++++++++
 .../src/pages/Asset/AssetStore/AssetStoreModal.tsx | 138 +++++++++++++++
 .../Asset/AssetStore/ClearAllAssetStoreButton.tsx  |  66 +++++++
 .../Asset/AssetStore/DeleteAssetStoreButton.tsx    |  67 +++++++
 .../Asset/AssetStore/EditAssetStoreButton.tsx      |  45 +++++
 .../airflow/ui/src/pages/Asset/AssetStore/index.ts |  20 +++
 .../ui/src/pages/GroupTaskInstance/Header.tsx      |   4 +-
 .../ui/src/pages/MappedTaskInstance/Header.tsx     |   4 +-
 .../airflow/ui/src/pages/TaskInstance/Header.tsx   |  14 +-
 .../ui/src/pages/TaskInstance/TaskInstance.tsx     |   9 +-
 .../ui/src/pages/TaskStore/AddTaskStoreButton.tsx  |  53 ++++++
 .../pages/TaskStore/ClearAllTaskStoreButton.tsx    |  69 ++++++++
 .../src/pages/TaskStore/DeleteTaskStoreButton.tsx  |  70 ++++++++
 .../ui/src/pages/TaskStore/EditTaskStoreButton.tsx |  57 ++++++
 .../airflow/ui/src/pages/TaskStore/TaskStore.tsx   | 165 +++++++++++++++++
 .../ui/src/pages/TaskStore/TaskStoreModal.tsx      | 196 +++++++++++++++++++++
 .../src/airflow/ui/src/pages/TaskStore/index.ts    |  19 ++
 .../src/airflow/ui/src/queries/useStoreMutation.ts |  67 +++++++
 airflow-core/src/airflow/ui/src/router.tsx         |  16 +-
 airflow-core/src/airflow/ui/src/utils/links.ts     |  15 +-
 32 files changed, 1593 insertions(+), 106 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
index 11c4e7f36c4..2e933a2fbcc 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/assets.json
@@ -2,6 +2,19 @@
   "additional_data": "Additional Data",
   "asset_many": "Assets",
   "asset_one": "Asset",
+  "assetStore": {
+    "add": "Add Asset Store",
+    "clearAll": {
+      "resource": "all asset store",
+      "title": "Clear All Asset Store",
+      "warning": "All asset store will be wiped. Tasks that use this store to 
coordinate work will lose their persisted memory."
+    },
+    "delete": "Delete Asset Store",
+    "deleteWarning": "The asset will lose this persisted store entry.",
+    "edit": "Edit Asset Store",
+    "emptyState": "Asset store stores values scoped to an asset identity, 
shared across all Dag runs. Workers can write asset store via the Task SDK.",
+    "title": "Asset Store"
+  },
   "consumingDags": "Consuming Dags",
   "consumingTasks": "Consuming Tasks",
   "createEvent": {
@@ -25,6 +38,7 @@
     },
     "title": "Create Asset Event for {{name}}"
   },
+  "events": "Events",
   "extra": "Extra",
   "group": "Group",
   "lastAssetEvent": "Last Asset Event",
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 95a86d0fab1..8eea401e69a 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
@@ -146,6 +146,7 @@
     "startTime": "Start Time"
   },
   "generateToken": "Generate Token",
+  "key": "Key",
   "logicalDate": "Logical Date",
   "logout": "Logout",
   "logoutConfirmation": "You are about to logout from the application.",
@@ -262,7 +263,8 @@
       "any": "Any"
     },
     "tagPlaceholder": "Filter by tag",
-    "to": "To"
+    "to": "To",
+    "updatedAt": "Updated at"
   },
   "task": {
     "documentation": "Task Documentation",
@@ -393,6 +395,7 @@
     "mustBeAtLeast": "Must be at least {{min}}.",
     "mustBeValidNumber": "Must be a valid number."
   },
+  "value": "Value",
   "wrap": {
     "hotkey": "w",
     "tooltip": "Press {{hotkey}} to toggle wrap",
diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
index 877c169a477..617fd6f474a 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/dag.json
@@ -230,12 +230,34 @@
     "renderedTemplates": "Rendered Templates",
     "requiredActions": "Required Actions",
     "runs": "Runs",
+    "storage": "Storage",
     "taskInstances": "Task Instances",
+    "taskStore": "Task Store",
     "tasks": "Tasks",
     "xcom": "XCom"
   },
   "taskGroups": {
     "collapseAll": "Collapse all task groups",
     "expandAll": "Expand all task groups"
+  },
+  "taskStore": {
+    "add": "Add Task Store",
+    "clearAll": {
+      "resource": "all task store",
+      "title": "Clear All Task Store",
+      "warning": "All task store will be wiped. Tasks that use this store to 
track external work will not be able to resume without re-running from scratch."
+    },
+    "delete": "Delete Task Store",
+    "deleteWarning": "The task will lose this persisted memory. If the task is 
using this key to track external work (e.g. an external job ID), it will not be 
able to resume it.",
+    "edit": "Edit Task Store",
+    "emptyStore": "Task store stores values that persist across retries. 
Workers can write task store via the Task SDK.",
+    "expiresAt": {
+      "column": "Expires At",
+      "custom": "Custom",
+      "default": "Default ({{interval}})",
+      "label": "Expiration",
+      "never": "Never"
+    },
+    "title": "Task Store"
   }
 }
diff --git a/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx 
b/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
index ada2d14d47c..5f57d171ba1 100644
--- a/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/DeleteDialog.tsx
@@ -47,14 +47,7 @@ const DeleteDialog: React.FC<DeleteDialogProps> = ({
   const { t: translate } = useTranslation("common");
 
   return (
-    <Dialog.Root
-      data-testid="delete-dialog"
-      lazyMount
-      onOpenChange={onClose}
-      open={open}
-      size="md"
-      unmountOnExit
-    >
+    <Dialog.Root data-testid="delete-dialog" lazyMount onOpenChange={onClose} 
open={open} unmountOnExit>
       <Dialog.Content backdrop>
         <Dialog.Header>
           <Heading size="lg">{title}</Heading>
diff --git a/airflow-core/src/airflow/ui/src/components/StoreValueCell.test.tsx 
b/airflow-core/src/airflow/ui/src/components/StoreValueCell.test.tsx
new file mode 100644
index 00000000000..f2c17147dc2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/StoreValueCell.test.tsx
@@ -0,0 +1,86 @@
+/*!
+ * 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, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { StoreValueCell, resolveStoreValue } from "./StoreValueCell";
+
+// Mock the JSON viewer so tests don't have to load Monaco; assert via the 
serialized content.
+vi.mock("src/components/RenderedJsonField", () => ({
+  default: ({ content }: { readonly content: object }) => (
+    <div data-testid="json-field">{JSON.stringify(content)}</div>
+  ),
+}));
+
+describe("resolveStoreValue", () => {
+  it.each([
+    ["a plain object", { count: 2, nested: { ok: true } }, { json: { count: 2, 
nested: { ok: true } } }],
+    ["an array", [1, 2, 3], { json: [1, 2, 3] }],
+    ["a JSON object string", '{"job_id": "abc", "n1": 1}', { json: { job_id: 
"abc", n1: 1 } }],
+    ["a JSON array string", "[1, 2, 3]", { json: [1, 2, 3] }],
+  ])("renders %s in the JSON viewer", (_label, value, expected) => {
+    expect(resolveStoreValue(value)).toStrictEqual(expected);
+  });
+
+  it.each([
+    ["a non-JSON string", "polling", "polling"],
+    ["an empty string", "", ""],
+    ["a malformed JSON string", "{oops", "{oops"],
+    // Valid JSON, but a primitive — not worth a JSON editor, so it stays text.
+    ["a numeric JSON string", "42", "42"],
+    ["a boolean JSON string", "true", "true"],
+    ['the string "null"', "null", "null"],
+    ["a number", 42, "42"],
+    ["a boolean", false, "false"],
+    ["null", null, "null"],
+    ["undefined", undefined, "undefined"],
+  ])("renders %s as text", (_label, value, expected) => {
+    expect(resolveStoreValue(value)).toStrictEqual({ text: expected });
+  });
+});
+
+describe("StoreValueCell", () => {
+  it("renders objects in the JSON viewer", () => {
+    render(<StoreValueCell value={{ key: "value" }} />, { wrapper: Wrapper });
+
+    
expect(screen.getByTestId("json-field")).toHaveTextContent('{"key":"value"}');
+  });
+
+  it("parses and renders JSON-encoded strings in the JSON viewer", () => {
+    render(<StoreValueCell value='{"key": "value"}' />, { wrapper: Wrapper });
+
+    
expect(screen.getByTestId("json-field")).toHaveTextContent('{"key":"value"}');
+  });
+
+  it("renders plain strings as text", () => {
+    render(<StoreValueCell value="just a string" />, { wrapper: Wrapper });
+
+    expect(screen.queryByTestId("json-field")).toBeNull();
+    expect(screen.getByText("just a string")).toBeInTheDocument();
+  });
+
+  it("renders numbers as text", () => {
+    render(<StoreValueCell value={42} />, { wrapper: Wrapper });
+
+    expect(screen.queryByTestId("json-field")).toBeNull();
+    expect(screen.getByText("42")).toBeInTheDocument();
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/StoreValueCell.tsx 
b/airflow-core/src/airflow/ui/src/components/StoreValueCell.tsx
new file mode 100644
index 00000000000..53eda8e8109
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/StoreValueCell.tsx
@@ -0,0 +1,54 @@
+/* eslint-disable react-refresh/only-export-components */
+
+/*!
+ * 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 { Text } from "@chakra-ui/react";
+
+import type { JsonValue } from "openapi/requests";
+import RenderedJsonField from "src/components/RenderedJsonField";
+
+export const resolveStoreValue = (value: JsonValue): { json: object } | { 
text: string } => {
+  if (typeof value === "object" && value !== null) {
+    return { json: value };
+  }
+
+  if (typeof value === "string") {
+    try {
+      const parsed: unknown = JSON.parse(value);
+
+      if (typeof parsed === "object" && parsed !== null) {
+        return { json: parsed };
+      }
+    } catch {
+      // Not JSON — fall through and render the raw string.
+    }
+  }
+
+  return { text: String(value) };
+};
+
+export const StoreValueCell = ({ value }: { readonly value: JsonValue }) => {
+  const resolved = resolveStoreValue(value);
+
+  return "json" in resolved ? (
+    <RenderedJsonField content={resolved.json} enableClipboard={false} />
+  ) : (
+    <Text>{resolved.text}</Text>
+  );
+};
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 7725cb4ba12..5cfd5832c67 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -55,7 +55,7 @@ import { DagBreadcrumb } from "./DagBreadcrumb";
 import { Gantt } from "./Gantt/Gantt";
 import { Graph } from "./Graph";
 import { Grid } from "./Grid";
-import { NavTabs } from "./NavTabs";
+import { NavTabs, type NavTab } from "./NavTabs";
 import { PanelButtons } from "./PanelButtons";
 
 // Separate component so useHover can be called inside HoverProvider.
@@ -88,7 +88,7 @@ const SharedScrollBox = ({
 type Props = {
   readonly error?: unknown;
   readonly isLoading?: boolean;
-  readonly tabs: Array<{ icon: ReactNode; label: string; value: string }>;
+  readonly tabs: Array<NavTab>;
 } & PropsWithChildren;
 
 export const DetailsLayout = ({ children, error, isLoading, tabs }: Props) => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
index 4b2f9fe3e79..0a83b2e1780 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/NavTabs.tsx
@@ -18,17 +18,28 @@
  */
 import { Center, Flex } from "@chakra-ui/react";
 import { useRef, type ReactNode } from "react";
-import { NavLink } from "react-router-dom";
+import { NavLink, useLocation } from "react-router-dom";
 
 import { useContainerWidth } from "src/utils";
 
+export type NavTab = {
+  readonly icon?: ReactNode;
+  readonly label: string;
+  /** Additional route segments that should also mark this tab as active. */
+  readonly matchPaths?: Array<string>;
+  readonly value: string;
+};
+
 type Props = {
-  readonly tabs: Array<{ icon?: ReactNode; label: string; value: string }>;
+  readonly tabs: Array<NavTab>;
 };
 
 export const NavTabs = ({ tabs }: Props) => {
   const containerRef = useRef<HTMLDivElement>(null);
   const containerWidth = useContainerWidth(containerRef);
+  const { pathname } = useLocation();
+  // Last path segment, e.g. "task-store" or "xcom"
+  const lastSegment = pathname.split("/").pop() ?? "";
 
   return (
     <Flex
@@ -38,33 +49,41 @@ export const NavTabs = ({ tabs }: Props) => {
       mb={2}
       ref={containerRef}
     >
-      {tabs.map(({ icon, label, value }) => (
-        <NavLink
-          end
-          key={value}
-          title={label}
-          to={{
-            pathname: value,
-          }}
-        >
-          {({ isActive }) => (
-            <Center
-              _hover={{ color: "fg" }}
-              borderBottomColor="border.info"
-              borderBottomWidth={isActive ? 3 : 0}
-              color={isActive ? "fg" : "fg.muted"}
-              fontWeight="bold"
-              height="40px"
-              mb="-2px" // Show the border on top of its parent's border
-              pb={isActive ? 0 : "3px"}
-              px={4}
-              transition="all 0.2s ease"
-            >
-              {containerWidth > 600 || !Boolean(icon) ? label : icon}
-            </Center>
-          )}
-        </NavLink>
-      ))}
+      {tabs.map(({ icon, label, matchPaths, value }) => {
+        const isPathMatch = (matchPaths ?? []).includes(lastSegment);
+
+        return (
+          <NavLink
+            end
+            key={value}
+            title={label}
+            to={{
+              pathname: value,
+            }}
+          >
+            {({ isActive }) => {
+              const active = isActive || isPathMatch;
+
+              return (
+                <Center
+                  _hover={{ color: "fg" }}
+                  borderBottomColor="border.info"
+                  borderBottomWidth={active ? 3 : 0}
+                  color={active ? "fg" : "fg.muted"}
+                  fontWeight="bold"
+                  height="40px"
+                  mb="-2px" // Show the border on top of its parent's border
+                  pb={active ? 0 : "3px"}
+                  px={4}
+                  transition="all 0.2s ease"
+                >
+                  {containerWidth > 600 || !Boolean(icon) ? label : icon}
+                </Center>
+              );
+            }}
+          </NavLink>
+        );
+      })}
     </Flex>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/StorageLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/StorageLayout.tsx
new file mode 100644
index 00000000000..857cd3a7b35
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/StorageLayout.tsx
@@ -0,0 +1,40 @@
+/*!
+ * 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 { useTranslation } from "react-i18next";
+import { MdOutlineStorage, MdSyncAlt } from "react-icons/md";
+import { Outlet } from "react-router-dom";
+
+import { NavTabs } from "src/layouts/Details/NavTabs";
+
+/** Sub-nav tabs shared by the task-store and xcom routes. */
+export const StorageLayout = () => {
+  const { t: translate } = useTranslation("dag");
+
+  return (
+    <>
+      <NavTabs
+        tabs={[
+          { icon: <MdOutlineStorage />, label: translate("tabs.taskStore"), 
value: "task-store" },
+          { icon: <MdSyncAlt />, label: translate("tabs.xcom"), value: "xcom" 
},
+        ]}
+      />
+      <Outlet />
+    </>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetEvents.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetEvents.tsx
new file mode 100644
index 00000000000..efb4fcb2bbe
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetEvents.tsx
@@ -0,0 +1,79 @@
+/*!
+ * 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 { useParams, useSearchParams } from "react-router-dom";
+
+import { useAssetServiceGetAssetEvents } from "openapi/queries";
+import { AssetEvents as AssetEventsTable } from 
"src/components/Assets/AssetEvents";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { SearchParamsKeys } from "src/constants/searchParams";
+
+export const AssetEvents = () => {
+  const { assetId: assetIdParam } = useParams();
+  const assetId = assetIdParam === undefined ? undefined : 
parseInt(assetIdParam, 10);
+
+  const { setTableURLState, tableURLState } = useTableURLState();
+  const { pagination, sorting } = tableURLState;
+  const [sort] = sorting;
+  const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : 
["-timestamp"];
+
+  const { DAG_ID, END_DATE, START_DATE, TASK_ID } = SearchParamsKeys;
+  const [searchParams] = useSearchParams();
+
+  const { data, isLoading } = useAssetServiceGetAssetEvents(
+    {
+      assetId,
+      limit: pagination.pageSize,
+      offset: pagination.pageIndex * pagination.pageSize,
+      orderBy,
+      sourceDagId: searchParams.get(DAG_ID) ?? undefined,
+      sourceTaskId: searchParams.get(TASK_ID) ?? undefined,
+      timestampGte: searchParams.get(START_DATE) ?? undefined,
+      timestampLte: searchParams.get(END_DATE) ?? undefined,
+    },
+    undefined,
+    { enabled: Boolean(assetId) },
+  );
+
+  const setOrderBy = (value: string) => {
+    setTableURLState({
+      pagination,
+      sorting: [
+        {
+          desc: value.startsWith("-"),
+          id: value.replace("-", ""),
+        },
+      ],
+    });
+  };
+
+  return (
+    <Box h="100%" overflow="auto" pt={2}>
+      <AssetEventsTable
+        assetId={assetId}
+        data={data}
+        isLoading={isLoading}
+        setOrderBy={setOrderBy}
+        setTableUrlState={setTableURLState}
+        showFilters
+        tableUrlState={tableURLState}
+      />
+    </Box>
+  );
+};
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 47a7edf6295..ad8a1fca308 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetLayout.tsx
@@ -16,20 +16,19 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { HStack, Box, Text, Code } from "@chakra-ui/react";
+import { HStack, Box, Code, Text } from "@chakra-ui/react";
 import { useReactFlow } from "@xyflow/react";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
+import { MdOutlineStorage, MdTimeline } from "react-icons/md";
 import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
-import { useParams, useSearchParams } from "react-router-dom";
+import { Outlet, useParams } from "react-router-dom";
 
-import { useAssetServiceGetAsset, useAssetServiceGetAssetEvents } from 
"openapi/queries";
-import { AssetEvents } from "src/components/Assets/AssetEvents";
+import { useAssetServiceGetAsset } from "openapi/queries";
 import { BreadcrumbStats } from "src/components/BreadcrumbStats";
-import { useTableURLState } from "src/components/DataTable/useTableUrlState";
 import { ProgressBar } from "src/components/ui";
-import { SearchParamsKeys } from "src/constants/searchParams";
 import { GroupsProvider } from "src/context/groups";
+import { NavTabs } from "src/layouts/Details/NavTabs";
 
 import { AssetGraph } from "./AssetGraph";
 import { AssetPanelButtons } from "./AssetPanelButtons";
@@ -42,11 +41,6 @@ export const AssetLayout = () => {
   const direction = i18n.dir();
   const [dependencyType, setDependencyType] = useState<"data" | 
"scheduling">("scheduling");
 
-  const { setTableURLState, tableURLState } = useTableURLState();
-  const { pagination, sorting } = tableURLState;
-  const [sort] = sorting;
-  const orderBy = sort ? [`${sort.desc ? "-" : ""}${sort.id}`] : 
["-timestamp"];
-
   const { data: asset, isLoading } = useAssetServiceGetAsset(
     { assetId: assetId === undefined ? 0 : parseInt(assetId, 10) },
     undefined,
@@ -63,37 +57,13 @@ export const AssetLayout = () => {
     },
   ];
 
-  const { DAG_ID, END_DATE, START_DATE, TASK_ID } = SearchParamsKeys;
-  const [searchParams] = useSearchParams();
-  const { data, isLoading: isLoadingEvents } = useAssetServiceGetAssetEvents(
-    {
-      assetId: asset?.id,
-      limit: pagination.pageSize,
-      offset: pagination.pageIndex * pagination.pageSize,
-      orderBy,
-      sourceDagId: searchParams.get(DAG_ID) ?? undefined,
-      sourceTaskId: searchParams.get(TASK_ID) ?? undefined,
-      timestampGte: searchParams.get(START_DATE) ?? undefined,
-      timestampLte: searchParams.get(END_DATE) ?? undefined,
-    },
-    undefined,
-    { enabled: Boolean(asset?.id) },
-  );
-
-  const setOrderBy = (value: string) => {
-    setTableURLState({
-      pagination,
-      sorting: [
-        {
-          desc: value.startsWith("-"),
-          id: value.replace("-", ""),
-        },
-      ],
-    });
-  };
-
   const { fitView, getZoom } = useReactFlow();
 
+  const tabs = [
+    { icon: <MdTimeline />, label: translate("assets:events"), value: "" },
+    { icon: <MdOutlineStorage />, label: translate("assets:assetStore.title"), 
value: "asset-store" },
+  ];
+
   return (
     <>
       <HStack justifyContent="space-between" mb={2}>
@@ -150,17 +120,8 @@ export const AssetLayout = () => {
               </Box>
             ) : null}
 
-            <Box h="100%" overflow="auto" pt={2}>
-              <AssetEvents
-                assetId={asset?.id}
-                data={data}
-                isLoading={isLoadingEvents}
-                setOrderBy={setOrderBy}
-                setTableUrlState={setTableURLState}
-                showFilters={true}
-                tableUrlState={tableURLState}
-              />
-            </Box>
+            <NavTabs tabs={tabs} />
+            <Outlet />
           </Panel>
         </PanelGroup>
       </Box>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AddAssetStoreButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AddAssetStoreButton.tsx
new file mode 100644
index 00000000000..09a04122716
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AddAssetStoreButton.tsx
@@ -0,0 +1,42 @@
+/*!
+ * 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 { Button, useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiPlus } from "react-icons/fi";
+
+import { AssetStoreModal } from "./AssetStoreModal";
+
+type Props = {
+  readonly assetId: number;
+};
+
+export const AddAssetStoreButton = ({ assetId }: Props) => {
+  const { t: translate } = useTranslation("assets");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  return (
+    <>
+      <Button onClick={onOpen}>
+        <FiPlus /> {translate("assetStore.add")}
+      </Button>
+
+      <AssetStoreModal assetId={assetId} isOpen={open} mode="add" 
onClose={onClose} />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStore.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStore.tsx
new file mode 100644
index 00000000000..418a46b17cd
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStore.tsx
@@ -0,0 +1,110 @@
+/*!
+ * 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 type { ColumnDef } from "@tanstack/react-table";
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import { useAssetStoreServiceListAssetStore } from "openapi/queries";
+import type { AssetStoreResponse } from "openapi/requests";
+import { DataTable } from "src/components/DataTable";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StoreValueCell } from "src/components/StoreValueCell";
+import Time from "src/components/Time";
+
+import { AddAssetStoreButton } from "./AddAssetStoreButton";
+import { ClearAllAssetStoreButton } from "./ClearAllAssetStoreButton";
+import { DeleteAssetStoreButton } from "./DeleteAssetStoreButton";
+import { EditAssetStoreButton } from "./EditAssetStoreButton";
+
+type ColumnsProps = {
+  readonly assetId: number;
+  readonly translate: (key: string) => string;
+};
+
+const getColumns = ({ assetId, translate }: ColumnsProps): 
Array<ColumnDef<AssetStoreResponse>> => [
+  {
+    accessorKey: "key",
+    cell: ({ row: { original } }) => <Text>{original.key}</Text>,
+    header: translate("common:key"),
+  },
+  {
+    accessorKey: "value",
+    cell: ({ row: { original } }) => <StoreValueCell value={original.value} />,
+    enableSorting: false,
+    header: translate("common:value"),
+  },
+  {
+    accessorKey: "updated_at",
+    cell: ({ row: { original } }) => <Time datetime={original.updated_at} />,
+    header: translate("common:table.updatedAt"),
+  },
+  {
+    accessorKey: "actions",
+    cell: ({ row: { original } }) => (
+      <Flex justifyContent="end">
+        <EditAssetStoreButton assetId={assetId} storeKey={original.key} />
+        <DeleteAssetStoreButton assetId={assetId} storeKey={original.key} />
+      </Flex>
+    ),
+    enableSorting: false,
+    header: "",
+  },
+];
+
+export const AssetStore = () => {
+  const { t: translate } = useTranslation(["assets", "common"]);
+  const { assetId: rawAssetId } = useParams();
+  const assetId = rawAssetId === undefined ? 0 : parseInt(rawAssetId, 10);
+  const { setTableURLState, tableURLState } = useTableURLState();
+  const { pagination } = tableURLState;
+
+  const { data, error, isFetching, isLoading } = 
useAssetStoreServiceListAssetStore({
+    assetId,
+    limit: pagination.pageSize,
+    offset: pagination.pageIndex * pagination.pageSize,
+  });
+
+  const columns = getColumns({ assetId, translate });
+
+  return (
+    <>
+      <Flex gap={2} justifyContent="flex-end" mb={2}>
+        <AddAssetStoreButton assetId={assetId} />
+        {(data?.total_entries ?? 0) > 0 ? <ClearAllAssetStoreButton 
assetId={assetId} /> : undefined}
+      </Flex>
+
+      <ErrorAlert error={error} />
+      <DataTable
+        columns={columns}
+        data={data?.asset_store ?? []}
+        displayMode="table"
+        initialState={tableURLState}
+        isFetching={isFetching}
+        isLoading={isLoading}
+        modelName="assets:assetStore.title"
+        noRowsMessage={translate("assetStore.emptyState")}
+        onStateChange={setTableURLState}
+        showRowCountHeading={false}
+        total={data?.total_entries ?? 0}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStoreModal.tsx 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStoreModal.tsx
new file mode 100644
index 00000000000..93e49fd306a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/AssetStoreModal.tsx
@@ -0,0 +1,138 @@
+/*!
+ * 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, Button, Heading, Input, Text, VStack } from "@chakra-ui/react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+  useAssetStoreServiceGetAssetStore,
+  useAssetStoreServiceGetAssetStoreKey,
+  useAssetStoreServiceListAssetStoreKey,
+  useAssetStoreServiceSetAssetStore,
+} from "openapi/queries";
+import { JsonEditor } from "src/components/JsonEditor";
+import { Dialog, ProgressBar } from "src/components/ui";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type Props = {
+  readonly assetId: number;
+  readonly isOpen: boolean;
+  readonly mode: "add" | "edit";
+  readonly onClose: () => void;
+  readonly storeKey?: string;
+};
+
+const isJsonValid = (val: string) => {
+  if (!val.trim()) {
+    return false;
+  }
+
+  try {
+    JSON.parse(val);
+
+    return true;
+  } catch {
+    return false;
+  }
+};
+
+export const AssetStoreModal = ({ assetId, isOpen, mode, onClose, storeKey }: 
Props) => {
+  const { t: translate } = useTranslation(["assets", "common"]);
+  const [key, setKey] = useState("");
+  const [value, setValue] = useState("");
+  const isEditMode = mode === "edit";
+  const isValueValid = isJsonValid(value);
+
+  const { data: existingState, isLoading: isFetchingExisting } = 
useAssetStoreServiceGetAssetStore(
+    { assetId, key: storeKey ?? "" },
+    undefined,
+    { enabled: isEditMode && Boolean(storeKey) },
+  );
+
+  useEffect(() => {
+    if (isEditMode && existingState !== undefined) {
+      setValue(JSON.stringify(existingState.value, null, 2));
+    }
+  }, [existingState, isEditMode]);
+
+  const { isPending, mutate: setAssetStore } = 
useAssetStoreServiceSetAssetStore(
+    useStoreMutation({
+      invalidationKeys: [useAssetStoreServiceListAssetStoreKey, 
useAssetStoreServiceGetAssetStoreKey],
+      onSuccessConfirm: onClose,
+      operation: isEditMode ? "update" : "create",
+      resourceName: translate("assetStore.title"),
+    }),
+  );
+
+  const onSave = () => {
+    setAssetStore({
+      assetId,
+      key: isEditMode ? (storeKey ?? "") : key,
+      requestBody: { value: JSON.parse(value) },
+    });
+  };
+
+  return (
+    <Dialog.Root lazyMount onOpenChange={onClose} open={isOpen} unmountOnExit>
+      <Dialog.Content backdrop>
+        <Dialog.Header>
+          <Heading size="lg">{translate(`assets:assetStore.${mode}`)}</Heading>
+        </Dialog.Header>
+        <Dialog.CloseTrigger />
+        <Dialog.Body>
+          {isFetchingExisting ? (
+            <ProgressBar size="xs" />
+          ) : (
+            <VStack gap={4}>
+              <Box width="100%">
+                <Text fontWeight="bold" mb={2}>
+                  {translate("common:key")}
+                </Text>
+                {isEditMode ? (
+                  <Text>{storeKey}</Text>
+                ) : (
+                  <Input onChange={(event) => setKey(event.target.value)} 
value={key} />
+                )}
+              </Box>
+
+              <Box width="100%">
+                <Text fontWeight="bold" mb={2}>
+                  {translate("common:value")}
+                </Text>
+                <JsonEditor onChange={setValue} value={value} />
+              </Box>
+            </VStack>
+          )}
+        </Dialog.Body>
+        <Dialog.Footer>
+          <Button onClick={onClose} variant="outline">
+            {translate("common:modal.cancel")}
+          </Button>
+          <Button
+            disabled={isFetchingExisting || !isValueValid || (!isEditMode && 
key === "")}
+            loading={isPending}
+            onClick={onSave}
+          >
+            {translate("common:modal.save")}
+          </Button>
+        </Dialog.Footer>
+      </Dialog.Content>
+    </Dialog.Root>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/ClearAllAssetStoreButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/ClearAllAssetStoreButton.tsx
new file mode 100644
index 00000000000..b3c64369046
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/ClearAllAssetStoreButton.tsx
@@ -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 { Button, useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiTrash2 } from "react-icons/fi";
+
+import {
+  useAssetStoreServiceClearAssetStore,
+  useAssetStoreServiceGetAssetStoreKey,
+  useAssetStoreServiceListAssetStoreKey,
+} from "openapi/queries";
+import DeleteDialog from "src/components/DeleteDialog";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type ClearAllAssetStoreButtonProps = {
+  readonly assetId: number;
+};
+
+export const ClearAllAssetStoreButton = ({ assetId }: 
ClearAllAssetStoreButtonProps) => {
+  const { t: translate } = useTranslation("assets");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  const { isPending, mutate } = useAssetStoreServiceClearAssetStore(
+    useStoreMutation({
+      invalidationKeys: [useAssetStoreServiceListAssetStoreKey, 
useAssetStoreServiceGetAssetStoreKey],
+      onSuccessConfirm: onClose,
+      operation: "delete",
+      resourceName: translate("assetStore.clearAll.resource"),
+    }),
+  );
+
+  return (
+    <>
+      <Button colorPalette="danger" onClick={onOpen} variant="outline">
+        <FiTrash2 /> {translate("assetStore.clearAll.title")}
+      </Button>
+
+      <DeleteDialog
+        deleteButtonText={translate("assetStore.clearAll.title")}
+        isDeleting={isPending}
+        onClose={onClose}
+        onDelete={() => mutate({ assetId })}
+        open={open}
+        resourceName={translate("assetStore.clearAll.resource")}
+        title={translate("assetStore.clearAll.title")}
+        warningText={translate("assetStore.clearAll.warning")}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/DeleteAssetStoreButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/DeleteAssetStoreButton.tsx
new file mode 100644
index 00000000000..9c9399f3688
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/DeleteAssetStoreButton.tsx
@@ -0,0 +1,67 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiTrash2 } from "react-icons/fi";
+
+import {
+  useAssetStoreServiceDeleteAssetStore,
+  useAssetStoreServiceGetAssetStoreKey,
+  useAssetStoreServiceListAssetStoreKey,
+} from "openapi/queries";
+import DeleteDialog from "src/components/DeleteDialog";
+import { IconButton } from "src/components/ui";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type Props = {
+  readonly assetId: number;
+  readonly storeKey: string;
+};
+
+export const DeleteAssetStoreButton = ({ assetId, storeKey }: Props) => {
+  const { t: translate } = useTranslation("assets");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  const { isPending, mutate } = useAssetStoreServiceDeleteAssetStore(
+    useStoreMutation({
+      invalidationKeys: [useAssetStoreServiceListAssetStoreKey, 
useAssetStoreServiceGetAssetStoreKey],
+      onSuccessConfirm: onClose,
+      operation: "delete",
+      resourceName: translate("assetStore.title"),
+    }),
+  );
+
+  return (
+    <>
+      <IconButton colorPalette="danger" label={translate("assetStore.delete")} 
onClick={onOpen}>
+        <FiTrash2 />
+      </IconButton>
+
+      <DeleteDialog
+        isDeleting={isPending}
+        onClose={onClose}
+        onDelete={() => mutate({ assetId, key: storeKey })}
+        open={open}
+        resourceName={storeKey}
+        title={translate("assetStore.delete")}
+        warningText={translate("assetStore.deleteWarning")}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/EditAssetStoreButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/EditAssetStoreButton.tsx
new file mode 100644
index 00000000000..ff437fc6224
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/EditAssetStoreButton.tsx
@@ -0,0 +1,45 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiEdit2 } from "react-icons/fi";
+
+import { IconButton } from "src/components/ui";
+
+import { AssetStoreModal } from "./AssetStoreModal";
+
+type Props = {
+  readonly assetId: number;
+  readonly storeKey: string;
+};
+
+export const EditAssetStoreButton = ({ assetId, storeKey }: Props) => {
+  const { t: translate } = useTranslation("assets");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  return (
+    <>
+      <IconButton label={translate("assetStore.edit")} onClick={onOpen} 
variant="ghost">
+        <FiEdit2 />
+      </IconButton>
+
+      <AssetStoreModal assetId={assetId} isOpen={open} mode="edit" 
onClose={onClose} storeKey={storeKey} />
+    </>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/index.ts 
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/index.ts
new file mode 100644
index 00000000000..72fa2f40888
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetStore/index.ts
@@ -0,0 +1,20 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export * from "./AssetStore";
diff --git a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
index ede2262088f..513a3452760 100644
--- a/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/GroupTaskInstance/Header.tsx
@@ -32,9 +32,9 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
   const { t: translate } = useTranslation();
   const entries: Array<{ label: string; value: number | ReactNode | string }> 
= [];
 
-  Object.entries(taskInstance.child_states ?? {}).forEach(([taskState, count]) 
=> {
+  Object.entries(taskInstance.child_states ?? {}).forEach(([state, count]) => {
     entries.push({
-      label: translate("total", { state: 
translate(`states.${taskState.toLowerCase()}`) }),
+      label: translate("total", { state: 
translate(`states.${state.toLowerCase()}`) }),
       value: count,
     });
   });
diff --git 
a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
index 592f36e1077..f84de17c164 100644
--- a/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/MappedTaskInstance/Header.tsx
@@ -34,9 +34,9 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: LightGridTaskI
   const entries: Array<{ label: string; value: number | ReactNode | string }> 
= [];
   let taskCount: number = 0;
 
-  Object.entries(taskInstance.child_states ?? {}).forEach(([taskState, count]) 
=> {
+  Object.entries(taskInstance.child_states ?? {}).forEach(([state, count]) => {
     entries.push({
-      label: translate("total", { state: 
translate(`states.${taskState.toLowerCase()}`) }),
+      label: translate("total", { state: 
translate(`states.${state.toLowerCase()}`) }),
       value: count,
     });
     taskCount += count;
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
index 8c0ac81dc03..d053c68534c 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -35,6 +35,13 @@ import { getDuration, renderDuration } from "src/utils";
 export const Header = ({ taskInstance }: { readonly taskInstance: 
TaskInstanceResponse }) => {
   const { t: translate } = useTranslation();
 
+  const [note, setNote] = useState<string | null>(taskInstance.note);
+
+  const dagId = taskInstance.dag_id;
+  const dagRunId = taskInstance.dag_run_id;
+  const taskId = taskInstance.task_id;
+  const mapIndex = taskInstance.map_index;
+
   const stats = [
     { label: translate("task.operator"), value: taskInstance.operator_name },
     ...(taskInstance.map_index > -1
@@ -61,13 +68,6 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
     },
   ];
 
-  const [note, setNote] = useState<string | null>(taskInstance.note);
-
-  const dagId = taskInstance.dag_id;
-  const dagRunId = taskInstance.dag_run_id;
-  const taskId = taskInstance.task_id;
-  const mapIndex = taskInstance.map_index;
-
   const { isPending, mutate } = usePatchTaskInstance({
     dagId,
     dagRunId,
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 6a943f7b4c7..0855cd4145e 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/TaskInstance.tsx
@@ -20,7 +20,7 @@ import { Heading } from "@chakra-ui/react";
 import { ReactFlowProvider } from "@xyflow/react";
 import { useTranslation } from "react-i18next";
 import { FiCode, FiDatabase, FiUser } from "react-icons/fi";
-import { MdDetails, MdOutlineEventNote, MdOutlineTask, MdReorder, MdSyncAlt } 
from "react-icons/md";
+import { MdDetails, MdOutlineEventNote, MdOutlineStorage, MdOutlineTask, 
MdReorder } from "react-icons/md";
 import { PiBracketsCurlyBold } from "react-icons/pi";
 import { useParams } from "react-router-dom";
 
@@ -48,7 +48,12 @@ export const TaskInstance = () => {
       label: translate("tabs.renderedTemplates"),
       value: "rendered_templates",
     },
-    { icon: <MdSyncAlt />, label: translate("tabs.xcom"), value: "xcom" },
+    {
+      icon: <MdOutlineStorage />,
+      label: translate("tabs.storage"),
+      matchPaths: ["task-store", "xcom"],
+      value: "task-store",
+    },
     { icon: <FiDatabase />, label: translate("tabs.assetEvents"), value: 
"asset_events" },
     { icon: <MdOutlineEventNote />, label: translate("tabs.auditLog"), value: 
"events" },
     { icon: <FiCode />, label: translate("tabs.code"), value: "code" },
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskStore/AddTaskStoreButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/AddTaskStoreButton.tsx
new file mode 100644
index 00000000000..49936456c29
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/AddTaskStoreButton.tsx
@@ -0,0 +1,53 @@
+/*!
+ * 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 { Button, useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiPlus } from "react-icons/fi";
+
+import { TaskStoreModal } from "./TaskStoreModal";
+
+type Props = {
+  readonly dagId: string;
+  readonly mapIndex: number;
+  readonly runId: string;
+  readonly taskId: string;
+};
+
+export const AddTaskStoreButton = ({ dagId, mapIndex, runId, taskId }: Props) 
=> {
+  const { t: translate } = useTranslation("dag");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  return (
+    <>
+      <Button onClick={onOpen}>
+        <FiPlus /> {translate("taskStore.add")}
+      </Button>
+
+      <TaskStoreModal
+        dagId={dagId}
+        isOpen={open}
+        mapIndex={mapIndex}
+        mode="add"
+        onClose={onClose}
+        runId={runId}
+        taskId={taskId}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskStore/ClearAllTaskStoreButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/ClearAllTaskStoreButton.tsx
new file mode 100644
index 00000000000..3635d611078
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/ClearAllTaskStoreButton.tsx
@@ -0,0 +1,69 @@
+/*!
+ * 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 { Button, useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiTrash2 } from "react-icons/fi";
+
+import {
+  useTaskStoreServiceClearTaskStore,
+  useTaskStoreServiceGetTaskStoreKey,
+  useTaskStoreServiceListTaskStoreKey,
+} from "openapi/queries";
+import DeleteDialog from "src/components/DeleteDialog";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type Props = {
+  readonly dagId: string;
+  readonly mapIndex: number;
+  readonly runId: string;
+  readonly taskId: string;
+};
+
+export const ClearAllTaskStoreButton = ({ dagId, mapIndex, runId, taskId }: 
Props) => {
+  const { t: translate } = useTranslation("dag");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  const { isPending, mutate } = useTaskStoreServiceClearTaskStore(
+    useStoreMutation({
+      invalidationKeys: [useTaskStoreServiceListTaskStoreKey, 
useTaskStoreServiceGetTaskStoreKey],
+      onSuccessConfirm: onClose,
+      operation: "delete",
+      resourceName: translate("taskStore.clearAll.resource"),
+    }),
+  );
+
+  return (
+    <>
+      <Button colorPalette="danger" onClick={onOpen} variant="outline">
+        <FiTrash2 /> {translate("taskStore.clearAll.title")}
+      </Button>
+
+      <DeleteDialog
+        deleteButtonText={translate("taskStore.clearAll.title")}
+        isDeleting={isPending}
+        onClose={onClose}
+        onDelete={() => mutate({ dagId, dagRunId: runId, mapIndex, taskId })}
+        open={open}
+        resourceName={translate("taskStore.clearAll.resource")}
+        title={translate("taskStore.clearAll.title")}
+        warningText={translate("taskStore.clearAll.warning")}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskStore/DeleteTaskStoreButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/DeleteTaskStoreButton.tsx
new file mode 100644
index 00000000000..fe4dc9c9a15
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/DeleteTaskStoreButton.tsx
@@ -0,0 +1,70 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiTrash2 } from "react-icons/fi";
+
+import {
+  useTaskStoreServiceDeleteTaskStore,
+  useTaskStoreServiceGetTaskStoreKey,
+  useTaskStoreServiceListTaskStoreKey,
+} from "openapi/queries";
+import DeleteDialog from "src/components/DeleteDialog";
+import { IconButton } from "src/components/ui";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type Props = {
+  readonly dagId: string;
+  readonly mapIndex: number;
+  readonly runId: string;
+  readonly storeKey: string;
+  readonly taskId: string;
+};
+
+export const DeleteTaskStoreButton = ({ dagId, mapIndex, runId, storeKey, 
taskId }: Props) => {
+  const { t: translate } = useTranslation("dag");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  const { isPending, mutate } = useTaskStoreServiceDeleteTaskStore(
+    useStoreMutation({
+      invalidationKeys: [useTaskStoreServiceListTaskStoreKey, 
useTaskStoreServiceGetTaskStoreKey],
+      onSuccessConfirm: onClose,
+      operation: "delete",
+      resourceName: translate("taskStore.title"),
+    }),
+  );
+
+  return (
+    <>
+      <IconButton colorPalette="danger" label={translate("taskStore.delete")} 
onClick={onOpen}>
+        <FiTrash2 />
+      </IconButton>
+
+      <DeleteDialog
+        isDeleting={isPending}
+        onClose={onClose}
+        onDelete={() => mutate({ dagId, dagRunId: runId, key: storeKey, 
mapIndex, taskId })}
+        open={open}
+        resourceName={storeKey}
+        title={translate("taskStore.delete")}
+        warningText={translate("taskStore.deleteWarning")}
+      />
+    </>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskStore/EditTaskStoreButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/EditTaskStoreButton.tsx
new file mode 100644
index 00000000000..ed6b4ef2a44
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/EditTaskStoreButton.tsx
@@ -0,0 +1,57 @@
+/*!
+ * 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 { useDisclosure } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiEdit2 } from "react-icons/fi";
+
+import { IconButton } from "src/components/ui";
+
+import { TaskStoreModal } from "./TaskStoreModal";
+
+type Props = {
+  readonly dagId: string;
+  readonly mapIndex: number;
+  readonly runId: string;
+  readonly storeKey: string;
+  readonly taskId: string;
+};
+
+export const EditTaskStoreButton = ({ dagId, mapIndex, runId, storeKey, taskId 
}: Props) => {
+  const { t: translate } = useTranslation("dag");
+  const { onClose, onOpen, open } = useDisclosure();
+
+  return (
+    <>
+      <IconButton label={translate("taskStore.edit")} onClick={onOpen} 
variant="ghost">
+        <FiEdit2 />
+      </IconButton>
+
+      <TaskStoreModal
+        dagId={dagId}
+        isOpen={open}
+        mapIndex={mapIndex}
+        mode="edit"
+        onClose={onClose}
+        runId={runId}
+        storeKey={storeKey}
+        taskId={taskId}
+      />
+    </>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStore.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStore.tsx
new file mode 100644
index 00000000000..1b795e17c53
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStore.tsx
@@ -0,0 +1,165 @@
+/*!
+ * 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 { Badge, Flex, Text } from "@chakra-ui/react";
+import type { ColumnDef } from "@tanstack/react-table";
+import { useTranslation } from "react-i18next";
+import { useParams } from "react-router-dom";
+
+import {
+  useTaskInstanceServiceGetMappedTaskInstance,
+  useTaskStoreServiceListTaskStore,
+} from "openapi/queries";
+import type { TaskStoreResponse } from "openapi/requests";
+import { DataTable } from "src/components/DataTable";
+import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { ErrorAlert } from "src/components/ErrorAlert";
+import { StoreValueCell } from "src/components/StoreValueCell";
+import Time from "src/components/Time";
+import { isStatePending, useAutoRefresh } from "src/utils";
+
+import { AddTaskStoreButton } from "./AddTaskStoreButton";
+import { ClearAllTaskStoreButton } from "./ClearAllTaskStoreButton";
+import { DeleteTaskStoreButton } from "./DeleteTaskStoreButton";
+import { EditTaskStoreButton } from "./EditTaskStoreButton";
+
+type ColumnsProps = {
+  readonly dagId: string;
+  readonly mapIndex: number;
+  readonly runId: string;
+  readonly taskId: string;
+  readonly translate: (key: string) => string;
+};
+
+const getColumns = ({
+  dagId,
+  mapIndex,
+  runId,
+  taskId,
+  translate,
+}: ColumnsProps): Array<ColumnDef<TaskStoreResponse>> => [
+  {
+    accessorKey: "key",
+    cell: ({ row: { original } }) => <Text>{original.key}</Text>,
+    header: translate("common:key"),
+  },
+  {
+    accessorKey: "value",
+    cell: ({ row: { original } }) => <StoreValueCell value={original.value} />,
+    enableSorting: false,
+    header: translate("common:value"),
+  },
+  {
+    accessorKey: "updated_at",
+    cell: ({ row: { original } }) => <Time datetime={original.updated_at} />,
+    header: translate("common:table.updatedAt"),
+  },
+  {
+    accessorKey: "expires_at",
+    cell: ({ row: { original } }) =>
+      Boolean(original.expires_at) ? (
+        <Time datetime={original.expires_at} />
+      ) : (
+        <Badge colorPalette="gray" variant="subtle">
+          {translate("taskStore.expiresAt.never")}
+        </Badge>
+      ),
+    header: translate("taskStore.expiresAt.column"),
+  },
+  {
+    accessorKey: "actions",
+    cell: ({ row: { original } }) => (
+      <Flex justifyContent="end">
+        <EditTaskStoreButton
+          dagId={dagId}
+          mapIndex={mapIndex}
+          runId={runId}
+          storeKey={original.key}
+          taskId={taskId}
+        />
+        <DeleteTaskStoreButton
+          dagId={dagId}
+          mapIndex={mapIndex}
+          runId={runId}
+          storeKey={original.key}
+          taskId={taskId}
+        />
+      </Flex>
+    ),
+    enableSorting: false,
+    header: "",
+  },
+];
+
+export const TaskStore = () => {
+  const { dagId = "", mapIndex: rawMapIndex = "-1", runId = "", taskId = "" } 
= useParams();
+  const mapIndex = parseInt(rawMapIndex, 10);
+  const refetchInterval = useAutoRefresh({ dagId });
+
+  const { data: taskInstance } = useTaskInstanceServiceGetMappedTaskInstance({
+    dagId,
+    dagRunId: runId,
+    mapIndex,
+    taskId,
+  });
+
+  const { t: translate } = useTranslation(["dag", "common"]);
+  const { setTableURLState, tableURLState } = useTableURLState();
+  const { pagination } = tableURLState;
+
+  const { data, error, isFetching, isLoading } = 
useTaskStoreServiceListTaskStore(
+    {
+      dagId,
+      dagRunId: runId,
+      limit: pagination.pageSize,
+      mapIndex,
+      offset: pagination.pageIndex * pagination.pageSize,
+      taskId,
+    },
+    undefined,
+    { refetchInterval: isStatePending(taskInstance?.state) ? refetchInterval : 
false },
+  );
+
+  const columns = getColumns({ dagId, mapIndex, runId, taskId, translate });
+
+  return (
+    <>
+      <Flex gap={2} justifyContent="flex-end" mb={2}>
+        <AddTaskStoreButton dagId={dagId} mapIndex={mapIndex} runId={runId} 
taskId={taskId} />
+        {(data?.total_entries ?? 0) > 0 ? (
+          <ClearAllTaskStoreButton dagId={dagId} mapIndex={mapIndex} 
runId={runId} taskId={taskId} />
+        ) : undefined}
+      </Flex>
+
+      <ErrorAlert error={error} />
+      <DataTable
+        columns={columns}
+        data={data?.task_store ?? []}
+        displayMode="table"
+        initialState={tableURLState}
+        isFetching={isFetching}
+        isLoading={isLoading}
+        modelName="dag:taskStore.title"
+        noRowsMessage={translate("taskStore.emptyStore")}
+        onStateChange={setTableURLState}
+        showRowCountHeading={false}
+        total={data?.total_entries ?? 0}
+      />
+    </>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStoreModal.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStoreModal.tsx
new file mode 100644
index 00000000000..6f0dd6e65f7
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/TaskStoreModal.tsx
@@ -0,0 +1,196 @@
+/*!
+ * 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, Button, Heading, Input, RadioCard, Text, VStack } from 
"@chakra-ui/react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import {
+  useTaskStoreServiceGetTaskStore,
+  useTaskStoreServiceGetTaskStoreKey,
+  useTaskStoreServiceListTaskStoreKey,
+  useTaskStoreServiceSetTaskStore,
+} from "openapi/queries";
+import { JsonEditor } from "src/components/JsonEditor";
+import { Dialog, ProgressBar } from "src/components/ui";
+import { useStoreMutation } from "src/queries/useStoreMutation";
+
+type Props = {
+  readonly dagId: string;
+  readonly isOpen: boolean;
+  readonly mapIndex: number;
+  readonly mode: "add" | "edit";
+  readonly onClose: () => void;
+  readonly runId: string;
+  readonly storeKey?: string;
+  readonly taskId: string;
+};
+
+const isJsonValid = (val: string) => {
+  if (!val.trim()) {
+    return false;
+  }
+
+  try {
+    JSON.parse(val);
+
+    return true;
+  } catch {
+    return false;
+  }
+};
+
+export const TaskStoreModal = ({
+  dagId,
+  isOpen,
+  mapIndex,
+  mode,
+  onClose,
+  runId,
+  storeKey,
+  taskId,
+}: Props) => {
+  const { t: translate } = useTranslation(["dag", "common"]);
+  const [key, setKey] = useState("");
+  const [value, setValue] = useState("");
+  const [expiresAt, setExpiresAt] = useState<"default" | "never">("default");
+  const isEditMode = mode === "edit";
+  const isValueValid = isJsonValid(value);
+
+  const { data: existingState, isLoading: isFetchingExisting } = 
useTaskStoreServiceGetTaskStore(
+    { dagId, dagRunId: runId, key: storeKey ?? "", mapIndex, taskId },
+    undefined,
+    { enabled: isEditMode && Boolean(storeKey) },
+  );
+
+  useEffect(() => {
+    if (isEditMode && existingState !== undefined) {
+      setValue(JSON.stringify(existingState.value, null, 2));
+      setExpiresAt(existingState.expires_at === null ? "never" : "default");
+    }
+  }, [existingState, isEditMode]);
+
+  const { isPending, mutate: setTaskStore } = useTaskStoreServiceSetTaskStore(
+    useStoreMutation({
+      invalidationKeys: [useTaskStoreServiceListTaskStoreKey, 
useTaskStoreServiceGetTaskStoreKey],
+      onSuccessConfirm: onClose,
+      operation: isEditMode ? "update" : "create",
+      resourceName: translate("taskStore.title"),
+    }),
+  );
+
+  const onSave = () => {
+    setTaskStore({
+      dagId,
+      dagRunId: runId,
+      key: isEditMode ? (storeKey ?? "") : key,
+      mapIndex,
+      requestBody: { expires_at: expiresAt === "never" ? null : "default", 
value: JSON.parse(value) },
+      taskId,
+    });
+  };
+
+  return (
+    <Dialog.Root lazyMount onOpenChange={onClose} open={isOpen} unmountOnExit>
+      <Dialog.Content backdrop>
+        <Dialog.Header>
+          <Heading size="lg">{translate(`dag:taskStore.${mode}`)}</Heading>
+        </Dialog.Header>
+        <Dialog.CloseTrigger />
+        <Dialog.Body>
+          {isFetchingExisting ? (
+            <ProgressBar size="xs" />
+          ) : (
+            <VStack gap={4}>
+              <Box width="100%">
+                <Text fontWeight="bold" mb={2}>
+                  {translate("common:key")}
+                </Text>
+                {isEditMode ? (
+                  <Text>{storeKey}</Text>
+                ) : (
+                  <Input onChange={(event) => setKey(event.target.value)} 
value={key} />
+                )}
+              </Box>
+
+              <Box width="100%">
+                <Text fontWeight="bold" mb={2}>
+                  {translate("common:value")}
+                </Text>
+                <JsonEditor onChange={setValue} value={value} />
+              </Box>
+
+              <Box width="100%">
+                <Text fontWeight="bold" mb={2}>
+                  {translate("dag:taskStore.expiresAt.label")}
+                </Text>
+                <RadioCard.Root
+                  onValueChange={(ev) => setExpiresAt(ev.value as "default" | 
"never")}
+                  value={expiresAt}
+                >
+                  <RadioCard.Item value="default">
+                    <RadioCard.ItemHiddenInput />
+                    <RadioCard.ItemControl>
+                      <RadioCard.ItemContent>
+                        <RadioCard.ItemText>
+                          {translate("dag:taskStore.expiresAt.default", { 
interval: "30 days" })}
+                        </RadioCard.ItemText>
+                      </RadioCard.ItemContent>
+                      <RadioCard.ItemIndicator />
+                    </RadioCard.ItemControl>
+                  </RadioCard.Item>
+                  <RadioCard.Item value="never">
+                    <RadioCard.ItemHiddenInput />
+                    <RadioCard.ItemControl>
+                      <RadioCard.ItemContent>
+                        
<RadioCard.ItemText>{translate("dag:taskStore.expiresAt.never")}</RadioCard.ItemText>
+                      </RadioCard.ItemContent>
+                      <RadioCard.ItemIndicator />
+                    </RadioCard.ItemControl>
+                  </RadioCard.Item>
+                  {/* TODO: Add a datetime picker for custom expiry once a 
picker component is available */}
+                  <RadioCard.Item disabled value="custom">
+                    <RadioCard.ItemHiddenInput />
+                    <RadioCard.ItemControl>
+                      <RadioCard.ItemContent>
+                        
<RadioCard.ItemText>{translate("dag:taskStore.expiresAt.custom")}</RadioCard.ItemText>
+                      </RadioCard.ItemContent>
+                      <RadioCard.ItemIndicator />
+                    </RadioCard.ItemControl>
+                  </RadioCard.Item>
+                </RadioCard.Root>
+              </Box>
+            </VStack>
+          )}
+        </Dialog.Body>
+        <Dialog.Footer>
+          <Button onClick={onClose} variant="outline">
+            {translate("common:modal.cancel")}
+          </Button>
+          <Button
+            disabled={isFetchingExisting || !isValueValid || (!isEditMode && 
key === "")}
+            loading={isPending}
+            onClick={onSave}
+          >
+            {translate("common:modal.save")}
+          </Button>
+        </Dialog.Footer>
+      </Dialog.Content>
+    </Dialog.Root>
+  );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskStore/index.ts 
b/airflow-core/src/airflow/ui/src/pages/TaskStore/index.ts
new file mode 100644
index 00000000000..ed6640b26be
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskStore/index.ts
@@ -0,0 +1,19 @@
+/*!
+ * 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 { TaskStore } from "./TaskStore";
diff --git a/airflow-core/src/airflow/ui/src/queries/useStoreMutation.ts 
b/airflow-core/src/airflow/ui/src/queries/useStoreMutation.ts
new file mode 100644
index 00000000000..c3a4d5e7c8d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useStoreMutation.ts
@@ -0,0 +1,67 @@
+/*!
+ * 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 { useQueryClient } from "@tanstack/react-query";
+import { useTranslation } from "react-i18next";
+
+import { toaster } from "src/components/ui";
+import { createErrorToaster } from "src/utils";
+
+type StoreMutationOperation = "create" | "delete" | "update";
+
+type UseStoreMutationOptions = {
+  readonly invalidationKeys: ReadonlyArray<unknown>;
+  readonly onSuccessConfirm?: () => void;
+  readonly operation: StoreMutationOperation;
+  /** Already-translated, human readable name of the mutated resource. */
+  readonly resourceName: string;
+};
+
+export const useStoreMutation = ({
+  invalidationKeys,
+  onSuccessConfirm,
+  operation,
+  resourceName,
+}: UseStoreMutationOptions) => {
+  const queryClient = useQueryClient();
+  const { t: translate } = useTranslation("common");
+
+  const onError = (error: unknown) => {
+    createErrorToaster(
+      error,
+      { params: { resourceName }, titleKey: `toaster.${operation}.error` },
+      translate,
+    );
+  };
+
+  const onSuccess = async () => {
+    await Promise.all(
+      invalidationKeys.map((queryKey) => queryClient.invalidateQueries({ 
queryKey: [queryKey] })),
+    );
+
+    onSuccessConfirm?.();
+
+    toaster.create({
+      description: translate(`toaster.${operation}.success.description`, { 
resourceName }),
+      title: translate(`toaster.${operation}.success.title`, { resourceName }),
+      type: "success",
+    });
+  };
+
+  return { onError, onSuccess };
+};
diff --git a/airflow-core/src/airflow/ui/src/router.tsx 
b/airflow-core/src/airflow/ui/src/router.tsx
index f54a785f3e5..d315443a56f 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -24,6 +24,8 @@ import { ConfigService } from "openapi/requests/services.gen";
 import { BaseLayout } from "src/layouts/BaseLayout";
 import { DagsLayout } from "src/layouts/DagsLayout";
 import { Asset } from "src/pages/Asset";
+import { AssetEvents } from "src/pages/Asset/AssetEvents";
+import { AssetStore } from "src/pages/Asset/AssetStore";
 import { AssetsList } from "src/pages/AssetsList";
 import { Configs } from "src/pages/Configs";
 import { Connections } from "src/pages/Connections";
@@ -60,9 +62,11 @@ import { Details as TaskInstanceDetails } from 
"src/pages/TaskInstance/Details";
 import { HITLResponse } from "src/pages/TaskInstance/HITLResponse";
 import { RenderedTemplates } from "src/pages/TaskInstance/RenderedTemplates";
 import { TaskInstances } from "src/pages/TaskInstances";
+import { TaskStore } from "src/pages/TaskStore";
 import { Variables } from "src/pages/Variables";
 import { XCom } from "src/pages/XCom";
 
+import { StorageLayout } from "./layouts/StorageLayout";
 import { client } from "./queryClient";
 
 const pluginRoute = {
@@ -73,7 +77,13 @@ const pluginRoute = {
 export const taskInstanceRoutes = [
   { element: <Logs />, index: true, path: undefined },
   { element: <Events />, path: "events" },
-  { element: <XCom />, path: "xcom" },
+  {
+    children: [
+      { element: <TaskStore />, path: "task-store" },
+      { element: <XCom />, path: "xcom" },
+    ],
+    element: <StorageLayout />,
+  },
   { element: <Code />, path: "code" },
   { element: <TaskInstanceDetails />, path: "details" },
   { element: <RenderedTemplates />, path: "rendered_templates" },
@@ -123,6 +133,10 @@ export const routerConfig = [
         path: "configs",
       },
       {
+        children: [
+          { element: <AssetEvents />, index: true },
+          { element: <AssetStore />, path: "asset-store" },
+        ],
         element: <Asset />,
         path: "assets/:assetId",
       },
diff --git a/airflow-core/src/airflow/ui/src/utils/links.ts 
b/airflow-core/src/airflow/ui/src/utils/links.ts
index e0de230f7a4..132a9e3f471 100644
--- a/airflow-core/src/airflow/ui/src/utils/links.ts
+++ b/airflow-core/src/airflow/ui/src/utils/links.ts
@@ -55,7 +55,20 @@ export const getNextHref = (location: Pick<Location, "hash" 
| "pathname" | "sear
   `${location.pathname}${location.search}${location.hash}`;
 
 export const getTaskInstanceAdditionalPath = (pathname: string): string => {
-  const subRoutes = taskInstanceRoutes.filter((route) => route.path !== 
undefined).map((route) => route.path);
+  const subRoutes = taskInstanceRoutes.flatMap((route) => {
+    if (route.path !== undefined) {
+      return [route.path];
+    }
+
+    // Include paths from children of pathless layout routes (e.g. StoragePage 
wrapping task-store and xcom)
+    if ("children" in route && Array.isArray(route.children)) {
+      return route.children
+        .filter((child) => "path" in child && typeof child.path === "string")
+        .map((child) => child.path);
+    }
+
+    return [];
+  });
   // Look for patterns like /tasks/{taskId}/mapped/{mapIndex}/{sub-route}
   const mappedRegex = /\/tasks\/[^/]+\/mapped\/[^/]+\/(?<subRoute>.+)$/u;
   const mappedMatch = mappedRegex.exec(pathname);

Reply via email to