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

pierrejeambrun 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 18506de1629 Feat note modal (#68362)
18506de1629 is described below

commit 18506de1629ce67310d89998adab45f9bc15f464
Author: Brent Bovenzi <[email protected]>
AuthorDate: Tue Jun 16 04:25:32 2026 -0600

    Feat note modal (#68362)
    
    * Update note UX
    
    * Remove useEffects
    
    * code cleanup
    
    * Fix markdown
    
    * Revert back to modal, with preview
    
    * Add ellipsis for cutoff notes
    
    * Fix note editor showing unsaved text after cancel and stray scrollbar
    
    ---------
    
    Co-authored-by: pierrejeambrun <[email protected]>
---
 .../airflow/ui/public/i18n/locales/en/common.json  |   5 +-
 .../ui/src/components/EditableMarkdownArea.tsx     |  85 ----------
 .../ui/src/components/EditableMarkdownButton.tsx   |  60 ++-----
 .../src/airflow/ui/src/components/HeaderCard.tsx   |   8 +-
 .../ui/src/components/MarkdownModal.test.tsx       | 131 +++++++++++++++
 .../airflow/ui/src/components/MarkdownModal.tsx    | 181 +++++++++++++++++++++
 .../src/airflow/ui/src/components/NoteIcon.tsx     |  25 +++
 .../airflow/ui/src/components/NotePreview.test.tsx |  98 +++++++++++
 .../src/airflow/ui/src/components/NotePreview.tsx  | 104 ++++++++++++
 .../airflow/ui/src/components/ReactMarkdown.tsx    |  34 ++--
 .../ui/src/components/ui/ResizableWrapper.tsx      |  10 +-
 .../src/airflow/ui/src/pages/DagRuns/DagRuns.tsx   |   2 +
 .../airflow/ui/src/pages/DagRuns/RunNoteButton.tsx |  42 +++++
 .../src/airflow/ui/src/pages/Run/Header.tsx        |  43 ++---
 .../airflow/ui/src/pages/TaskInstance/Header.tsx   |  52 ++----
 .../pages/TaskInstances/TaskInstanceNoteButton.tsx |  42 +++++
 .../ui/src/pages/TaskInstances/TaskInstances.tsx   |   2 +
 .../src/airflow/ui/src/queries/useDagRunNote.ts    |  41 +++++
 .../src/airflow/ui/src/queries/useNoteEditor.ts    |  48 ++++++
 .../airflow/ui/src/queries/useTaskInstanceNote.ts  |  52 ++++++
 20 files changed, 833 insertions(+), 232 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 32f9988a239..3aaa5115bb2 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
@@ -179,9 +179,12 @@
   "note": {
     "add": "Add a note",
     "dagRun": "Dag Run Note",
+    "edit": "Edit note",
     "label": "Note",
     "placeholder": "Add a note...",
-    "taskInstance": "Task Instance Note"
+    "preview": "Preview",
+    "taskInstance": "Task Instance Note",
+    "write": "Write"
   },
   "overallStatus": "Overall Status",
   "partitionedDagRun_one": "Partitioned Dag Run",
diff --git 
a/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx 
b/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx
deleted file mode 100644
index 3b467734a09..00000000000
--- a/airflow-core/src/airflow/ui/src/components/EditableMarkdownArea.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import { Box, VStack, Editable, Text } from "@chakra-ui/react";
-import type { ChangeEvent } from "react";
-import { useState, useRef } from "react";
-
-import ReactMarkdown from "./ReactMarkdown";
-
-const EditableMarkdownArea = ({
-  mdContent,
-  onBlur,
-  placeholder,
-  setMdContent,
-}: {
-  readonly mdContent?: string | null;
-  readonly onBlur?: () => void;
-  readonly placeholder?: string | null;
-  readonly setMdContent: (value: string) => void;
-}) => {
-  const [currentValue, setCurrentValue] = useState(mdContent ?? "");
-  const prevMdContentRef = useRef(mdContent);
-
-  // Sync local state with prop changes
-  if (mdContent !== prevMdContentRef.current) {
-    setCurrentValue(mdContent ?? "");
-    prevMdContentRef.current = mdContent;
-  }
-
-  return (
-    <Box height="100%" p={4} width="100%">
-      <Editable.Root
-        height="100%"
-        onBlur={onBlur}
-        onChange={(event: ChangeEvent<HTMLInputElement>) => {
-          const { value } = event.target;
-
-          setCurrentValue(value);
-          setMdContent(value);
-        }}
-        value={currentValue}
-      >
-        <Editable.Preview
-          _hover={{ backgroundColor: "transparent" }}
-          alignItems="flex-start"
-          as={VStack}
-          gap="0"
-          height="100%"
-          overflowY="auto"
-          width="100%"
-        >
-          {Boolean(currentValue) ? (
-            <ReactMarkdown>{currentValue}</ReactMarkdown>
-          ) : (
-            <Text color="fg.subtle">{placeholder}</Text>
-          )}
-        </Editable.Preview>
-        <Editable.Textarea
-          data-testid="markdown-input"
-          height="100%"
-          overflowY="auto"
-          placeholder={placeholder ?? ""}
-          resize="none"
-        />
-      </Editable.Root>
-    </Box>
-  );
-};
-
-export default EditableMarkdownArea;
diff --git 
a/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx 
b/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
index 7d2f5e61c8e..e75f4daa275 100644
--- a/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
+++ b/airflow-core/src/airflow/ui/src/components/EditableMarkdownButton.tsx
@@ -16,15 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Button, Flex, Heading, VStack } from "@chakra-ui/react";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
-import { PiNoteBold, PiNoteBlankBold } from "react-icons/pi";
 
-import { IconButton, Dialog } from "src/components/ui";
-import { ResizableWrapper, MARKDOWN_DIALOG_STORAGE_KEY } from 
"src/components/ui/ResizableWrapper";
+import { IconButton } from "src/components/ui";
 
-import EditableMarkdownArea from "./EditableMarkdownArea";
+import MarkdownModal from "./MarkdownModal";
+import NoteIcon from "./NoteIcon";
 
 const EditableMarkdownButton = ({
   header,
@@ -47,7 +45,6 @@ const EditableMarkdownButton = ({
   const [isOpen, setIsOpen] = useState(false);
 
   const hasContent = Boolean(mdContent?.trim());
-  const noteIcon = hasContent ? <PiNoteBold /> : <PiNoteBlankBold />;
   const label = hasContent ? translate("note.label") : translate("note.add");
 
   const handleOpen = () => {
@@ -60,47 +57,18 @@ const EditableMarkdownButton = ({
   return (
     <>
       <IconButton label={label} onClick={handleOpen}>
-        {noteIcon}
+        <NoteIcon hasNote={hasContent} />
       </IconButton>
-      <Dialog.Root
-        data-testid="markdown-modal"
-        lazyMount
-        onOpenChange={() => setIsOpen(false)}
-        open={isOpen}
-        size="md"
-        unmountOnExit={true}
-      >
-        <Dialog.Content backdrop maxHeight="90vh" maxWidth="90vw" padding={0} 
width="auto">
-          <ResizableWrapper storageKey={MARKDOWN_DIALOG_STORAGE_KEY}>
-            <Dialog.Header bg="brand.muted" flexShrink={0}>
-              <Heading size="xl">{header}</Heading>
-              <Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
-            </Dialog.Header>
-            <Dialog.Body alignItems="flex-start" as={VStack} flex="1" gap="0" 
overflow="hidden" p={0}>
-              <Box flex="1" overflow="hidden" width="100%">
-                <EditableMarkdownArea
-                  mdContent={mdContent}
-                  placeholder={placeholder}
-                  setMdContent={setMdContent}
-                />
-              </Box>
-              <Box bg="bg.panel" flexShrink={0} width="100%">
-                <Flex justifyContent="end" p={4}>
-                  <Button
-                    loading={isPending}
-                    onClick={() => {
-                      onConfirm();
-                      setIsOpen(false);
-                    }}
-                  >
-                    {noteIcon} {translate("modal.confirm")}
-                  </Button>
-                </Flex>
-              </Box>
-            </Dialog.Body>
-          </ResizableWrapper>
-        </Dialog.Content>
-      </Dialog.Root>
+      <MarkdownModal
+        header={header}
+        isOpen={isOpen}
+        isPending={isPending}
+        mdContent={mdContent}
+        onClose={() => setIsOpen(false)}
+        onConfirm={onConfirm}
+        placeholder={placeholder}
+        setMdContent={setMdContent}
+      />
     </>
   );
 };
diff --git a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx 
b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
index 7908076bc4d..bb4ad874d16 100644
--- a/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
+++ b/airflow-core/src/airflow/ui/src/components/HeaderCard.tsx
@@ -39,13 +39,7 @@ export const HeaderCard = ({ actions, icon, state, stats, 
subTitle, title }: Pro
   const { t: translate } = useTranslation();
 
   return (
-    <Box
-      borderColor="border.emphasized"
-      borderRadius={8}
-      borderWidth={1}
-      data-testid="header-card"
-      overflow="hidden"
-    >
+    <Box data-testid="header-card" overflow="hidden">
       <DagDeactivatedBanner />
       <Box p={2}>
         <Flex alignItems="center" flexWrap="wrap" 
justifyContent="space-between" mb={2}>
diff --git a/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx 
b/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx
new file mode 100644
index 00000000000..383b0c7d9c1
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/MarkdownModal.test.tsx
@@ -0,0 +1,131 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import MarkdownModal, { MAX_NOTE_LENGTH } from "./MarkdownModal";
+
+const defaultProps = {
+  header: "Note",
+  isOpen: true,
+  isPending: false,
+  mdContent: "An existing note",
+  onClose: vi.fn(),
+  onConfirm: vi.fn(),
+  placeholder: "Add a note...",
+  setMdContent: vi.fn(),
+};
+
+const renderModal = (props: Partial<typeof defaultProps> = {}) =>
+  render(<MarkdownModal {...defaultProps} {...props} />, { wrapper: Wrapper });
+
+describe("MarkdownModal", () => {
+  it("shows rendered markdown (read-only) with an edit toggle for an existing 
note", () => {
+    renderModal();
+    expect(screen.getByText("An existing note", { selector: "p" 
})).toBeInTheDocument();
+    expect(screen.queryByTestId("markdown-input")).toBeNull();
+    expect(screen.getByTestId("edit-markdown")).toBeInTheDocument();
+  });
+
+  it("reveals the textarea when the edit toggle is clicked", () => {
+    renderModal();
+    fireEvent.click(screen.getByTestId("edit-markdown"));
+    expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+  });
+
+  it("opens straight into editing when there is no content", () => {
+    renderModal({ mdContent: "" });
+    expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+  });
+
+  it("calls setMdContent as the textarea value changes", () => {
+    const setMdContent = vi.fn();
+
+    renderModal({ mdContent: "", setMdContent });
+    fireEvent.change(screen.getByTestId("markdown-input"), { target: { value: 
"new content" } });
+    expect(setMdContent).toHaveBeenCalledWith("new content");
+  });
+
+  describe("character limit", () => {
+    it("caps the textarea at the maximum length", () => {
+      renderModal({ mdContent: "" });
+      
expect(screen.getByTestId("markdown-input")).toHaveAttribute("maxlength", 
String(MAX_NOTE_LENGTH));
+    });
+
+    it("shows the live character count and keeps saving enabled under the 
limit", () => {
+      renderModal({ mdContent: "hello" });
+      fireEvent.click(screen.getByTestId("edit-markdown"));
+      expect(screen.getByText(`5/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: /confirm/iu })).toBeEnabled();
+    });
+
+    it("keeps saving enabled at exactly the limit", () => {
+      renderModal({ mdContent: "x".repeat(MAX_NOTE_LENGTH) });
+      fireEvent.click(screen.getByTestId("edit-markdown"));
+      
expect(screen.getByText(`${MAX_NOTE_LENGTH}/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: /confirm/iu })).toBeEnabled();
+    });
+
+    it("shows the count and disables saving when over the limit", () => {
+      renderModal({ mdContent: "x".repeat(MAX_NOTE_LENGTH + 1) });
+      fireEvent.click(screen.getByTestId("edit-markdown"));
+      expect(screen.getByText(`${MAX_NOTE_LENGTH + 
1}/${MAX_NOTE_LENGTH}`)).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: /confirm/iu })).toBeDisabled();
+    });
+  });
+
+  it("toggles between the textarea and a rendered preview while editing", () 
=> {
+    // The modal is controlled, so drive mdContent from a stateful wrapper.
+    const ControlledModal = () => {
+      const [value, setValue] = useState("");
+
+      return <MarkdownModal {...defaultProps} mdContent={value} 
setMdContent={setValue} />;
+    };
+
+    render(<ControlledModal />, { wrapper: Wrapper });
+
+    // Starts in the editor
+    fireEvent.change(screen.getByTestId("markdown-input"), { target: { value: 
"**bold**" } });
+    fireEvent.click(screen.getByTestId("preview-toggle"));
+
+    // Preview hides the textarea and renders the markdown
+    expect(screen.queryByTestId("markdown-input")).toBeNull();
+    expect(screen.getByText("bold", { selector: "strong" 
})).toBeInTheDocument();
+
+    // Toggling back returns to the editor
+    fireEvent.click(screen.getByTestId("preview-toggle"));
+    expect(screen.getByTestId("markdown-input")).toBeInTheDocument();
+  });
+
+  it("confirms and closes when save is clicked", () => {
+    const onClose = vi.fn();
+    const onConfirm = vi.fn();
+
+    renderModal({ mdContent: "valid note", onClose, onConfirm });
+    fireEvent.click(screen.getByTestId("edit-markdown"));
+    fireEvent.click(screen.getByRole("button", { name: /confirm/iu }));
+
+    expect(onConfirm).toHaveBeenCalledTimes(1);
+    expect(onClose).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx 
b/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx
new file mode 100644
index 00000000000..80288a37d3f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/MarkdownModal.tsx
@@ -0,0 +1,181 @@
+/*!
+ * 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, Flex, Heading, HStack, Text, Textarea, VStack } from 
"@chakra-ui/react";
+import type { ChangeEvent } from "react";
+import { useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiEdit, FiEye } from "react-icons/fi";
+
+import NoteIcon from "src/components/NoteIcon";
+import ReactMarkdown from "src/components/ReactMarkdown";
+import { Dialog } from "src/components/ui";
+import { ResizableWrapper, MARKDOWN_DIALOG_STORAGE_KEY } from 
"src/components/ui/ResizableWrapper";
+
+export const MAX_NOTE_LENGTH = 1000;
+
+const MarkdownModal = ({
+  header,
+  isOpen,
+  isPending,
+  mdContent,
+  onClose,
+  onConfirm,
+  placeholder,
+  setMdContent,
+}: {
+  readonly header: string;
+  readonly isOpen: boolean;
+  readonly isPending: boolean;
+  readonly mdContent?: string | null;
+  readonly onClose: () => void;
+  readonly onConfirm: () => void;
+  readonly placeholder: string;
+  readonly setMdContent: (value: string) => void;
+}) => {
+  const { t: translate } = useTranslation("common");
+
+  const hasContent = Boolean(mdContent?.trim());
+  // Open straight into editing when there's nothing to read; otherwise show 
the
+  // rendered note (links clickable) and let the edit button reveal the 
textarea.
+  const [isEditing, setIsEditing] = useState(!hasContent);
+  // While editing, toggle between the textarea and a rendered preview.
+  const [showPreview, setShowPreview] = useState(false);
+
+  // Track the saved content while closed; it freezes on open, so dismissing 
(X / Esc / backdrop)
+  // restores it and discards the unsaved draft instead of leaking it into the 
shared note state.
+  // Confirm closes through onClose directly and keeps the edited value.
+  const openContentRef = useRef(mdContent ?? "");
+
+  if (!isOpen) {
+    openContentRef.current = mdContent ?? "";
+  }
+
+  const onDismiss = () => {
+    setMdContent(openContentRef.current);
+    onClose();
+  };
+
+  const length = mdContent?.length ?? 0;
+  // Existing notes may already exceed the limit (created before validation or
+  // via the API); block saving until trimmed rather than silently truncating.
+  const isOverLimit = length > MAX_NOTE_LENGTH;
+
+  // Textarea only while editing and not previewing; otherwise show the 
rendered markdown.
+  const showEditor = isEditing && !showPreview;
+  const renderedContent = hasContent ? (
+    <ReactMarkdown>{mdContent ?? ""}</ReactMarkdown>
+  ) : (
+    <Text color="fg.subtle">{placeholder}</Text>
+  );
+
+  return (
+    <Dialog.Root
+      data-testid="markdown-modal"
+      lazyMount
+      onOpenChange={({ open: nextOpen }) => {
+        if (!nextOpen) {
+          onDismiss();
+        }
+      }}
+      open={isOpen}
+      size="xl"
+      unmountOnExit={true}
+    >
+      <Dialog.Content backdrop maxHeight="90vh" maxWidth="90vw" padding={0} 
width="auto">
+        <ResizableWrapper
+          defaultSize={{ height: 700, width: 1000 }}
+          maxConstraints={[1600, 1000]}
+          minSize={{ height: 600, width: 800 }}
+          storageKey={MARKDOWN_DIALOG_STORAGE_KEY}
+        >
+          <Dialog.Header bg="brand.muted" flexShrink={0}>
+            <Heading size="xl">{header}</Heading>
+            <Dialog.CloseTrigger closeButtonProps={{ size: "xl" }} />
+          </Dialog.Header>
+          <Dialog.Body alignItems="stretch" as={VStack} flex="1" gap="0" 
overflow="hidden" p={0}>
+            <Box display="flex" flex="1" flexDirection="column" minH={0} 
overflow="hidden" p={4} width="100%">
+              {showEditor ? (
+                <Textarea
+                  data-testid="markdown-input"
+                  flex="1"
+                  maxLength={MAX_NOTE_LENGTH}
+                  minH={0}
+                  onChange={(event: ChangeEvent<HTMLTextAreaElement>) => 
setMdContent(event.target.value)}
+                  placeholder={placeholder}
+                  resize="none"
+                  value={mdContent ?? ""}
+                />
+              ) : (
+                <Box flex="1" minH={0} overflow="auto">
+                  {renderedContent}
+                </Box>
+              )}
+            </Box>
+            <Box bg="bg.panel" flexShrink={0} width="100%">
+              <Flex alignItems="center" gap={4} justifyContent="space-between" 
p={4}>
+                {isEditing ? (
+                  <Text color={isOverLimit ? "fg.error" : "fg.muted"} 
fontSize="sm">
+                    {length}/{MAX_NOTE_LENGTH}
+                  </Text>
+                ) : (
+                  <Box />
+                )}
+                {isEditing ? (
+                  <HStack gap={2}>
+                    <Button
+                      data-testid="preview-toggle"
+                      onClick={() => setShowPreview((prev) => !prev)}
+                      variant="outline"
+                    >
+                      {showPreview ? <FiEdit /> : <FiEye />}
+                      {showPreview ? translate("note.write") : 
translate("note.preview")}
+                    </Button>
+                    <Button
+                      disabled={isOverLimit}
+                      loading={isPending}
+                      onClick={() => {
+                        onConfirm();
+                        onClose();
+                      }}
+                    >
+                      <NoteIcon hasNote={hasContent} /> 
{translate("modal.confirm")}
+                    </Button>
+                  </HStack>
+                ) : (
+                  <Button
+                    data-testid="edit-markdown"
+                    onClick={() => {
+                      setShowPreview(false);
+                      setIsEditing(true);
+                    }}
+                    variant="outline"
+                  >
+                    <FiEdit /> {translate("edit")}
+                  </Button>
+                )}
+              </Flex>
+            </Box>
+          </Dialog.Body>
+        </ResizableWrapper>
+      </Dialog.Content>
+    </Dialog.Root>
+  );
+};
+
+export default MarkdownModal;
diff --git a/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx 
b/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx
new file mode 100644
index 00000000000..ac67a31d434
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NoteIcon.tsx
@@ -0,0 +1,25 @@
+/*!
+ * 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 { PiNoteBold, PiNoteBlankBold } from "react-icons/pi";
+
+/** Filled note icon when a note exists, blank otherwise. */
+const NoteIcon = ({ hasNote }: { readonly hasNote: boolean }) =>
+  hasNote ? <PiNoteBold /> : <PiNoteBlankBold />;
+
+export default NoteIcon;
diff --git a/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx 
b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
new file mode 100644
index 00000000000..498cb54c42d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NotePreview.test.tsx
@@ -0,0 +1,98 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import NotePreview from "./NotePreview";
+
+const defaultProps = {
+  header: "Note",
+  isPending: false,
+  note: "",
+  onOpen: vi.fn(),
+  onSave: vi.fn(),
+  setNote: vi.fn(),
+};
+
+const renderNote = (props: Partial<typeof defaultProps> = {}) =>
+  render(<NotePreview {...defaultProps} {...props} />, { wrapper: Wrapper });
+
+describe("NotePreview", () => {
+  describe("preview", () => {
+    it("shows the placeholder when there is no note", () => {
+      renderNote();
+      expect(screen.getByText("note.placeholder")).toBeInTheDocument();
+    });
+
+    it("renders the note content as a read-only preview", () => {
+      const note = "A short single line note";
+
+      renderNote({ note });
+      expect(screen.getByText(note, { selector: "p" })).toBeInTheDocument();
+    });
+
+    it("shows only the first non-empty line with an ellipsis for a multiline 
note", () => {
+      renderNote({ note: "First line\nSecond line" });
+      expect(screen.getByText(/First line …/u)).toBeInTheDocument();
+      expect(screen.queryByText("Second line")).toBeNull();
+    });
+
+    it("does not append an ellipsis for a single-line note", () => {
+      renderNote({ note: "Only line" });
+      expect(screen.getByText("Only line", { selector: "p" 
})).toBeInTheDocument();
+      expect(screen.queryByText(/…/u)).toBeNull();
+    });
+
+    it("does not render an editable textarea before opening the modal", () => {
+      renderNote({ note: "some note" });
+      expect(screen.queryByTestId("markdown-input")).toBeNull();
+    });
+  });
+
+  describe("opening the modal", () => {
+    it("resets state and opens the modal when the edit button is clicked", 
async () => {
+      const onOpen = vi.fn();
+
+      renderNote({ note: "some note", onOpen });
+      fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+      expect(onOpen).toHaveBeenCalledTimes(1);
+      // Modal header confirms the dialog is open
+      expect(await screen.findByRole("heading", { name: "Note" 
})).toBeInTheDocument();
+    });
+
+    it("opens an existing note in read mode (markdown shown, textarea 
hidden)", async () => {
+      renderNote({ note: "some note" });
+      fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+      expect(await screen.findByTestId("edit-markdown")).toBeInTheDocument();
+      expect(screen.queryByTestId("markdown-input")).toBeNull();
+    });
+
+    it("opens an empty note straight into editing", async () => {
+      renderNote({ note: "" });
+      fireEvent.click(screen.getByRole("button", { name: "note.edit" }));
+
+      expect(await screen.findByTestId("markdown-input")).toBeInTheDocument();
+    });
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/components/NotePreview.tsx 
b/airflow-core/src/airflow/ui/src/components/NotePreview.tsx
new file mode 100644
index 00000000000..1f2a2b094c6
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/components/NotePreview.tsx
@@ -0,0 +1,104 @@
+/*!
+ * 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, HStack, Text } from "@chakra-ui/react";
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FiEdit } from "react-icons/fi";
+
+import MarkdownModal from "src/components/MarkdownModal";
+import NoteIcon from "src/components/NoteIcon";
+import ReactMarkdown from "src/components/ReactMarkdown";
+import { IconButton } from "src/components/ui";
+
+type Props = {
+  readonly header: string;
+  readonly isPending: boolean;
+  readonly note: string | null;
+  readonly onOpen: () => void;
+  readonly onSave: () => void;
+  readonly setNote: (value: string) => void;
+};
+
+const NotePreview = ({ header, isPending, note, onOpen, onSave, setNote }: 
Props) => {
+  const { t: translate } = useTranslation();
+  const [isOpen, setIsOpen] = useState(false);
+
+  const hasNote = note !== null && note.trim().length > 0;
+  // Only the first non-empty line drives the single-line preview; append an
+  // ellipsis when there's more content below it that the preview hides.
+  const meaningfulLines = hasNote ? note.split("\n").filter((line) => 
line.trim().length > 0) : [];
+  const firstLine = meaningfulLines[0] ?? "";
+  const previewContent = meaningfulLines.length > 1 ? `${firstLine} …` : 
firstLine;
+
+  const handleOpen = () => {
+    onOpen();
+    setIsOpen(true);
+  };
+
+  return (
+    <HStack alignItems="center" gap={2} px={3}>
+      {/* Icon indicates whether a note exists */}
+      <Box color={hasNote ? undefined : "fg.subtle"} flexShrink={0} 
fontSize="md">
+        <NoteIcon hasNote={hasNote} />
+      </Box>
+
+      {/* Read-only single-line preview — links stay clickable, edits go 
through the modal */}
+      <Box
+        flex="1"
+        fontSize="sm"
+        overflow="hidden"
+        style={{
+          display: "-webkit-box",
+          WebkitBoxOrient: "vertical",
+          WebkitLineClamp: 1,
+          whiteSpace: "normal",
+        }}
+      >
+        {hasNote ? (
+          <ReactMarkdown>{previewContent}</ReactMarkdown>
+        ) : (
+          <Text color="fg.subtle">{translate("note.placeholder")}</Text>
+        )}
+      </Box>
+
+      <IconButton
+        flexShrink={0}
+        label={translate("note.edit")}
+        onClick={handleOpen}
+        size="xs"
+        variant="ghost"
+      >
+        <FiEdit />
+      </IconButton>
+
+      <MarkdownModal
+        header={header}
+        isOpen={isOpen}
+        isPending={isPending}
+        mdContent={note}
+        onClose={() => setIsOpen(false)}
+        onConfirm={onSave}
+        placeholder={translate("note.placeholder")}
+        setMdContent={setNote}
+      />
+    </HStack>
+  );
+};
+
+export default NotePreview;
diff --git a/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx 
b/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
index 57a585a2b91..9a89c105fef 100644
--- a/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ReactMarkdown.tsx
@@ -64,7 +64,7 @@ const LinkComponent = ({
   readonly href: string;
   readonly title?: string;
 }) => (
-  <Link color="fg.info" fontWeight="bold" href={href} title={title}>
+  <Link color="fg.info" fontWeight="bold" href={href} rel="noopener 
noreferrer" target="_blank" title={title}>
     {children}
   </Link>
 );
@@ -109,16 +109,13 @@ const UlComponent = ({ children }: PropsWithChildren) => (
 // Factory function for the code component that needs style
 const createCodeComponent =
   (style: typeof oneDark | typeof oneLight) =>
-  ({
-    children,
-    className,
-    inline,
-  }: {
-    readonly children: ReactNode;
-    readonly className?: string;
-    readonly inline?: boolean;
-  }) => {
-    if (inline) {
+  ({ children, className }: { readonly children: ReactNode; readonly 
className?: string }) => {
+    // react-markdown v9 no longer passes an `inline` prop. Only fenced code
+    // blocks carry a `language-*` class; everything else is inline code and
+    // must render as a <span> (a block <div> inside a <p> breaks hydration).
+    const match = /language-(?<lang>\w+)/u.exec(className ?? "");
+
+    if (!match) {
       return (
         <Code display="inline" p={2}>
           {children}
@@ -126,9 +123,7 @@ const createCodeComponent =
       );
     }
 
-    // Extract language from className (format: "language-python")
-    const match = /language-(?<lang>\w+)/u.exec(className ?? "");
-    const language = match?.groups?.lang;
+    const language = match.groups?.lang;
 
     // Safely extract string content from children
     let childString = "";
@@ -146,7 +141,7 @@ const createCodeComponent =
     );
   };
 
-const ReactMarkdown = (props: Options) => {
+const ReactMarkdown = ({ components: componentOverrides, ...restProps }: 
Options) => {
   const { colorMode } = useColorMode();
   const style = colorMode === "dark" ? oneDark : oneLight;
 
@@ -180,7 +175,14 @@ const ReactMarkdown = (props: Options) => {
     ul: UlComponent,
   };
 
-  return <ReactMD components={components as Components} {...props} 
remarkPlugins={[remarkGfm]} skipHtml />;
+  return (
+    <ReactMD
+      components={{ ...components, ...componentOverrides } as Components}
+      {...restProps}
+      remarkPlugins={[remarkGfm]}
+      skipHtml
+    />
+  );
 };
 
 export default ReactMarkdown;
diff --git a/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx 
b/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
index d448a181bbc..b10d4b78910 100644
--- a/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
+++ b/airflow-core/src/airflow/ui/src/components/ui/ResizableWrapper.tsx
@@ -24,6 +24,7 @@ import { useLocalStorage } from "usehooks-ts";
 type ResizableWrapperProps = {
   readonly defaultSize?: { height: number; width: number };
   readonly maxConstraints?: [width: number, height: number];
+  readonly minSize?: { height: number; width: number };
   readonly storageKey: string;
 } & PropsWithChildren;
 
@@ -36,6 +37,7 @@ export const ResizableWrapper = ({
   children,
   defaultSize = DEFAULT_SIZE,
   maxConstraints = MAX_SIZE,
+  minSize = DEFAULT_SIZE,
   storageKey,
 }: ResizableWrapperProps) => {
   const ref = useRef<HTMLDivElement>(null);
@@ -79,13 +81,13 @@ export const ResizableWrapper = ({
         overflow: "hidden",
         resize: "both",
       }}
-      height={`${storedSize.height}px`}
+      height={`${Math.max(storedSize.height, minSize.height)}px`}
       maxHeight={`${maxConstraints[1]}px`}
       maxWidth={`${maxConstraints[0]}px`}
-      minHeight={`${DEFAULT_SIZE.height}px`}
-      minWidth={`${DEFAULT_SIZE.width}px`}
+      minHeight={`${minSize.height}px`}
+      minWidth={`${minSize.width}px`}
       ref={ref}
-      width={`${storedSize.width}px`}
+      width={`${Math.max(storedSize.width, minSize.width)}px`}
     >
       {children}
     </Box>
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 4443ec53056..971354ebe55 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/DagRuns.tsx
@@ -54,6 +54,7 @@ import BulkDeleteDagRunsButton from 
"./BulkDeleteDagRunsButton";
 import BulkMarkDagRunsAsButton from "./BulkMarkDagRunsAsButton";
 import { DagRunsFilters } from "./DagRunsFilters";
 import DeleteRunButton from "./DeleteRunButton";
+import RunNoteButton from "./RunNoteButton";
 
 // Matches the identifier the bulk Dag Run endpoint echoes back in its 
``success`` /
 // ``errors`` lists, so the bulk response can deselect rows directly.
@@ -203,6 +204,7 @@ const runColumns = ({ dagId, translate }: ColumnProps): 
Array<ColumnDef<DAGRunRe
     accessorKey: "actions",
     cell: ({ row }) => (
       <Flex justifyContent="end">
+        <RunNoteButton dagRun={row.original} />
         <ClearRunButton dagRun={row.original} />
         <MarkRunAsButton dagRun={row.original} />
         <DeleteRunButton dagRun={row.original} />
diff --git a/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.tsx
new file mode 100644
index 00000000000..f8d5c732866
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagRuns/RunNoteButton.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 { useTranslation } from "react-i18next";
+
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+import EditableMarkdownButton from "src/components/EditableMarkdownButton";
+import { useDagRunNote } from "src/queries/useDagRunNote";
+
+const RunNoteButton = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
+  const { t: translate } = useTranslation();
+  const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
+
+  return (
+    <EditableMarkdownButton
+      header={translate("note.dagRun")}
+      isPending={isPending}
+      mdContent={note}
+      onConfirm={onSave}
+      onOpen={onOpen}
+      placeholder={translate("note.placeholder")}
+      setMdContent={setNote}
+    />
+  );
+};
+
+export default RunNoteButton;
diff --git a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx 
b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
index 18c0e927426..d9a56dd796d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Run/Header.tsx
@@ -17,7 +17,6 @@
  * under the License.
  */
 import { HStack, Text, Box } from "@chakra-ui/react";
-import { useState } from "react";
 import { useTranslation } from "react-i18next";
 import { FiBarChart } from "react-icons/fi";
 
@@ -25,23 +24,23 @@ import { useDeadlinesServiceGetDagDeadlineAlerts } from 
"openapi/queries";
 import type { DAGRunResponse } from "openapi/requests/types.gen";
 import { ClearRunButton } from "src/components/Clear";
 import { DagVersion } from "src/components/DagVersion";
-import EditableMarkdownButton from "src/components/EditableMarkdownButton";
 import { HeaderCard } from "src/components/HeaderCard";
 import { LimitedItemsList } from "src/components/LimitedItemsList";
 import { MarkRunAsButton } from "src/components/MarkAs";
+import NotePreview from "src/components/NotePreview";
 import { RunTypeIcon } from "src/components/RunTypeIcon";
 import Time from "src/components/Time";
 import { RouterLink } from "src/components/ui";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import DeleteRunButton from "src/pages/DagRuns/DeleteRunButton";
-import { usePatchDagRun } from "src/queries/usePatchDagRun";
+import { useDagRunNote } from "src/queries/useDagRunNote";
 import { getDuration } from "src/utils";
 
 import { DeadlineStatus } from "./DeadlineStatus";
 
 export const Header = ({ dagRun }: { readonly dagRun: DAGRunResponse }) => {
   const { t: translate } = useTranslation();
-  const [note, setNote] = useState<string | null>(dagRun.note);
+  const { isPending, note, onOpen, onSave, setNote } = useDagRunNote(dagRun);
 
   const dagId = dagRun.dag_id;
   const dagRunId = dagRun.dag_run_id;
@@ -49,39 +48,11 @@ export const Header = ({ dagRun }: { readonly dagRun: 
DAGRunResponse }) => {
   const { data: alertData } = useDeadlinesServiceGetDagDeadlineAlerts({ dagId 
});
   const hasDeadlineAlerts = (alertData?.total_entries ?? 0) > 0;
 
-  const { isPending, mutate } = usePatchDagRun({
-    dagId,
-    dagRunId,
-  });
-
-  const onConfirm = () => {
-    if (note !== dagRun.note) {
-      mutate({
-        dagId,
-        dagRunId,
-        requestBody: { note },
-      });
-    }
-  };
-
-  const onOpen = () => {
-    setNote(dagRun.note ?? "");
-  };
-
   return (
     <Box>
       <HeaderCard
         actions={
           <>
-            <EditableMarkdownButton
-              header={translate("note.dagRun")}
-              isPending={isPending}
-              mdContent={dagRun.note}
-              onConfirm={onConfirm}
-              onOpen={onOpen}
-              placeholder={translate("note.placeholder")}
-              setMdContent={setNote}
-            />
             <ClearRunButton dagRun={dagRun} isHotkeyEnabled />
             <MarkRunAsButton dagRun={dagRun} isHotkeyEnabled />
             <DeleteRunButton dagRun={dagRun} />
@@ -154,6 +125,14 @@ export const Header = ({ dagRun }: { readonly dagRun: 
DAGRunResponse }) => {
         ]}
         title={dagRun.dag_run_id}
       />
+      <NotePreview
+        header={translate("note.dagRun")}
+        isPending={isPending}
+        note={note}
+        onOpen={onOpen}
+        onSave={onSave}
+        setNote={setNote}
+      />
     </Box>
   );
 };
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 d053c68534c..48364a9e9a6 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Header.tsx
@@ -25,22 +25,16 @@ import type { TaskInstanceResponse } from 
"openapi/requests/types.gen";
 import { ClearTaskInstanceButton } from "src/components/Clear";
 import ClearTaskInstanceDialog from 
"src/components/Clear/TaskInstance/ClearTaskInstanceDialog";
 import { DagVersion } from "src/components/DagVersion";
-import EditableMarkdownButton from "src/components/EditableMarkdownButton";
 import { HeaderCard } from "src/components/HeaderCard";
 import { MarkTaskInstanceAsButton } from "src/components/MarkAs";
+import NotePreview from "src/components/NotePreview";
 import Time from "src/components/Time";
-import { usePatchTaskInstance } from "src/queries/usePatchTaskInstance";
+import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
 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 { isPending, note, onOpen, onSave, setNote } = 
useTaskInstanceNote(taskInstance);
 
   const stats = [
     { label: translate("task.operator"), value: taskInstance.operator_name },
@@ -68,29 +62,6 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
     },
   ];
 
-  const { isPending, mutate } = usePatchTaskInstance({
-    dagId,
-    dagRunId,
-    mapIndex,
-    taskId,
-  });
-
-  const onConfirm = () => {
-    if (note !== taskInstance.note) {
-      mutate({
-        dagId,
-        dagRunId,
-        mapIndex,
-        requestBody: { note },
-        taskId,
-      });
-    }
-  };
-
-  const onOpen = () => {
-    setNote(taskInstance.note ?? "");
-  };
-
   // Stable dialog state at header/page level
   const [clearOpen, setClearOpen] = useState(false);
 
@@ -99,15 +70,6 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
       <HeaderCard
         actions={
           <>
-            <EditableMarkdownButton
-              header={translate("note.taskInstance")}
-              isPending={isPending}
-              mdContent={taskInstance.note}
-              onConfirm={onConfirm}
-              onOpen={onOpen}
-              placeholder={translate("note.placeholder")}
-              setMdContent={setNote}
-            />
             <ClearTaskInstanceButton
               isHotkeyEnabled
               onOpen={() => setClearOpen(true)}
@@ -121,6 +83,14 @@ export const Header = ({ taskInstance }: { readonly 
taskInstance: TaskInstanceRe
         stats={stats}
         title={`${taskInstance.task_display_name}${taskInstance.map_index > -1 
? ` [${taskInstance.rendered_map_index ?? taskInstance.map_index}]` : ""}`}
       />
+      <NotePreview
+        header={translate("note.taskInstance")}
+        isPending={isPending}
+        note={note}
+        onOpen={onOpen}
+        onSave={onSave}
+        setNote={setNote}
+      />
       <ClearTaskInstanceDialog
         onClose={() => setClearOpen(false)}
         open={clearOpen}
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.tsx
new file mode 100644
index 00000000000..58d1faa79e9
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstanceNoteButton.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 { useTranslation } from "react-i18next";
+
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+import EditableMarkdownButton from "src/components/EditableMarkdownButton";
+import { useTaskInstanceNote } from "src/queries/useTaskInstanceNote";
+
+const TaskInstanceNoteButton = ({ taskInstance }: { readonly taskInstance: 
TaskInstanceResponse }) => {
+  const { t: translate } = useTranslation();
+  const { isPending, note, onOpen, onSave, setNote } = 
useTaskInstanceNote(taskInstance);
+
+  return (
+    <EditableMarkdownButton
+      header={translate("note.taskInstance")}
+      isPending={isPending}
+      mdContent={note}
+      onConfirm={onSave}
+      onOpen={onOpen}
+      placeholder={translate("note.placeholder")}
+      setMdContent={setNote}
+    />
+  );
+};
+
+export default TaskInstanceNoteButton;
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 ff28b5eb7b5..3249b876621 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstances/TaskInstances.tsx
@@ -51,6 +51,7 @@ import BulkClearTaskInstancesButton from 
"./BulkClearTaskInstancesButton";
 import BulkDeleteTaskInstancesButton from "./BulkDeleteTaskInstancesButton";
 import BulkMarkTaskInstancesAsButton from "./BulkMarkTaskInstancesAsButton";
 import DeleteTaskInstanceButton from "./DeleteTaskInstanceButton";
+import TaskInstanceNoteButton from "./TaskInstanceNoteButton";
 import { TaskInstancesFilter } from "./TaskInstancesFilter";
 
 type TaskInstanceRow = { row: { original: TaskInstanceResponse } };
@@ -235,6 +236,7 @@ const taskInstanceColumns = ({
     accessorKey: "actions",
     cell: ({ row }) => (
       <Flex justifyContent="end">
+        <TaskInstanceNoteButton taskInstance={row.original} />
         <ClearTaskInstanceButton taskInstance={row.original} />
         <MarkTaskInstanceAsButton taskInstance={row.original} />
         <DeleteTaskInstanceButton taskInstance={row.original} />
diff --git a/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts 
b/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts
new file mode 100644
index 00000000000..5d821d44fb2
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useDagRunNote.ts
@@ -0,0 +1,41 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { DAGRunResponse } from "openapi/requests/types.gen";
+
+import { useNoteEditor } from "./useNoteEditor";
+import { usePatchDagRun } from "./usePatchDagRun";
+
+/**
+ * Note-editing state and save logic for a Dag run.
+ * Used by both the detail-page NotePreview and the list-page RunNoteButton
+ * so mutation + cache invalidation stays in one place.
+ */
+export const useDagRunNote = (dagRun: DAGRunResponse) => {
+  const { isPending, mutate } = usePatchDagRun({
+    dagId: dagRun.dag_id,
+    dagRunId: dagRun.dag_run_id,
+  });
+
+  return useNoteEditor({
+    isPending,
+    mutateNote: (note, options) =>
+      mutate({ dagId: dagRun.dag_id, dagRunId: dagRun.dag_run_id, requestBody: 
{ note } }, options),
+    savedNote: dagRun.note,
+  });
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useNoteEditor.ts 
b/airflow-core/src/airflow/ui/src/queries/useNoteEditor.ts
new file mode 100644
index 00000000000..5a702890df0
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useNoteEditor.ts
@@ -0,0 +1,48 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from "react";
+
+/**
+ * Shared note-editing state and save logic, parameterised over the underlying
+ * mutation. Keeps the local-state / diff-on-save / reset-on-open behaviour in
+ * one place for the Dag run and task instance note hooks.
+ */
+export const useNoteEditor = ({
+  isPending,
+  mutateNote,
+  savedNote,
+}: {
+  readonly isPending: boolean;
+  readonly mutateNote: (note: string | null, options: { onError: () => void }) 
=> void;
+  readonly savedNote: string | null;
+}) => {
+  const [note, setNote] = useState<string | null>(savedNote);
+
+  const onSave = () => {
+    if (note !== savedNote) {
+      mutateNote(note, { onError: () => setNote(savedNote ?? null) });
+    }
+  };
+
+  // Reset local state to the server value each time an edit surface is opened,
+  // so stale edits from a previous session don't linger.
+  const onOpen = () => setNote(savedNote ?? "");
+
+  return { isPending, note, onOpen, onSave, setNote };
+};
diff --git a/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts 
b/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts
new file mode 100644
index 00000000000..06f518914cb
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/queries/useTaskInstanceNote.ts
@@ -0,0 +1,52 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import type { TaskInstanceResponse } from "openapi/requests/types.gen";
+
+import { useNoteEditor } from "./useNoteEditor";
+import { usePatchTaskInstance } from "./usePatchTaskInstance";
+
+/**
+ * Note-editing state and save logic for a task instance.
+ * Used by both the detail-page NotePreview and the list-page 
TaskInstanceNoteButton
+ * so mutation + cache invalidation stays in one place.
+ */
+export const useTaskInstanceNote = (taskInstance: TaskInstanceResponse) => {
+  const { isPending, mutate } = usePatchTaskInstance({
+    dagId: taskInstance.dag_id,
+    dagRunId: taskInstance.dag_run_id,
+    mapIndex: taskInstance.map_index,
+    taskId: taskInstance.task_id,
+  });
+
+  return useNoteEditor({
+    isPending,
+    mutateNote: (note, options) =>
+      mutate(
+        {
+          dagId: taskInstance.dag_id,
+          dagRunId: taskInstance.dag_run_id,
+          mapIndex: taskInstance.map_index,
+          requestBody: { note },
+          taskId: taskInstance.task_id,
+        },
+        options,
+      ),
+    savedNote: taskInstance.note,
+  });
+};

Reply via email to