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 4d5d876d610 feat(ui): add search functionality to task log viewer 
(#63467)
4d5d876d610 is described below

commit 4d5d876d610c986ecce1c61a779fd21b37e9f314
Author: Antonio Mello <[email protected]>
AuthorDate: Mon Apr 13 14:55:43 2026 -0300

    feat(ui): add search functionality to task log viewer (#63467)
    
    Add text search with navigation and highlighting to the task log viewer.
    
    Search input in the header with keyboard shortcuts (/, Enter, Shift+Enter,
    Escape), yellow highlighting for matches (emphasized for active, subtle for
    others), auto-scroll via useVirtualizer, and i18n support.
    
    Log groups (::group::/::endgroup::) are rendered as flat entries with group
    metadata instead of collapsed <details> elements. This allows search to find
    matches per-line inside groups rather than treating each group as a single
    searchable unit. A useLogGroups hook manages expand/collapse state with
    support for nested groups and auto-expansion when search navigates to a
    match inside a collapsed group.
---
 .../src/airflow/ui/public/i18n/locales/en/dag.json |   5 +
 .../src/airflow/ui/src/mocks/handlers/log.ts       |   9 +
 .../ui/src/pages/Dag/Overview/TaskLogPreview.tsx   |   3 +-
 .../pages/TaskInstance/Logs/HighlightedText.tsx    |  47 ++++
 .../TaskInstance/Logs/LogSearchInput.test.tsx      | 177 ++++++++++++++
 .../src/pages/TaskInstance/Logs/LogSearchInput.tsx | 118 ++++++++++
 .../ui/src/pages/TaskInstance/Logs/Logs.test.tsx   | 252 ++++++++++++++++++--
 .../ui/src/pages/TaskInstance/Logs/Logs.tsx        |  80 +++++--
 .../src/pages/TaskInstance/Logs/ScrollToButton.tsx |  60 +++++
 .../src/pages/TaskInstance/Logs/TaskLogContent.tsx | 253 +++++++++++++--------
 .../src/pages/TaskInstance/Logs/TaskLogHeader.tsx  |   7 +-
 .../src/pages/TaskInstance/Logs/useLogGroups.tsx   | 171 ++++++++++++++
 .../ui/src/pages/TaskInstance/Logs/utils.test.ts   | 162 +++++++++++++
 .../ui/src/pages/TaskInstance/Logs/utils.ts        | 113 +++++++++
 .../src/airflow/ui/src/queries/useLogs.tsx         |  93 ++++----
 15 files changed, 1357 insertions(+), 193 deletions(-)

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 58626c24942..563b53eefe1 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
@@ -66,6 +66,11 @@
     },
     "info": "INFO",
     "noTryNumber": "No try number",
+    "search": {
+      "matchCount": "{{current}} of {{total}}",
+      "noMatches": "No matches",
+      "placeholder": "Search logs..."
+    },
     "settings": "Log Settings",
     "viewInExternal": "View logs in {{name}} (attempt {{attempt}})",
     "warning": "WARNING"
diff --git a/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts 
b/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
index b9ed6b73448..b9d9d2cfc62 100644
--- a/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
+++ b/airflow-core/src/airflow/ui/src/mocks/handlers/log.ts
@@ -82,6 +82,11 @@ export const handlers: Array<HttpHandler> = [
             "[2025-02-28T10:49:09.535+0530] {local_task_job_runner.py:120} 
INFO - ::group::Pre task execution logs",
           timestamp: "2025-02-28T10:49:09.535000+05:30",
         },
+        {
+          event:
+            "[2025-02-28T10:49:09.673+0530] {taskinstance.py:2340} INFO - 
::group::Dependency check details",
+          timestamp: "2025-02-28T10:49:09.673000+05:30",
+        },
         {
           event:
             "[2025-02-28T10:49:09.674+0530] {taskinstance.py:2348} INFO - 
Dependencies all met for dep_context=non-requeueable deps ti=<TaskInstance: 
tutorial_dag.load manual__2025-02-28T05:18:54.249762+00:00 [queued]>",
@@ -92,6 +97,10 @@ export const handlers: Array<HttpHandler> = [
             "[2025-02-28T10:49:09.678+0530] {taskinstance.py:2348} INFO - 
Dependencies all met for dep_context=requeueable deps ti=<TaskInstance: 
tutorial_dag.load manual__2025-02-28T05:18:54.249762+00:00 [queued]>",
           timestamp: "2025-02-28T10:49:09.678000+05:30",
         },
+        {
+          event: "[2025-02-28T10:49:09.679+0530] {taskinstance.py:2349} INFO - 
::endgroup::",
+          timestamp: "2025-02-28T10:49:09.679000+05:30",
+        },
         {
           event: "[2025-02-28T10:49:09.679+0530] {taskinstance.py:2589} INFO - 
Starting attempt 1 of 3",
           timestamp: "2025-02-28T10:49:09.679000+05:30",
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
index 9554c02e216..e4ca4e1e966 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Overview/TaskLogPreview.tsx
@@ -84,13 +84,14 @@ export const TaskLogPreview = ({
         <Box borderTopStyle="solid" borderTopWidth={1} maxHeight="200px" 
overflow="auto">
           <TaskLogContent
             error={error}
+            expanded
             isLoading={isLoading}
             logError={error}
             parsedLogs={data.parsedLogs ?? []}
             wrap={wrap}
           />
         </Box>
-      ) : null}
+      ) : undefined}
     </Box>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx
new file mode 100644
index 00000000000..48f647f2f29
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/HighlightedText.tsx
@@ -0,0 +1,47 @@
+/*!
+ * 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 { Mark } from "@chakra-ui/react";
+import type { PropsWithChildren } from "react";
+
+import { splitBySearchQuery } from "./utils";
+
+type HighlightedTextProps = PropsWithChildren<{ readonly query?: string }>;
+
+export const HighlightedText = ({ children, query }: HighlightedTextProps) => {
+  if (typeof query !== "string" || query.length === 0 || typeof children !== 
"string") {
+    return children;
+  }
+
+  const segments = splitBySearchQuery(children, query);
+
+  return (
+    <span>
+      {segments.map((segment, idx) =>
+        segment.highlight ? (
+          // eslint-disable-next-line react/no-array-index-key
+          <Mark bg="yellow.subtle" key={idx}>
+            {segment.text}
+          </Mark>
+        ) : (
+          segment.text
+        ),
+      )}
+    </span>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
new file mode 100644
index 00000000000..53f48ad9fbc
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.test.tsx
@@ -0,0 +1,177 @@
+/*!
+ * 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 { LogSearchInput } from "./LogSearchInput";
+
+const defaultProps = {
+  currentMatchIndex: 0,
+  onSearchChange: vi.fn(),
+  onSearchNext: vi.fn(),
+  onSearchPrevious: vi.fn(),
+  searchQuery: "",
+  totalMatches: 0,
+};
+
+describe("LogSearchInput", () => {
+  describe("initial render", () => {
+    it("renders the search input", () => {
+      render(<LogSearchInput {...defaultProps} />, { wrapper: Wrapper });
+      expect(screen.getByTestId("log-search-input")).toBeInTheDocument();
+    });
+
+    it("does not show match counter or navigation buttons when searchQuery is 
empty", () => {
+      render(<LogSearchInput {...defaultProps} />, { wrapper: Wrapper });
+      expect(screen.queryByRole("button", { name: /next match/iu 
})).toBeNull();
+      expect(screen.queryByRole("button", { name: /previous match/iu 
})).toBeNull();
+    });
+
+    it("shows match counter and navigation buttons when searchQuery is 
non-empty", () => {
+      render(<LogSearchInput {...defaultProps} searchQuery="error" 
totalMatches={3} />, {
+        wrapper: Wrapper,
+      });
+      expect(screen.getByRole("button", { name: /next match/iu 
})).toBeInTheDocument();
+      expect(screen.getByRole("button", { name: /previous match/iu 
})).toBeInTheDocument();
+    });
+  });
+
+  describe("keyboard navigation", () => {
+    it("calls onSearchNext when Enter is pressed in the input", () => {
+      const onSearchNext = vi.fn();
+
+      render(
+        <LogSearchInput {...defaultProps} onSearchNext={onSearchNext} 
searchQuery="info" totalMatches={5} />,
+        {
+          wrapper: Wrapper,
+        },
+      );
+
+      fireEvent.keyDown(screen.getByTestId("log-search-input"), { key: "Enter" 
});
+      expect(onSearchNext).toHaveBeenCalledTimes(1);
+    });
+
+    it("calls onSearchPrevious when Shift+Enter is pressed", () => {
+      const onSearchPrevious = vi.fn();
+
+      render(
+        <LogSearchInput
+          {...defaultProps}
+          onSearchPrevious={onSearchPrevious}
+          searchQuery="info"
+          totalMatches={5}
+        />,
+        { wrapper: Wrapper },
+      );
+
+      fireEvent.keyDown(screen.getByTestId("log-search-input"), { key: 
"Enter", shiftKey: true });
+      expect(onSearchPrevious).toHaveBeenCalledTimes(1);
+    });
+
+    it("calls onSearchChange with empty string when Escape is pressed", () => {
+      const onSearchChange = vi.fn();
+
+      render(
+        <LogSearchInput
+          {...defaultProps}
+          onSearchChange={onSearchChange}
+          searchQuery="warning"
+          totalMatches={2}
+        />,
+        {
+          wrapper: Wrapper,
+        },
+      );
+
+      fireEvent.keyDown(screen.getByTestId("log-search-input"), { key: 
"Escape" });
+      expect(onSearchChange).toHaveBeenCalledWith("");
+    });
+  });
+
+  describe("navigation buttons", () => {
+    it("calls onSearchNext when the next-match button is clicked", () => {
+      const onSearchNext = vi.fn();
+
+      render(
+        <LogSearchInput {...defaultProps} onSearchNext={onSearchNext} 
searchQuery="task" totalMatches={4} />,
+        {
+          wrapper: Wrapper,
+        },
+      );
+
+      fireEvent.click(screen.getByRole("button", { name: /next match/iu }));
+      expect(onSearchNext).toHaveBeenCalledTimes(1);
+    });
+
+    it("calls onSearchPrevious when the previous-match button is clicked", () 
=> {
+      const onSearchPrevious = vi.fn();
+
+      render(
+        <LogSearchInput
+          {...defaultProps}
+          onSearchPrevious={onSearchPrevious}
+          searchQuery="task"
+          totalMatches={4}
+        />,
+        {
+          wrapper: Wrapper,
+        },
+      );
+
+      fireEvent.click(screen.getByRole("button", { name: /previous match/iu 
}));
+      expect(onSearchPrevious).toHaveBeenCalledTimes(1);
+    });
+
+    it("navigation buttons are disabled when there are no matches", () => {
+      render(<LogSearchInput {...defaultProps} searchQuery="notfound" 
totalMatches={0} />, {
+        wrapper: Wrapper,
+      });
+
+      expect(screen.getByRole("button", { name: /next match/iu 
})).toBeDisabled();
+      expect(screen.getByRole("button", { name: /previous match/iu 
})).toBeDisabled();
+    });
+  });
+
+  describe("clear button", () => {
+    it("calls onSearchChange with empty string when the clear button is 
clicked", () => {
+      const onSearchChange = vi.fn();
+
+      render(
+        <LogSearchInput
+          {...defaultProps}
+          onSearchChange={onSearchChange}
+          searchQuery="error"
+          totalMatches={1}
+        />,
+        {
+          wrapper: Wrapper,
+        },
+      );
+
+      // The CloseButton has no label text; find it by its role inside the 
search widget
+      const closeButton = screen.getByRole("button", { name: /close/iu });
+
+      fireEvent.click(closeButton);
+      expect(onSearchChange).toHaveBeenCalledWith("");
+    });
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
new file mode 100644
index 00000000000..d9a417e0a66
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/LogSearchInput.tsx
@@ -0,0 +1,118 @@
+/*!
+ * 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 { CloseButton, HStack, IconButton, Input, InputGroup, Text } from 
"@chakra-ui/react";
+import { useRef, type KeyboardEvent } from "react";
+import { useHotkeys } from "react-hotkeys-hook";
+import { useTranslation } from "react-i18next";
+import { FiChevronDown, FiChevronUp, FiSearch } from "react-icons/fi";
+
+export type LogSearchInputProps = {
+  readonly currentMatchIndex: number;
+  readonly onSearchChange: (query: string) => void;
+  readonly onSearchNext: () => void;
+  readonly onSearchPrevious: () => void;
+  readonly searchQuery: string;
+  readonly totalMatches: number;
+};
+
+export const LogSearchInput = ({
+  currentMatchIndex,
+  onSearchChange,
+  onSearchNext,
+  onSearchPrevious,
+  searchQuery,
+  totalMatches,
+}: LogSearchInputProps) => {
+  const { t: translate } = useTranslation("dag");
+  const searchInputRef = useRef<HTMLInputElement>(null);
+
+  useHotkeys(
+    "/",
+    (event) => {
+      event.preventDefault();
+      searchInputRef.current?.focus();
+    },
+    { enableOnFormTags: false },
+  );
+
+  const handleSearchKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
+    if (event.key === "Enter") {
+      if (event.shiftKey) {
+        onSearchPrevious();
+      } else {
+        onSearchNext();
+      }
+    }
+    if (event.key === "Escape") {
+      onSearchChange("");
+      searchInputRef.current?.blur();
+    }
+  };
+
+  return (
+    <HStack flex={1} gap={1} maxW="350px" minW="150px">
+      <InputGroup
+        endElement={
+          searchQuery ? (
+            <HStack gap={0.5}>
+              <Text color="fg.muted" fontSize="xs" whiteSpace="nowrap">
+                {totalMatches > 0
+                  ? translate("logs.search.matchCount", {
+                      current: currentMatchIndex + 1,
+                      total: totalMatches,
+                    })
+                  : translate("logs.search.noMatches")}
+              </Text>
+              <IconButton
+                aria-label="Previous match"
+                disabled={totalMatches === 0}
+                onClick={onSearchPrevious}
+                size="2xs"
+                variant="ghost"
+              >
+                <FiChevronUp />
+              </IconButton>
+              <IconButton
+                aria-label="Next match"
+                disabled={totalMatches === 0}
+                onClick={onSearchNext}
+                size="2xs"
+                variant="ghost"
+              >
+                <FiChevronDown />
+              </IconButton>
+              <CloseButton onClick={() => onSearchChange("")} size="2xs" />
+            </HStack>
+          ) : undefined
+        }
+        startElement={<FiSearch />}
+      >
+        <Input
+          data-testid="log-search-input"
+          onChange={(event) => onSearchChange(event.target.value)}
+          onKeyDown={handleSearchKeyDown}
+          placeholder={translate("logs.search.placeholder")}
+          ref={searchInputRef}
+          size="sm"
+          value={searchQuery}
+        />
+      </InputGroup>
+    </HStack>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
index 253bdd0bc3d..cae7f847caa 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.test.tsx
@@ -37,9 +37,12 @@ const waitForLogs = async () => {
   await waitFor(() => 
expect(screen.getByTestId("virtualized-list")).toBeInTheDocument());
 
   // Wait for virtualized items to be rendered - they might not all be visible 
initially
+  // Items can have either virtualized-item- or group-header- testid prefixes
   await waitFor(() => {
     const virtualizedList = screen.getByTestId("virtualized-list");
-    const virtualizedItems = 
virtualizedList.querySelectorAll('[data-testid^="virtualized-item-"]');
+    const virtualizedItems = virtualizedList.querySelectorAll(
+      '[data-testid^="virtualized-item-"], [data-testid^="group-header-"]',
+    );
 
     expect(virtualizedItems.length).toBeGreaterThan(0);
   });
@@ -87,36 +90,52 @@ describe("Task log grouping", () => {
 
     await waitForLogs();
 
+    // Group headers use the summary-{name} testid pattern and are always 
visible
     const summarySource = screen.getByTestId(
       'summary-Log message source details 
sources=["/home/airflow/logs/dag_id=tutorial_dag/run_id=manual__2025-02-28T05:18:54.249762+00:00/task_id=load/attempt=1.log"]',
     );
 
     expect(summarySource).toBeVisible();
-    fireEvent.click(summarySource);
-    await waitFor(() => 
expect(screen.queryByText(/sources=\[/iu)).toBeVisible());
 
     const summaryPre = screen.getByTestId("summary-Pre task execution logs");
 
     expect(summaryPre).toBeVisible();
-    fireEvent.click(summaryPre);
-    await waitFor(() => expect(screen.getByText(/starting attempt 1 of 
3/iu)).toBeVisible());
 
+    // All groups start collapsed. Verify Post header is in the virtualizer 
range.
     const summaryPost = screen.getByTestId("summary-Post task execution logs");
 
     expect(summaryPost).toBeVisible();
-    fireEvent.click(summaryPost);
-    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).toBeVisible());
 
+    // Groups start collapsed — content should not be in DOM
+    expect(screen.queryByText(/starting attempt 1 of 3/iu)).toBeNull();
+
+    // Click to expand Pre
+    fireEvent.click(summaryPre);
+    await waitFor(() => expect(screen.getByText(/starting attempt 1 of 
3/iu)).toBeInTheDocument());
+
+    // Click Pre to collapse
     fireEvent.click(summaryPre);
-    await waitFor(() => expect(screen.queryByText(/Task instance is in running 
state/iu)).not.toBeVisible());
+    await waitFor(() => expect(screen.queryByText(/Task instance is in running 
state/iu)).toBeNull());
+
+    // Click Pre to expand again
     fireEvent.click(summaryPre);
-    await waitFor(() => expect(screen.queryByText(/Task instance is in running 
state/iu)).toBeVisible());
+    await waitFor(() =>
+      expect(screen.queryByText(/Task instance is in running 
state/iu)).toBeInTheDocument(),
+    );
 
-    fireEvent.click(summaryPost);
-    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).not.toBeVisible());
-    fireEvent.click(summaryPost);
-    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).toBeVisible());
+    // Click Pre to collapse again (to return to compact view)
+    fireEvent.click(summaryPre);
+    await waitFor(() => expect(screen.queryByText(/Task instance is in running 
state/iu)).toBeNull());
 
+    // Now expand Post (which is visible because Pre is collapsed again)
+    fireEvent.click(screen.getByTestId("summary-Post task execution logs"));
+    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).toBeInTheDocument());
+
+    // Collapse Post
+    fireEvent.click(screen.getByTestId("summary-Post task execution logs"));
+    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).toBeNull());
+
+    // Test Expand All / Collapse All via settings menu
     const settingsBtn = screen.getByRole("button", { name: /settings/iu });
 
     fireEvent.click(settingsBtn);
@@ -125,12 +144,213 @@ describe("Task log grouping", () => {
 
     fireEvent.click(expandItem);
 
-    /* ─── NEW: open again & click  "Collapse"  ─── */
-    fireEvent.click(settingsBtn); // menu is closed after previous click, so 
reopen
+    // After "Expand All", Pre group content near the top should be visible
+    await waitFor(() => expect(screen.queryByText(/starting attempt 1 of 
3/iu)).toBeInTheDocument());
+
+    /* ─── Click  "Collapse"  ─── */
+    fireEvent.click(settingsBtn);
     const collapseItem = await screen.findByRole("menuitem", { name: 
/collapse/iu });
 
     fireEvent.click(collapseItem);
 
-    await waitFor(() => expect(screen.queryByText(/Marking task as 
SUCCESS/iu)).not.toBeVisible());
+    // After "Collapse All", group content should be gone from the DOM
+    await waitFor(() => expect(screen.queryByText(/starting attempt 1 of 
3/iu)).toBeNull());
+  }, 10_000);
+
+  it("renders nested groups correctly", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
 />,
+    );
+
+    await waitForLogs();
+
+    // The nested group "Dependency check details" should have a header
+    // First expand the parent group "Pre task execution logs"
+    const summaryPre = screen.getByTestId("summary-Pre task execution logs");
+
+    fireEvent.click(summaryPre);
+
+    // The nested group header should now be visible
+    await waitFor(() => expect(screen.getByTestId("summary-Dependency check 
details")).toBeInTheDocument());
+
+    // But nested group content is collapsed
+    expect(screen.queryByText(/dep_context=non-requeueable/iu)).toBeNull();
+
+    // Expand the nested group
+    fireEvent.click(screen.getByTestId("summary-Dependency check details"));
+    await waitFor(() => 
expect(screen.getByText(/dep_context=non-requeueable/iu)).toBeInTheDocument());
+  }, 10_000);
+});
+
+describe("Task log search", () => {
+  it("search input is rendered in the log header", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    expect(screen.getByTestId("log-search-input")).toBeInTheDocument();
+  });
+
+  it("typing in the search input enables navigation buttons for a known term", 
async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    // "running state" appears in the mock log data
+    fireEvent.change(searchInput, { target: { value: "running state" } });
+
+    // Navigation buttons should become enabled once matches are found
+    await waitFor(() => {
+      expect(screen.getByRole("button", { name: /next match/iu 
})).not.toBeDisabled();
+      expect(screen.getByRole("button", { name: /previous match/iu 
})).not.toBeDisabled();
+    });
+  });
+
+  it("shows no-matches indicator for a term that does not exist in logs", 
async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    fireEvent.change(searchInput, { target: { value: "zzz_not_in_logs_zzz" } 
});
+
+    await waitFor(() => {
+      // Navigation buttons should be disabled with zero matches
+      const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+      expect(nextBtn).toBeDisabled();
+    });
+  });
+
+  it("pressing Escape clears the search query", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    fireEvent.change(searchInput, { target: { value: "running" } });
+
+    await waitFor(() => expect(screen.queryByRole("button", { name: /next 
match/iu })).toBeInTheDocument());
+
+    fireEvent.keyDown(searchInput, { key: "Escape" });
+
+    await waitFor(() => {
+      expect((searchInput as HTMLInputElement).value).toBe("");
+    });
+  });
+
+  it("pressing Enter keeps navigation buttons enabled (navigates to next 
match)", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    // "state" appears multiple times in the mock log data
+    fireEvent.change(searchInput, { target: { value: "state" } });
+
+    // Wait for matches to be found (navigation buttons become enabled)
+    await waitFor(() => expect(screen.getByRole("button", { name: /next 
match/iu })).not.toBeDisabled());
+
+    // Navigate forward — buttons remain enabled
+    fireEvent.keyDown(searchInput, { key: "Enter" });
+
+    await waitFor(() => {
+      expect(screen.getByRole("button", { name: /next match/iu 
})).not.toBeDisabled();
+    });
+  });
+
+  it("pressing Shift+Enter keeps navigation buttons enabled (navigates to 
previous match)", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/log_source"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    fireEvent.change(searchInput, { target: { value: "state" } });
+
+    // Wait for matches to be found
+    await waitFor(() => expect(screen.getByRole("button", { name: /previous 
match/iu })).not.toBeDisabled());
+
+    // Navigate backward — buttons remain enabled
+    fireEvent.keyDown(searchInput, { key: "Enter", shiftKey: true });
+
+    await waitFor(() => {
+      expect(screen.getByRole("button", { name: /previous match/iu 
})).not.toBeDisabled();
+    });
+  });
+
+  it("search finds matches per-line inside log groups (not collapsed into 1 
match per group)", async () => {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
 />,
