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

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


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 6fc4bcc311e [v3-3-test] Stop auto-scrolling task logs while the user 
is selecting text (#70594) (#71127)
6fc4bcc311e is described below

commit 6fc4bcc311ea84e4f0ba2d90134d77298cd792b2
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Aug 5 12:45:58 2026 +0530

    [v3-3-test] Stop auto-scrolling task logs while the user is selecting text 
(#70594) (#71127)
    
    * Follow live task logs only when at the very end
    
    When a task is running, the log view follows new lines by auto-scrolling to
    the bottom. It kept following even when the user had scrolled up a little to
    read something, because "at the bottom" allowed a 100px gap. Lower the gap 
so
    scrolling up stops the follow, and returning to the end resumes it.
    
    * Pause the task log auto-scroll while the user is selecting text
    
    While a task streams logs, the view follows new lines by scrolling to the
    bottom. If the user is selecting text at the bottom, the scroll moves the
    text out from under the cursor and clears the selection. Skip the follow
    while a non-empty selection sits inside the log; clearing it resumes.
    (cherry picked from commit 9d4245ae51e6bc1d5ebdd0344a2d02c7339c49ce)
    
    Co-authored-by: Andrew Chang <[email protected]>
    Co-authored-by: Rahul Vats <[email protected]>
---
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 13 ++--
 .../ui/src/pages/TaskInstance/Logs/utils.test.ts   | 78 +++++++++++++++++++++-
 .../ui/src/pages/TaskInstance/Logs/utils.ts        | 13 ++++
 3 files changed, 99 insertions(+), 5 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
index 8f57b096e3c..e2f4986c7e5 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogContent.tsx
@@ -29,7 +29,7 @@ import type { ParsedLogEntry } from "src/queries/useLogs";
 import { HighlightedText } from "./HighlightedText";
 import { ScrollToButton } from "./ScrollToButton";
 import { useLogGroups } from "./useLogGroups";
-import { getHighlightColor, scrollToBottom, scrollToTop } from "./utils";
+import { getHighlightColor, isSelectionWithin, scrollToBottom, scrollToTop } 
from "./utils";
 
 export type TaskLogContentProps = {
   readonly currentMatchLineIndex?: number;
@@ -43,8 +43,10 @@ export type TaskLogContentProps = {
   readonly wrap: boolean;
 };
 
-// How close to the bottom (in px) before we consider the user "at the bottom"
-const SCROLL_BOTTOM_THRESHOLD = 100;
+// How close to the very end (in px) the user must be for the log to keep
+// following new lines. Small so that scrolling up even a little to read
+// stops the follow; returning to the end resumes it.
+const SCROLL_BOTTOM_THRESHOLD = 40;
 
 export const TaskLogContent = ({
   currentMatchLineIndex,
@@ -108,8 +110,11 @@ export const TaskLogContent = ({
     }
     const isFirstLoad = prevVisibleCountRef.current === 0;
     const hasNewLines = visibleItems.length > prevVisibleCountRef.current;
+    // Pause following while the user is selecting text in the log — scrolling
+    // would move the text out from under the cursor and clear the selection.
+    const isSelecting = isSelectionWithin(document.getSelection(), 
parentRef.current);
 
-    if ((isFirstLoad || (hasNewLines && isAtBottomRef.current)) && 
!location.hash) {
+    if ((isFirstLoad || (hasNewLines && isAtBottomRef.current && 
!isSelecting)) && !location.hash) {
       rowVirtualizer.scrollToIndex(visibleItems.length - 1, { align: "end" });
     }
     prevVisibleCountRef.current = visibleItems.length;
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts
index 004f9a2b6e0..6bb68d7192a 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts
@@ -19,7 +19,7 @@
 import type { TFunction } from "i18next";
 import { describe, expect, it } from "vitest";
 
-import { getDownloadText, getHighlightColor, splitBySearchQuery } from 
"./utils";
+import { getDownloadText, getHighlightColor, isSelectionWithin, 
splitBySearchQuery } from "./utils";
 
 const translate = ((key: string) => key) as unknown as TFunction;
 
@@ -240,3 +240,79 @@ describe("splitBySearchQuery", () => {
     expect(splitBySearchQuery("error", "error")).toEqual([{ highlight: true, 
text: "error" }]);
   });
 });
+
+const makeSelectionForContainer = (options: {
+  anchor: Node | null;
+  collapsed?: boolean;
+  focus?: Node | null;
+  rangeCount?: number;
+}): Selection =>
+  ({
+    anchorNode: options.anchor,
+    focusNode: options.focus ?? options.anchor,
+    isCollapsed: options.collapsed ?? false,
+    rangeCount: options.rangeCount ?? 1,
+  }) as unknown as Selection;
+
+const buildSelectionContainer = () => {
+  const container = document.createElement("div");
+  const inside = document.createElement("span");
+
+  inside.textContent = "log line";
+  container.append(inside);
+
+  return { container, inside: inside.firstChild as Node };
+};
+
+describe("isSelectionWithin", () => {
+  it("returns true when a non-collapsed selection sits inside the container", 
() => {
+    const { container, inside } = buildSelectionContainer();
+
+    expect(isSelectionWithin(makeSelectionForContainer({ anchor: inside }), 
container)).toBe(true);
+  });
+
+  it("returns true when only one boundary is inside the container", () => {
+    const { container, inside } = buildSelectionContainer();
+    const outside = document.createElement("div");
+
+    outside.textContent = "elsewhere";
+
+    expect(
+      isSelectionWithin(makeSelectionForContainer({ anchor: 
outside.firstChild, focus: inside }), container),
+    ).toBe(true);
+  });
+
+  it("returns false for a collapsed selection (a plain click)", () => {
+    const { container, inside } = buildSelectionContainer();
+
+    expect(isSelectionWithin(makeSelectionForContainer({ anchor: inside, 
collapsed: true }), container)).toBe(
+      false,
+    );
+  });
+
+  it("returns false when the selection is entirely outside the container", () 
=> {
+    const { container } = buildSelectionContainer();
+    const outside = document.createElement("div");
+
+    outside.textContent = "elsewhere";
+
+    expect(isSelectionWithin(makeSelectionForContainer({ anchor: 
outside.firstChild }), container)).toBe(
+      false,
+    );
+  });
+
+  it("returns false for a null selection or null container", () => {
+    const { container, inside } = buildSelectionContainer();
+
+    expect(isSelectionWithin(null, container)).toBe(false);
+    expect(isSelectionWithin(makeSelectionForContainer({ anchor: inside }), 
null)).toBe(false);
+  });
+
+  it("returns false when there is no range", () => {
+    const { container, inside } = buildSelectionContainer();
+
+    expect(isSelectionWithin(makeSelectionForContainer({ anchor: inside, 
rangeCount: 0 }), container)).toBe(
+      false,
+    );
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
index 248a89de2a5..5ff63bb5511 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.ts
@@ -183,3 +183,16 @@ export const scrollToBottom = ({ element, virtualizer }: 
ScrollToBottomOptions):
   virtualizer.scrollToOffset(offset);
   element.scrollTop = offset;
 };
+
+/**
+ * Whether a non-empty text selection currently sits inside `container`.
+ * Used to pause following new log lines while the user is selecting text, so
+ * auto-scrolling does not move the text out from under the cursor.
+ */
+export const isSelectionWithin = (selection: Selection | null, container: 
HTMLElement | null): boolean => {
+  if (!selection || selection.isCollapsed || selection.rangeCount === 0 || 
!container) {
+    return false;
+  }
+
+  return container.contains(selection.anchorNode) || 
container.contains(selection.focusNode);
+};

Reply via email to