+    );
+
+    await waitForLogs();
+
+    const searchInput = screen.getByTestId("log-search-input");
+
+    // "INFO" appears in many individual log lines across multiple groups.
+    // With the old code, each group collapsed into 1 match. Now each line is 
a separate match.
+    fireEvent.change(searchInput, { target: { value: "INFO" } });
+
+    await waitFor(() => {
+      expect(screen.getByRole("button", { name: /next match/iu 
})).not.toBeDisabled();
+    });
+
+    // Verify per-line search by navigating through matches.
+    // With the old code (1 match per group), there were only 3 matches for 
"INFO".
+    // Now each line is a separate match, so we can navigate through many more.
+    const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+    // Navigate forward 4 times — if only 3 matches existed, the 4th click 
would wrap to #1
+    // We just verify it stays enabled (which it always does with >0 matches).
+    // The real proof is that the auto-expand test passes: searching "starting 
attempt"
+    // finds a match INSIDE a group, which was impossible with the old 
collapsed approach.
+    for (let navStep = 0; navStep < 4; navStep += 1) {
+      fireEvent.click(nextBtn);
+    }
+
+    await waitFor(() => {
+      expect(nextBtn).not.toBeDisabled();
+    });
+  }, 10_000);
+
+  it("search navigating to match in collapsed group auto-expands it", async () 
=> {
+    render(
+      <AppWrapper 
initialEntries={["/dags/log_grouping/runs/manual__2025-02-18T12:19/tasks/generate"]}
 />,
+    );
+
+    await waitForLogs();
+
+    // All groups start collapsed. Search for text that only appears inside a 
group.
+    const searchInput = screen.getByTestId("log-search-input");
+
+    fireEvent.change(searchInput, { target: { value: "starting attempt" } });
+
+    await waitFor(() => {
+      const nextBtn = screen.getByRole("button", { name: /next match/iu });
+
+      expect(nextBtn).not.toBeDisabled();
+    });
+
+    // The match is inside "Pre task execution logs" group.
+    // Auto-expand should make it visible.
+    await waitFor(() => expect(screen.getByText(/starting attempt 1 of 
3/iu)).toBeInTheDocument());
   }, 10_000);
 });
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
index c37b1e0b052..b9a2d61f08c 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/Logs.tsx
@@ -24,17 +24,16 @@ import { useParams, useSearchParams } from 
"react-router-dom";
 import { useLocalStorage } from "usehooks-ts";
 
 import { useTaskInstanceServiceGetMappedTaskInstance } from "openapi/queries";
-import { renderStructuredLog } from "src/components/renderStructuredLog";
 import { Dialog } from "src/components/ui";
 import { LOG_SHOW_SOURCE_KEY, LOG_SHOW_TIMESTAMP_KEY, LOG_WRAP_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { useConfig } from "src/queries/useConfig";
 import { useLogs } from "src/queries/useLogs";
-import { parseStreamingLogContent } from "src/utils/logs";
 
 import { ExternalLogLink } from "./ExternalLogLink";
 import { TaskLogContent, type TaskLogContentProps } from "./TaskLogContent";
 import { TaskLogHeader, type TaskLogHeaderProps } from "./TaskLogHeader";
+import { getDownloadText } from "./utils";
 
 export const Logs = () => {
   const { dagId = "", mapIndex = "-1", runId = "", taskId = "" } = useParams();
@@ -89,7 +88,6 @@ export const Logs = () => {
     parsedData,
   } = useLogs({
     dagId,
-    expanded,
     logLevelFilters,
     showSource,
     showTimestamp,
@@ -98,28 +96,52 @@ export const Logs = () => {
     tryNumber,
   });
 
-  const getParsedLogs = () => {
-    const lines = parseStreamingLogContent(fetchedData);
-
-    return lines.map((line) =>
-      renderStructuredLog({
-        index: 0,
-        logLevelFilters,
-        logLink: "",
-        logMessage: line,
-        renderingMode: "text",
-        showSource,
-        showTimestamp,
-        sourceFilters,
-        translate,
-      }),
-    );
+  const downloadTextLines = getDownloadText({
+    fetchedData,
+    logLevelFilters,
+    showSource,
+    showTimestamp,
+    sourceFilters,
+    translate,
+  });
+
+  const getLogString = () => downloadTextLines.filter((line) => line !== 
"").join("\n");
+
+  const [searchQuery, setSearchQuery] = useState("");
+  const [activeSearchIndex, setActiveSearchIndex] = useState(0);
+
+  const searchMatchIndices = (() => {
+    if (!searchQuery) {
+      return [];
+    }
+    const query = searchQuery.toLowerCase();
+    const indices: Array<number> = [];
+
+    parsedData.searchableText.forEach((line, index) => {
+      if (line.toLowerCase().includes(query)) {
+        indices.push(index);
+      }
+    });
+
+    return indices;
+  })();
+
+  const handleSearchChange = (query: string) => {
+    setSearchQuery(query);
+    setActiveSearchIndex(0);
+  };
+
+  const handleSearchNext = () => {
+    if (searchMatchIndices.length > 0) {
+      setActiveSearchIndex((prev) => (prev + 1) % searchMatchIndices.length);
+    }
   };
 
-  const getLogString = () =>
-    getParsedLogs()
-      .filter((line) => line !== "")
-      .join("\n");
+  const handleSearchPrevious = () => {
+    if (searchMatchIndices.length > 0) {
+      setActiveSearchIndex((prev) => (prev - 1 + searchMatchIndices.length) % 
searchMatchIndices.length);
+    }
+  };
 
   const downloadLogs = () => {
     const logContent = getLogString();
@@ -157,6 +179,14 @@ export const Logs = () => {
     expanded,
     getLogString,
     onSelectTryNumber,
+    search: {
+      currentMatchIndex: activeSearchIndex,
+      onSearchChange: handleSearchChange,
+      onSearchNext: handleSearchNext,
+      onSearchPrevious: handleSearchPrevious,
+      searchQuery,
+      totalMatches: searchMatchIndices.length,
+    },
     showSource,
     showTimestamp,
     sourceOptions: parsedData.sources,
@@ -171,10 +201,14 @@ export const Logs = () => {
   };
 
   const logContentProps: TaskLogContentProps = {
+    currentMatchLineIndex: searchMatchIndices[activeSearchIndex],
     error,
+    expanded,
     isLoading: isLoading || isLoadingLogs,
     logError,
     parsedLogs: parsedData.parsedLogs ?? [],
+    searchMatchIndices: searchQuery ? new Set(searchMatchIndices) : undefined,
+    searchQuery: searchQuery || undefined,
     wrap,
   };
 
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx
new file mode 100644
index 00000000000..579f23ef814
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/ScrollToButton.tsx
@@ -0,0 +1,60 @@
+/*!
+ * 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 { IconButton } from "@chakra-ui/react";
+import { useTranslation } from "react-i18next";
+import { FiChevronDown, FiChevronUp } from "react-icons/fi";
+
+import { Tooltip } from "src/components/ui";
+import { getMetaKey } from "src/utils";
+
+export const ScrollToButton = ({
+  direction,
+  onClick,
+}: {
+  readonly direction: "bottom" | "top";
+  readonly onClick: () => void;
+}) => {
+  const { t: translate } = useTranslation("common");
+
+  return (
+    <Tooltip
+      closeDelay={100}
+      content={translate("scroll.tooltip", {
+        direction: translate(`scroll.direction.${direction}`),
+        hotkey: `${getMetaKey()}+${direction === "bottom" ? "↓" : "↑"}`,
+      })}
+      openDelay={100}
+    >
+      <IconButton
+        _ltr={{ left: "auto", right: 4 }}
+        _rtl={{ left: 4, right: "auto" }}
+        aria-label={translate(`scroll.direction.${direction}`)}
+        bg="bg.panel"
+        bottom={direction === "bottom" ? 4 : 14}
+        onClick={onClick}
+        position="absolute"
+        rounded="full"
+        size="xs"
+        variant="outline"
+      >
+        {direction === "bottom" ? <FiChevronDown /> : <FiChevronUp />}
+      </IconButton>
+    </Tooltip>
+  );
+};
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 59ba801bacc..21583e19764 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
@@ -16,84 +16,63 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, Code, VStack, IconButton } from "@chakra-ui/react";
+import { Box, Code, VStack } from "@chakra-ui/react";
 import { useVirtualizer } from "@tanstack/react-virtual";
-import { type JSX, useLayoutEffect, useRef, useCallback, useEffect } from 
"react";
+import { useLayoutEffect, useRef, useCallback, useEffect } from "react";
 import { useHotkeys } from "react-hotkeys-hook";
-import { useTranslation } from "react-i18next";
-import { FiChevronDown, FiChevronUp } from "react-icons/fi";
 
 import { ErrorAlert } from "src/components/ErrorAlert";
-import { ProgressBar, Tooltip } from "src/components/ui";
-import { getMetaKey } from "src/utils";
+import { ProgressBar } from "src/components/ui";
+import type { ParsedLogEntry } from "src/queries/useLogs";
 
-import { scrollToBottom, scrollToTop } from "./utils";
+import { HighlightedText } from "./HighlightedText";
+import { ScrollToButton } from "./ScrollToButton";
+import { useLogGroups } from "./useLogGroups";
+import { getHighlightColor, scrollToBottom, scrollToTop } from "./utils";
 
 export type TaskLogContentProps = {
+  readonly currentMatchLineIndex?: number;
   readonly error: unknown;
+  readonly expanded: boolean;
   readonly isLoading: boolean;
   readonly logError: unknown;
-  readonly parsedLogs: Array<JSX.Element | string | undefined>;
+  readonly parsedLogs: Array<ParsedLogEntry>;
+  readonly searchMatchIndices?: Set<number>;
+  readonly searchQuery?: string;
   readonly wrap: boolean;
 };
 
 // How close to the bottom (in px) before we consider the user "at the bottom"
 const SCROLL_BOTTOM_THRESHOLD = 100;
 
-const ScrollToButton = ({
-  direction,
-  onClick,
-}: {
-  readonly direction: "bottom" | "top";
-  readonly onClick: () => void;
-}) => {
-  const { t: translate } = useTranslation("common");
-
-  return (
-    <Tooltip
-      closeDelay={100}
-      content={translate("scroll.tooltip", {
-        direction: translate(`scroll.direction.${direction}`),
-        hotkey: `${getMetaKey()}+${direction === "bottom" ? "↓" : "↑"}`,
-      })}
-      openDelay={100}
-    >
-      <IconButton
-        _ltr={{
-          left: "auto",
-          right: 4,
-        }}
-        _rtl={{
-          left: 4,
-          right: "auto",
-        }}
-        aria-label={translate(`scroll.direction.${direction}`)}
-        bg="bg.panel"
-        bottom={direction === "bottom" ? 4 : 14}
-        onClick={onClick}
-        position="absolute"
-        rounded="full"
-        size="xs"
-        variant="outline"
-      >
-        {direction === "bottom" ? <FiChevronDown /> : <FiChevronUp />}
-      </IconButton>
-    </Tooltip>
-  );
-};
-
-export const TaskLogContent = ({ error, isLoading, logError, parsedLogs, wrap 
}: TaskLogContentProps) => {
+export const TaskLogContent = ({
+  currentMatchLineIndex,
+  error,
+  expanded,
+  isLoading,
+  logError,
+  parsedLogs,
+  searchMatchIndices,
+  searchQuery,
+  wrap,
+}: TaskLogContentProps) => {
   const hash = location.hash.replace("#", "");
   const parentRef = useRef<HTMLDivElement | null>(null);
 
-  // Track whether user is at the bottom so we don't hijack their scroll 
position
-  // if they scrolled up to read something
+  const {
+    expandedGroups,
+    originalToVisibleIndex,
+    toggleGroup,
+    visibleCurrentMatchIndex,
+    visibleItems,
+    visibleSearchMatchIndices,
+  } = useLogGroups({ currentMatchLineIndex, expanded, parsedLogs, 
searchMatchIndices });
+
   const isAtBottomRef = useRef<boolean>(true);
-  // Track previous log count to detect new lines arriving
-  const prevLogCountRef = useRef<number>(0);
+  const prevVisibleCountRef = useRef<number>(0);
 
   const rowVirtualizer = useVirtualizer({
-    count: parsedLogs.length,
+    count: visibleItems.length,
     estimateSize: () => 20,
     getScrollElement: () => parentRef.current,
     overscan: 10,
@@ -101,18 +80,15 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
 
   const contentHeight = rowVirtualizer.getTotalSize();
   const containerHeight = rowVirtualizer.scrollElement?.clientHeight ?? 0;
-  const showScrollButtons = parsedLogs.length > 1 && contentHeight > 
containerHeight;
+  const showScrollButtons = visibleItems.length > 1 && contentHeight > 
containerHeight;
 
-  // Check if user is near the bottom on scroll
   const handleScroll = useCallback(() => {
     const el = parentRef.current;
 
     if (!el) {
       return;
     }
-    const distanceFromBottom = el.scrollHeight - el.scrollTop - 
el.clientHeight;
-
-    isAtBottomRef.current = distanceFromBottom <= SCROLL_BOTTOM_THRESHOLD;
+    isAtBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight 
<= SCROLL_BOTTOM_THRESHOLD;
   }, []);
 
   useEffect(() => {
@@ -123,41 +99,46 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
     return () => el?.removeEventListener("scroll", handleScroll);
   }, [handleScroll]);
 
-  // Auto-scroll to bottom when:
-  // 1. Logs first load (prevLogCount was 0)
-  // 2. New lines arrive AND user was already at the bottom
   useLayoutEffect(() => {
-    if (parsedLogs.length === 0) {
+    if (visibleItems.length === 0) {
       return;
     }
-
-    const isFirstLoad = prevLogCountRef.current === 0;
-    const hasNewLines = parsedLogs.length > prevLogCountRef.current;
+    const isFirstLoad = prevVisibleCountRef.current === 0;
+    const hasNewLines = visibleItems.length > prevVisibleCountRef.current;
 
     if ((isFirstLoad || (hasNewLines && isAtBottomRef.current)) && 
!location.hash) {
-      rowVirtualizer.scrollToIndex(parsedLogs.length - 1, { align: "end" });
+      rowVirtualizer.scrollToIndex(visibleItems.length - 1, { align: "end" });
     }
-
-    prevLogCountRef.current = parsedLogs.length;
-  }, [parsedLogs.length, rowVirtualizer]);
+    prevVisibleCountRef.current = visibleItems.length;
+  }, [visibleItems.length, rowVirtualizer]);
 
   useLayoutEffect(() => {
     if (location.hash && !isLoading) {
-      rowVirtualizer.scrollToIndex(Math.min(Number(hash) + 5, 
parsedLogs.length - 1));
+      const hashVisibleIndex = originalToVisibleIndex.get(Number(hash) - 1);
+
+      if (hashVisibleIndex !== undefined) {
+        rowVirtualizer.scrollToIndex(Math.min(hashVisibleIndex + 5, 
visibleItems.length - 1));
+      }
     }
-  }, [isLoading, rowVirtualizer, hash, parsedLogs]);
+    // React Compiler auto-memoizes; safe to include in deps
+  }, [isLoading, rowVirtualizer, hash, visibleItems, originalToVisibleIndex]);
+
+  useLayoutEffect(() => {
+    if (visibleCurrentMatchIndex !== undefined && !isLoading) {
+      rowVirtualizer.scrollToIndex(Math.min(visibleCurrentMatchIndex + 3, 
visibleItems.length - 1));
+    }
+    // React Compiler auto-memoizes; safe to include in deps
+  }, [visibleCurrentMatchIndex, isLoading, rowVirtualizer, visibleItems]);
 
   const handleScrollTo = (to: "bottom" | "top") => {
-    if (parsedLogs.length === 0) {
+    if (visibleItems.length === 0) {
       return;
     }
-
     const el = rowVirtualizer.scrollElement ?? parentRef.current;
 
     if (!el) {
       return;
     }
-
     if (to === "top") {
       isAtBottomRef.current = false;
       scrollToTop({ element: el, virtualizer: rowVirtualizer });
@@ -185,9 +166,7 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
         width="100%"
       >
         <Code
-          css={{
-            "& *::selection": { bg: "blue.emphasized" },
-          }}
+          css={{ "& *::selection": { bg: "blue.emphasized" } }}
           data-testid="virtualized-list"
           display="block"
           overflowX="auto"
@@ -200,29 +179,103 @@ export const TaskLogContent = ({ error, isLoading, 
logError, parsedLogs, wrap }:
             h={`${rowVirtualizer.getTotalSize()}px`}
             position="relative"
           >
-            {rowVirtualizer.getVirtualItems().map((virtualRow) => (
-              <Box
-                _ltr={{ left: 0, right: "auto" }}
-                _rtl={{ left: "auto", right: 0 }}
-                bgColor={
-                  Boolean(hash) && virtualRow.index === Number(hash) - 1 ? 
"brand.emphasized" : "transparent"
-                }
-                data-index={virtualRow.index}
-                data-testid={`virtualized-item-${virtualRow.index}`}
-                key={virtualRow.key}
-                position="absolute"
-                ref={rowVirtualizer.measureElement}
-                top={0}
-                transform={`translateY(${virtualRow.start}px)`}
-                width={wrap ? "100%" : "max-content"}
-              >
-                {parsedLogs[virtualRow.index] ?? undefined}
-              </Box>
-            ))}
+            {rowVirtualizer.getVirtualItems().map((virtualRow) => {
+              const item = visibleItems[virtualRow.index];
+
+              if (!item) {
+                return undefined;
+              }
+
+              const { entry, originalIndex } = item;
+              const isGroupHeader = entry.group?.type === "header";
+              const groupLevel = entry.group?.level ?? 0;
+              const indent = entry.group ? groupLevel * 4 + (isGroupHeader ? 0 
: 4) : 0;
+
+              if (isGroupHeader && entry.group) {
+                const isExpanded = expandedGroups.has(entry.group.id);
+
+                return (
+                  <Box
+                    _ltr={{ left: 0, right: "auto" }}
+                    _rtl={{ left: "auto", right: 0 }}
+                    bgColor={getHighlightColor({
+                      currentMatchLineIndex: visibleCurrentMatchIndex,
+                      hash,
+                      index: virtualRow.index,
+                      searchMatchIndices: visibleSearchMatchIndices,
+                    })}
+                    cursor="pointer"
+                    data-index={virtualRow.index}
+                    data-testid={`group-header-${virtualRow.index}`}
+                    key={virtualRow.key}
+                    onClick={() => entry.group && toggleGroup(entry.group.id)}
+                    pl={indent}
+                    position="absolute"
+                    ref={rowVirtualizer.measureElement}
+                    top={0}
+                    transform={`translateY(${virtualRow.start}px)`}
+                    width={wrap ? "100%" : "max-content"}
+                  >
+                    <Box
+                      as="span"
+                      color="fg.info"
+                      data-testid={`summary-${typeof entry.element === 
"string" ? entry.element : ""}`}
+                    >
+                      <Box
+                        as="span"
+                        display="inline-block"
+                        mr={1}
+                        transform={isExpanded ? "rotate(90deg)" : 
"rotate(0deg)"}
+                        transition="transform 0.15s"
+                      >
+                        {"\u25B6"}
+                      </Box>
+                      {visibleSearchMatchIndices?.has(virtualRow.index) ? (
+                        <HighlightedText query={searchQuery}>
+                          {typeof entry.element === "string" ? entry.element : 
undefined}
+                        </HighlightedText>
+                      ) : (
+                        entry.element
+                      )}
+                    </Box>
+                  </Box>
+                );
+              }
+
+              return (
+                <Box
+                  _ltr={{ left: 0, right: "auto" }}
+                  _rtl={{ left: "auto", right: 0 }}
+                  bgColor={getHighlightColor({
+                    currentMatchLineIndex: visibleCurrentMatchIndex,
+                    hash,
+                    index: virtualRow.index,
+                    searchMatchIndices: visibleSearchMatchIndices,
+                  })}
+                  data-index={virtualRow.index}
+                  data-original-index={originalIndex}
+                  data-testid={`virtualized-item-${virtualRow.index}`}
+                  key={virtualRow.key}
+                  pl={indent}
+                  position="absolute"
+                  ref={rowVirtualizer.measureElement}
+                  top={0}
+                  transform={`translateY(${virtualRow.start}px)`}
+                  width={wrap ? "100%" : "max-content"}
+                >
+                  {visibleSearchMatchIndices?.has(virtualRow.index) ? (
+                    <HighlightedText query={searchQuery}>
+                      {typeof entry.element === "string" ? entry.element : 
(entry.element ?? undefined)}
+                    </HighlightedText>
+                  ) : (
+                    (entry.element ?? undefined)
+                  )}
+                </Box>
+              );
+            })}
           </VStack>
         </Code>
       </Box>
-
       {showScrollButtons ? (
         <>
           <ScrollToButton direction="top" onClick={() => 
handleScrollTo("top")} />
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
index e94a258ffcb..bee839f744e 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/TaskLogHeader.tsx
@@ -45,12 +45,15 @@ import { SearchParamsKeys } from 
"src/constants/searchParams";
 import { defaultSystem } from "src/theme";
 import { type LogLevel, logLevelColorMapping, logLevelOptions } from 
"src/utils/logs";
 
+import { LogSearchInput, type LogSearchInputProps } from "./LogSearchInput";
+
 export type TaskLogHeaderProps = {
   readonly downloadLogs?: () => void;
   readonly expanded?: boolean;
   readonly getLogString: () => string;
   readonly isFullscreen?: boolean;
   readonly onSelectTryNumber: (tryNumber: number) => void;
+  readonly search: LogSearchInputProps;
   readonly showSource: boolean;
   readonly showTimestamp: boolean;
   readonly sourceOptions?: Array<string>;
@@ -70,6 +73,7 @@ export const TaskLogHeader = ({
   getLogString,
   isFullscreen = false,
   onSelectTryNumber,
+  search,
   showSource,
   showTimestamp,
   sourceOptions,
@@ -141,7 +145,7 @@ export const TaskLogHeader = ({
           taskInstance={taskInstance}
         />
       )}
-      <HStack justifyContent="space-between">
+      <HStack flexWrap="wrap" gap={2} justifyContent="space-between">
         <Select.Root
           collection={logLevelOptions}
           maxW="250px"
@@ -201,6 +205,7 @@ export const TaskLogHeader = ({
             </Select.Content>
           </Select.Root>
         ) : undefined}
+        <LogSearchInput {...search} />
         <HStack gap={1}>
           <Menu.Root>
             <Menu.Trigger asChild>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx
new file mode 100644
index 00000000000..176f481cf55
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/useLogGroups.tsx
@@ -0,0 +1,171 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useEffect, useState } from "react";
+
+import type { ParsedLogEntry } from "src/queries/useLogs";
+
+type VisibleItem = { entry: ParsedLogEntry; originalIndex: number };
+
+/**
+ * Manages log group expand/collapse state and computes the list of
+ * visible entries for the virtualizer. Handles nested groups and
+ * auto-expanding collapsed groups when search navigates into them.
+ */
+export const useLogGroups = ({
+  currentMatchLineIndex,
+  expanded,
+  parsedLogs,
+  searchMatchIndices,
+}: {
+  currentMatchLineIndex?: number;
+  expanded: boolean;
+  parsedLogs: Array<ParsedLogEntry>;
+  searchMatchIndices?: Set<number>;
+}) => {
+  // Build parent map for nested visibility checks
+  const groupHeaders = parsedLogs.filter(
+    (entry): entry is { group: NonNullable<ParsedLogEntry["group"]> } & 
ParsedLogEntry =>
+      entry.group?.type === "header",
+  );
+  const allGroupIds = new Set(groupHeaders.map((entry) => entry.group.id));
+  // eslint-disable-next-line react-hooks/exhaustive-deps -- React Compiler 
auto-memoizes this
+  const groupParentMap = new Map<number, number | undefined>(
+    groupHeaders.map((entry) => [entry.group.id, entry.group.parentId]),
+  );
+
+  const [expandedGroups, setExpandedGroups] = useState<Set<number>>(() =>
+    expanded ? new Set(allGroupIds) : new Set<number>(),
+  );
+
+  // Sync expandedGroups when expanded prop changes (toggle all)
+  useEffect(() => {
+    if (expanded) {
+      setExpandedGroups(new Set(allGroupIds));
+    } else {
+      setExpandedGroups(new Set<number>());
+    }
+    // Only react to the expanded prop, not allGroupIds
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [expanded]);
+
+  const toggleGroup = (groupId: number) => {
+    setExpandedGroups((prev) => {
+      const next = new Set(prev);
+
+      if (next.has(groupId)) {
+        next.delete(groupId);
+      } else {
+        next.add(groupId);
+      }
+
+      return next;
+    });
+  };
+
+  // Check if all ancestors of a group are expanded
+  const isGroupAncestryExpanded = (groupId: number): boolean => {
+    const parentId = groupParentMap.get(groupId);
+
+    if (parentId === undefined) {
+      return true;
+    }
+
+    return expandedGroups.has(parentId) && isGroupAncestryExpanded(parentId);
+  };
+
+  const isEntryVisible = (entry: ParsedLogEntry): boolean => {
+    if (!entry.group) {
+      return true;
+    }
+
+    if (entry.group.type === "header") {
+      return isGroupAncestryExpanded(entry.group.id);
+    }
+
+    return expandedGroups.has(entry.group.id) && 
isGroupAncestryExpanded(entry.group.id);
+  };
+
+  // Build visible items list with index mapping
+  const visibleItems: Array<VisibleItem> = [];
+  const originalToVisibleIndex = new Map<number, number>();
+
+  for (let idx = 0; idx < parsedLogs.length; idx += 1) {
+    const entry = parsedLogs[idx];
+
+    if (entry && isEntryVisible(entry)) {
+      originalToVisibleIndex.set(idx, visibleItems.length);
+      visibleItems.push({ entry, originalIndex: idx });
+    }
+  }
+
+  // Map search match indices from original to visible indices
+  const visibleSearchMatchIndices = searchMatchIndices
+    ? new Set(
+        [...searchMatchIndices]
+          .map((idx) => originalToVisibleIndex.get(idx))
+          .filter((idx): idx is number => idx !== undefined),
+      )
+    : undefined;
+
+  const visibleCurrentMatchIndex =
+    currentMatchLineIndex === undefined ? undefined : 
originalToVisibleIndex.get(currentMatchLineIndex);
+
+  // Auto-expand group (and all ancestors) when search navigates to a match 
inside a collapsed group
+  useEffect(() => {
+    if (currentMatchLineIndex === undefined) {
+      return;
+    }
+    const entry = parsedLogs[currentMatchLineIndex];
+
+    if (!entry?.group) {
+      return;
+    }
+
+    const groupsToExpand: Array<number> = [];
+
+    if (entry.group.type === "line" && !expandedGroups.has(entry.group.id)) {
+      groupsToExpand.push(entry.group.id);
+    }
+
+    // Walk up the parent chain
+    let currentId: number | undefined = entry.group.id;
+
+    while (currentId !== undefined) {
+      const parentId = groupParentMap.get(currentId);
+
+      if (parentId !== undefined && !expandedGroups.has(parentId)) {
+        groupsToExpand.push(parentId);
+      }
+      currentId = parentId;
+    }
+
+    if (groupsToExpand.length > 0) {
+      setExpandedGroups((prev) => new Set([...prev, ...groupsToExpand]));
+    }
+  }, [currentMatchLineIndex, parsedLogs, expandedGroups, groupParentMap]);
+
+  return {
+    expandedGroups,
+    originalToVisibleIndex,
+    toggleGroup,
+    visibleCurrentMatchIndex,
+    visibleItems,
+    visibleSearchMatchIndices,
+  };
+};
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
new file mode 100644
index 00000000000..5fd23ac5841
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Logs/utils.test.ts
@@ -0,0 +1,162 @@
+/*!
+ * 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 { describe, expect, it } from "vitest";
+
+import { getHighlightColor, splitBySearchQuery } from "./utils";
+
+describe("getHighlightColor", () => {
+  it("returns yellow.emphasized for the current search match", () => {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: 3,
+        hash: "",
+        index: 3,
+        searchMatchIndices: new Set([1, 3, 5]),
+      }),
+    ).toBe("yellow.emphasized");
+  });
+
+  it("returns yellow.subtle for a non-current search match", () => {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: 1,
+        hash: "",
+        index: 3,
+        searchMatchIndices: new Set([1, 3, 5]),
+      }),
+    ).toBe("yellow.subtle");
+  });
+
+  it("returns brand.emphasized for the URL-hash-linked line when no search is 
active", () => {
+    expect(
+      getHighlightColor({
+        hash: "5",
+        index: 4, // hash "5" maps to index 4 (1-based to 0-based)
+        searchMatchIndices: undefined,
+      }),
+    ).toBe("brand.emphasized");
+  });
+
+  it("returns transparent when no condition matches", () => {
+    expect(
+      getHighlightColor({
+        hash: "",
+        index: 2,
+        searchMatchIndices: undefined,
+      }),
+    ).toBe("transparent");
+  });
+
+  it("returns transparent when search is active but line is not a match", () 
=> {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: 0,
+        hash: "",
+        index: 7,
+        searchMatchIndices: new Set([0, 2]),
+      }),
+    ).toBe("transparent");
+  });
+
+  it("current match takes priority over hash highlight on same line", () => {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: 4,
+        hash: "5",
+        index: 4,
+        searchMatchIndices: new Set([4]),
+      }),
+    ).toBe("yellow.emphasized");
+  });
+
+  it("search match highlight takes priority over hash highlight on same line", 
() => {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: 0,
+        hash: "5",
+        index: 4,
+        searchMatchIndices: new Set([0, 4]),
+      }),
+    ).toBe("yellow.subtle");
+  });
+
+  it("returns transparent when searchMatchIndices is an empty Set", () => {
+    expect(
+      getHighlightColor({
+        currentMatchLineIndex: undefined,
+        hash: "",
+        index: 0,
+        searchMatchIndices: new Set(),
+      }),
+    ).toBe("transparent");
+  });
+});
+
+describe("splitBySearchQuery", () => {
+  it("returns the full text as a single non-highlight segment when query is 
empty", () => {
+    expect(splitBySearchQuery("hello world", "")).toEqual([{ highlight: false, 
text: "hello world" }]);
+  });
+
+  it("returns the full text when query does not match", () => {
+    expect(splitBySearchQuery("hello world", "xyz")).toEqual([{ highlight: 
false, text: "hello world" }]);
+  });
+
+  it("highlights a single match", () => {
+    expect(splitBySearchQuery("hello world", "world")).toEqual([
+      { highlight: false, text: "hello " },
+      { highlight: true, text: "world" },
+    ]);
+  });
+
+  it("highlights multiple matches", () => {
+    expect(splitBySearchQuery("foo bar foo", "foo")).toEqual([
+      { highlight: true, text: "foo" },
+      { highlight: false, text: " bar " },
+      { highlight: true, text: "foo" },
+    ]);
+  });
+
+  it("is case-insensitive", () => {
+    expect(splitBySearchQuery("Hello HELLO hello", "hello")).toEqual([
+      { highlight: true, text: "Hello" },
+      { highlight: false, text: " " },
+      { highlight: true, text: "HELLO" },
+      { highlight: false, text: " " },
+      { highlight: true, text: "hello" },
+    ]);
+  });
+
+  it("handles match at the start", () => {
+    expect(splitBySearchQuery("error: something", "error")).toEqual([
+      { highlight: true, text: "error" },
+      { highlight: false, text: ": something" },
+    ]);
+  });
+
+  it("handles match at the end", () => {
+    expect(splitBySearchQuery("something error", "error")).toEqual([
+      { highlight: false, text: "something " },
+      { highlight: true, text: "error" },
+    ]);
+  });
+
+  it("handles entire string as match", () => {
+    expect(splitBySearchQuery("error", "error")).toEqual([{ highlight: true, 
text: "error" }]);
+  });
+});
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 44fce3cc06c..276856b3f8c 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
@@ -17,6 +17,119 @@
  * under the License.
  */
 import type { Virtualizer } from "@tanstack/react-virtual";
+import type { TFunction } from "i18next";
+
+import type { TaskInstancesLogResponse } from "openapi/requests/types.gen";
+import { renderStructuredLog } from "src/components/renderStructuredLog";
+import { parseStreamingLogContent } from "src/utils/logs";
+
+type GetDownloadTextOptions = {
+  fetchedData: TaskInstancesLogResponse | undefined;
+  logLevelFilters: Array<string>;
+  showSource: boolean;
+  showTimestamp: boolean;
+  sourceFilters: Array<string>;
+  translate: TFunction;
+};
+
+/**
+ * Parse raw streaming log data into plain-text lines for log download.
+ * Search matching now uses the searchableText array from useLogs instead,
+ * which is derived from the rendered parsedLogs to stay aligned with log 
groups.
+ */
+export const getDownloadText = ({
+  fetchedData,
+  logLevelFilters,
+  showSource,
+  showTimestamp,
+  sourceFilters,
+  translate,
+}: GetDownloadTextOptions): Array<string> => {
+  const lines = parseStreamingLogContent(fetchedData);
+
+  return lines.map((line) =>
+    renderStructuredLog({
+      index: 0,
+      logLevelFilters,
+      logLink: "",
+      logMessage: line,
+      renderingMode: "text",
+      showSource,
+      showTimestamp,
+      sourceFilters,
+      translate,
+    }),
+  );
+};
+
+export type HighlightOptions = {
+  currentMatchLineIndex?: number;
+  hash: string;
+  index: number;
+  searchMatchIndices?: Set<number>;
+};
+
+/**
+ * Returns the background color token for a virtualized log row.
+ * Priority: current search match (yellow.emphasized) > URL hash line > 
transparent.
+ * Non-current search matches use yellow.subtle for a softer highlight.
+ */
+export const getHighlightColor = ({
+  currentMatchLineIndex,
+  hash,
+  index,
+  searchMatchIndices,
+}: HighlightOptions): string => {
+  if (currentMatchLineIndex !== undefined && index === currentMatchLineIndex) {
+    return "yellow.emphasized";
+  }
+  if (searchMatchIndices?.has(index)) {
+    return "yellow.subtle";
+  }
+  if (Boolean(hash) && index === Number(hash) - 1) {
+    return "brand.emphasized";
+  }
+
+  return "transparent";
+};
+
+/**
+ * Wraps matching substrings in a log line with a highlight marker.
+ * Returns an array of strings and { text, highlight } objects for rendering.
+ */
+export type HighlightSegment = { highlight: boolean; text: string };
+
+export const splitBySearchQuery = (text: string, query: string): 
Array<HighlightSegment> => {
+  if (!query) {
+    return [{ highlight: false, text }];
+  }
+
+  const lowerText = text.toLowerCase();
+  const lowerQuery = query.toLowerCase();
+  const segments: Array<HighlightSegment> = [];
+  let lastIndex = 0;
+
+  let matchIndex = lowerText.indexOf(lowerQuery, lastIndex);
+
+  while (matchIndex !== -1) {
+    if (matchIndex > lastIndex) {
+      segments.push({ highlight: false, text: text.slice(lastIndex, 
matchIndex) });
+    }
+    segments.push({ highlight: true, text: text.slice(matchIndex, matchIndex + 
query.length) });
+    lastIndex = matchIndex + query.length;
+    matchIndex = lowerText.indexOf(lowerQuery, lastIndex);
+  }
+
+  if (lastIndex < text.length) {
+    segments.push({ highlight: false, text: text.slice(lastIndex) });
+  }
+
+  if (segments.length === 0) {
+    return [{ highlight: false, text }];
+  }
+
+  return segments;
+};
 
 type VirtualizerInstance = Virtualizer<HTMLDivElement, Element>;
 
diff --git a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx 
b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
index 1a828967414..5c4862499d0 100644
--- a/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
+++ b/airflow-core/src/airflow/ui/src/queries/useLogs.tsx
@@ -16,7 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { chakra, Box } from "@chakra-ui/react";
 import type { UseQueryOptions } from "@tanstack/react-query";
 import dayjs from "dayjs";
 import type { TFunction } from "i18next";
@@ -31,10 +30,14 @@ import { isStatePending, useAutoRefresh } from "src/utils";
 import { getTaskInstanceLink } from "src/utils/links";
 import { parseStreamingLogContent } from "src/utils/logs";
 
+export type ParsedLogEntry = {
+  element: JSX.Element | string | undefined;
+  group?: { id: number; level: number; parentId?: number; type: "header" | 
"line" };
+};
+
 type Props = {
   accept?: "*/*" | "application/json" | "application/x-ndjson";
   dagId: string;
-  expanded?: boolean;
   limit?: number;
   logLevelFilters?: Array<string>;
   showSource?: boolean;
@@ -46,7 +49,6 @@ type Props = {
 
 type ParseLogsProps = {
   data: TaskInstancesLogResponse["content"];
-  expanded?: boolean;
   logLevelFilters?: Array<string>;
   showSource?: boolean;
   showTimestamp?: boolean;
@@ -58,7 +60,6 @@ type ParseLogsProps = {
 
 const parseLogs = ({
   data,
-  expanded,
   logLevelFilters,
   showSource,
   showTimestamp,
@@ -71,7 +72,6 @@ const parseLogs = ({
   let parsedLines;
   const sources: Array<string> = [];
 
-  const open = expanded ?? Boolean(globalThis.location.hash);
   const logLink = taskInstance ? 
`${getTaskInstanceLink(taskInstance)}?try_number=${tryNumber}` : "";
 
   try {
@@ -108,75 +108,56 @@ const parseLogs = ({
     return { data, warning };
   }
 
-  parsedLines = (() => {
-    type Group = { level: number; lines: Array<JSX.Element | "">; name: string 
};
+  const flatEntries: Array<ParsedLogEntry> = (() => {
+    type Group = { id: number; level: number; name: string };
     const groupStack: Array<Group> = [];
-    const result: Array<JSX.Element | ""> = [];
+    const result: Array<ParsedLogEntry> = [];
+    let nextGroupId = 0;
 
     parsedLines.forEach((line) => {
       const text = innerText(line);
 
       if (text.includes("::group::")) {
         const groupName = text.split("::group::")[1] as string;
+        const id = nextGroupId;
 
-        groupStack.push({ level: groupStack.length, lines: [], name: groupName 
});
+        nextGroupId += 1;
+        const level = groupStack.length;
+        const parentGroup = groupStack[groupStack.length - 1];
+
+        groupStack.push({ id, level, name: groupName });
+        result.push({
+          element: groupName,
+          group: { id, level, parentId: parentGroup?.id, type: "header" },
+        });
 
         return;
       }
 
       if (text.includes("::endgroup::")) {
-        const finishedGroup = groupStack.pop();
-
-        if (finishedGroup) {
-          const groupElement = (
-            <Box key={finishedGroup.name} mb={2} pl={finishedGroup.level * 2}>
-              <chakra.details open={open} w="100%">
-                <chakra.summary data-testid={`summary-${finishedGroup.name}`}>
-                  <chakra.span color="fg.info" cursor="pointer">
-                    {finishedGroup.name}
-                  </chakra.span>
-                </chakra.summary>
-                {finishedGroup.lines}
-              </chakra.details>
-            </Box>
-          );
-
-          const lastGroup = groupStack[groupStack.length - 1];
-
-          if (groupStack.length > 0 && lastGroup) {
-            lastGroup.lines.push(groupElement);
-          } else {
-            result.push(groupElement);
-          }
-        }
+        groupStack.pop();
 
         return;
       }
 
-      if (groupStack.length > 0 && groupStack[groupStack.length - 1]) {
-        groupStack[groupStack.length - 1]?.lines.push(line);
+      const currentGroup = groupStack[groupStack.length - 1];
+
+      if (groupStack.length > 0 && currentGroup) {
+        result.push({
+          element: line,
+          group: { id: currentGroup.id, level: currentGroup.level, type: 
"line" },
+        });
       } else {
-        result.push(line);
+        result.push({ element: line });
       }
     });
 
-    while (groupStack.length > 0) {
-      const unfinished = groupStack.pop();
-
-      if (unfinished) {
-        result.push(
-          <Box key={unfinished.name} mb={2} pl={unfinished.level * 2}>
-            {unfinished.lines}
-          </Box>,
-        );
-      }
-    }
-
+    // Handle unclosed groups: their lines are already in result as flat 
entries
     return result;
   })();
 
   return {
-    parsedLogs: parsedLines,
+    parsedLogs: flatEntries,
     sources,
     warning,
   };
@@ -203,7 +184,6 @@ export const useLogs = (
   {
     accept = "application/x-ndjson",
     dagId,
-    expanded,
     limit,
     logLevelFilters,
     showSource,
@@ -240,7 +220,6 @@ export const useLogs = (
 
   const parsedData = parseLogs({
     data: parseStreamingLogContent(truncateData(data, limit)),
-    expanded,
     logLevelFilters,
     showSource,
     showTimestamp,
@@ -250,5 +229,15 @@ export const useLogs = (
     tryNumber,
   });
 
-  return { parsedData, ...rest, fetchedData: data };
+  // Build a 1:1 searchable text array from parsedLogs so search indices align
+  // with the rendered output. Each entry maps to exactly one line.
+  const searchableText: Array<string> = (parsedData.parsedLogs ?? 
[]).map((entry) => {
+    if (typeof entry.element === "string") {
+      return entry.element;
+    }
+
+    return entry.element ? innerText(entry.element) : "";
+  });
+
+  return { parsedData: { ...parsedData, searchableText }, ...rest, 
fetchedData: data };
 };

Reply via email to