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

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new e5b07382047 Redo the gantt chart (#64335)
e5b07382047 is described below

commit e5b073820477cd6bd95a25273e210aa6cc595185
Author: Brent Bovenzi <[email protected]>
AuthorDate: Wed Apr 15 13:49:28 2026 -0400

    Redo the gantt chart (#64335)
    
    * New gantt chart
    
    * Fix scrollbox
    
    * fix tries
    
    * Address PR feedback
    
    * Fix tooltip
    
    * Hide queued/scheduled when less than 1s
    
    * Fix boxes for scheduled and queued
    
    * Clean up loops
    
    * Switch to px length for when to show queued/scheduled
---
 .../ui/src/components/TaskInstanceTooltip.tsx      |  25 +-
 .../Grid/DurationAxis.tsx => constants/dagView.ts} |   6 +-
 .../ui/src/layouts/Details/DetailsLayout.tsx       | 126 +++--
 .../airflow/ui/src/layouts/Details/Gantt/Gantt.tsx | 225 ++++----
 .../layouts/Details/Gantt/GanttTimeline.test.tsx   | 339 +++++++++++++
 .../ui/src/layouts/Details/Gantt/GanttTimeline.tsx | 407 +++++++++++++++
 .../ui/src/layouts/Details/Gantt/utils.test.ts     | 303 +++++------
 .../airflow/ui/src/layouts/Details/Gantt/utils.ts  | 564 ++++++++++-----------
 .../ui/src/layouts/Details/Grid/DurationAxis.tsx   |  10 +-
 .../airflow/ui/src/layouts/Details/Grid/Grid.tsx   | 216 ++++----
 .../layouts/Details/Grid/TaskInstancesColumn.tsx   |  18 +-
 .../ui/src/layouts/Details/Grid/TaskNames.tsx      |   3 +-
 .../ui/src/layouts/Details/Grid/constants.ts       |  13 +-
 .../Details/Grid/useGridRunsWithVersionFlags.ts    |  44 +-
 .../ui/src/layouts/Details/Grid/utils.test.ts      |  59 +++
 .../airflow/ui/src/layouts/Details/Grid/utils.ts   |  35 ++
 .../ui/src/layouts/Details/PanelButtons.tsx        |  67 ++-
 .../airflow/ui/src/pages/TaskInstance/Details.tsx  |  12 +
 18 files changed, 1662 insertions(+), 810 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx 
b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
index 8162c02a7c3..603b37bc56b 100644
--- a/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
+++ b/airflow-core/src/airflow/ui/src/components/TaskInstanceTooltip.tsx
@@ -28,8 +28,17 @@ import Time from "src/components/Time";
 import { Tooltip, type TooltipProps } from "src/components/ui";
 import { getDuration, renderDuration, sortStateEntries } from "src/utils";
 
+/** Grid summary plus optional schedule/queue hints (e.g. Gantt segment 
tooltips). */
+type LightGridTaskInstanceSummaryWithWhen = {
+  readonly queued_when?: string | null;
+  readonly scheduled_when?: string | null;
+} & LightGridTaskInstanceSummary;
+
 type Props = {
-  readonly taskInstance?: LightGridTaskInstanceSummary | 
TaskInstanceHistoryResponse | TaskInstanceResponse;
+  readonly taskInstance?:
+    | LightGridTaskInstanceSummaryWithWhen
+    | TaskInstanceHistoryResponse
+    | TaskInstanceResponse;
   readonly tooltip?: string | null;
 } & Omit<TooltipProps, "content">;
 
@@ -67,6 +76,20 @@ const TaskInstanceTooltip = ({ children, positioning, 
taskInstance, tooltip, ...
                   {translate("runId")}: {taskInstance.dag_run_id}
                 </Text>
               ) : undefined}
+              {"scheduled_when" in taskInstance &&
+              taskInstance.scheduled_when !== null &&
+              taskInstance.scheduled_when !== "" ? (
+                <Text>
+                  {translate("taskInstance.scheduledWhen")}: <Time 
datetime={taskInstance.scheduled_when} />
+                </Text>
+              ) : undefined}
+              {"queued_when" in taskInstance &&
+              taskInstance.queued_when !== null &&
+              taskInstance.queued_when !== "" ? (
+                <Text>
+                  {translate("taskInstance.queuedWhen")}: <Time 
datetime={taskInstance.queued_when} />
+                </Text>
+              ) : undefined}
               {"start_date" in taskInstance ? (
                 <>
                   {taskInstance.try_number > 1 && (
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx 
b/airflow-core/src/airflow/ui/src/constants/dagView.ts
similarity index 80%
copy from airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
copy to airflow-core/src/airflow/ui/src/constants/dagView.ts
index 0c798ab843a..6137c0423da 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
+++ b/airflow-core/src/airflow/ui/src/constants/dagView.ts
@@ -16,8 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, type BoxProps } from "@chakra-ui/react";
 
-export const DurationAxis = (props: BoxProps) => (
-  <Box borderBottomWidth={1} left={0} position="absolute" right={0} zIndex={0} 
{...props} />
-);
+/** The three available views for the main DAG panel. */
+export type DagView = "gantt" | "graph" | "grid";
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
index dacd6171f5d..450e9cd73c6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/DetailsLayout.tsx
@@ -18,10 +18,10 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, HStack, Flex, useDisclosure, IconButton } from 
"@chakra-ui/react";
+import { Box, Flex, HStack, IconButton, useDisclosure } from 
"@chakra-ui/react";
 import { useReactFlow } from "@xyflow/react";
-import { useRef, useState } from "react";
-import type { PropsWithChildren, ReactNode } from "react";
+import { useEffect, useRef, useState } from "react";
+import type { PropsWithChildren, ReactNode, RefObject } from "react";
 import { useTranslation } from "react-i18next";
 import { FaChevronLeft, FaChevronRight } from "react-icons/fa";
 import { LuFileWarning } from "react-icons/lu";
@@ -43,6 +43,7 @@ import { TriggerDAGButton } from 
"src/components/TriggerDag/TriggerDAGButton";
 import { ProgressBar } from "src/components/ui";
 import { Toaster } from "src/components/ui";
 import { Tooltip } from "src/components/ui/Tooltip";
+import type { DagView } from "src/constants/dagView";
 import {
   dagRunsLimitKey,
   dagRunStateFilterKey,
@@ -51,11 +52,10 @@ import {
   runAfterGteKey,
   runAfterLteKey,
   runTypeFilterKey,
-  showGanttKey,
   triggeringUserFilterKey,
 } from "src/constants/localStorage";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
-import { HoverProvider } from "src/context/hover";
+import { HoverProvider, useHover } from "src/context/hover";
 import { OpenGroupsProvider } from "src/context/openGroups";
 
 import { DagBreadcrumb } from "./DagBreadcrumb";
@@ -65,6 +65,33 @@ import { Grid } from "./Grid";
 import { NavTabs } from "./NavTabs";
 import { PanelButtons } from "./PanelButtons";
 
+// Separate component so useHover can be called inside HoverProvider.
+const SharedScrollBox = ({
+  children,
+  scrollRef,
+}: {
+  readonly children: ReactNode;
+  readonly scrollRef: RefObject<HTMLDivElement | null>;
+}) => {
+  const { setHoveredTaskId } = useHover();
+
+  return (
+    <Box
+      height="100%"
+      minH={0}
+      minW={0}
+      onMouseLeave={() => setHoveredTaskId(undefined)}
+      overflowX="hidden"
+      overflowY="auto"
+      ref={scrollRef}
+      style={{ scrollbarGutter: "stable" }}
+      w="100%"
+    >
+      {children}
+    </Box>
+  );
+};
+
 type Props = {
   readonly error?: unknown;
   readonly isLoading?: boolean;
@@ -77,7 +104,7 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
   const { data: dag } = useDagServiceGetDag({ dagId });
   const [defaultDagView] = useLocalStorage<"graph" | 
"grid">(DEFAULT_DAG_VIEW_KEY, "grid");
   const panelGroupRef = useRef<ImperativePanelGroupHandle | null>(null);
-  const [dagView, setDagView] = useLocalStorage<"graph" | 
"grid">(dagViewKey(dagId), defaultDagView);
+  const [dagView, setDagView] = useLocalStorage<DagView>(dagViewKey(dagId), 
defaultDagView);
   const [limit, setLimit] = useLocalStorage(dagRunsLimitKey(dagId), 10);
   const [runAfterGte, setRunAfterGte] = useLocalStorage<string | 
undefined>(runAfterGteKey(dagId), undefined);
   const [runAfterLte, setRunAfterLte] = useLocalStorage<string | 
undefined>(runAfterLteKey(dagId), undefined);
@@ -94,18 +121,28 @@ export const DetailsLayout = ({ children, error, 
isLoading, tabs }: Props) => {
     undefined,
   );
 
-  const [showGantt, setShowGantt] = useLocalStorage(showGanttKey(dagId), 
false);
   // Global setting: applies to all Dags (intentionally not scoped to dagId)
   const [showVersionIndicatorMode, setShowVersionIndicatorMode] = 
useLocalStorage(
     `version_indicator_display_mode`,
     VersionIndicatorOptions.ALL,
   );
+
+  // Reset to grid when there is no runId. Remove this when we do gantt 
averages
+  useEffect(() => {
+    if (!Boolean(runId) && dagView === "gantt") {
+      setDagView("grid");
+    }
+  }, [runId, dagView, setDagView]);
+
   const { fitView, getZoom } = useReactFlow();
   const { data: warningData } = useDagWarningServiceListDagWarnings({ dagId });
   const { onClose, onOpen, open } = useDisclosure();
   const [isRightPanelCollapsed, setIsRightPanelCollapsed] = useState(false);
   const { i18n } = useTranslation();
   const direction = i18n.dir();
+  const sharedGridGanttScrollRef = useRef<HTMLDivElement | null>(null);
+  // Treat "gantt" as "grid" for panel layout persistence so switching between 
them doesn't reset sizes.
+  const panelViewKey = dagView === "gantt" ? "grid" : dagView;
 
   return (
     <HoverProvider>
@@ -149,19 +186,19 @@ export const DetailsLayout = ({ children, error, 
isLoading, tabs }: Props) => {
             </Tooltip>
           ) : undefined}
           <PanelGroup
-            autoSaveId={`${dagView}-${direction}`}
+            autoSaveId={`${panelViewKey}-${direction}`}
             dir={direction}
             direction="horizontal"
-            key={`${dagView}-${direction}`}
+            key={`${panelViewKey}-${direction}`}
             ref={panelGroupRef}
           >
             <Panel
               defaultSize={dagView === "graph" ? 70 : 20}
               id="main-panel"
-              minSize={showGantt && dagView === "grid" && Boolean(runId) ? 35 
: 6}
+              minSize={dagView === "gantt" && Boolean(runId) ? 35 : 6}
               order={1}
             >
-              <Box height="100%" position="relative">
+              <Flex flexDirection="column" height="100%">
                 <PanelButtons
                   dagRunStateFilter={dagRunStateFilter}
                   dagView={dagView}
@@ -176,40 +213,62 @@ export const DetailsLayout = ({ children, error, 
isLoading, tabs }: Props) => {
                   setRunAfterGte={setRunAfterGte}
                   setRunAfterLte={setRunAfterLte}
                   setRunTypeFilter={setRunTypeFilter}
-                  setShowGantt={setShowGantt}
                   setShowVersionIndicatorMode={setShowVersionIndicatorMode}
                   setTriggeringUserFilter={setTriggeringUserFilter}
-                  showGantt={showGantt}
                   showVersionIndicatorMode={showVersionIndicatorMode}
                   triggeringUserFilter={triggeringUserFilter}
                 />
-                {dagView === "graph" ? (
-                  <Graph />
-                ) : (
-                  <HStack alignItems="flex-start" gap={0}>
-                    <Grid
-                      dagRunState={dagRunStateFilter}
-                      limit={limit}
-                      runAfterGte={runAfterGte}
-                      runAfterLte={runAfterLte}
-                      runType={runTypeFilter}
-                      showGantt={Boolean(runId) && showGantt}
-                      showVersionIndicatorMode={showVersionIndicatorMode}
-                      triggeringUser={triggeringUserFilter}
-                    />
-                    {showGantt ? (
-                      <Gantt
+                <Box flex={1} minH={0} overflow="hidden">
+                  {dagView === "graph" ? (
+                    <Graph />
+                  ) : dagView === "gantt" && Boolean(runId) ? (
+                    <SharedScrollBox scrollRef={sharedGridGanttScrollRef}>
+                      <Flex alignItems="flex-start" gap={0} maxW="100%" 
minW={0} overflow="clip" w="100%">
+                        <Grid
+                          dagRunState={dagRunStateFilter}
+                          limit={limit}
+                          runAfterGte={runAfterGte}
+                          runAfterLte={runAfterLte}
+                          runType={runTypeFilter}
+                          sharedScrollContainerRef={sharedGridGanttScrollRef}
+                          showGantt
+                          showVersionIndicatorMode={showVersionIndicatorMode}
+                          triggeringUser={triggeringUserFilter}
+                        />
+                        <Gantt
+                          dagRunState={dagRunStateFilter}
+                          limit={limit}
+                          runAfterGte={runAfterGte}
+                          runAfterLte={runAfterLte}
+                          runType={runTypeFilter}
+                          sharedScrollContainerRef={sharedGridGanttScrollRef}
+                          triggeringUser={triggeringUserFilter}
+                        />
+                      </Flex>
+                    </SharedScrollBox>
+                  ) : (
+                    <HStack
+                      alignItems="flex-start"
+                      gap={0}
+                      height="100%"
+                      maxW="100%"
+                      minW={0}
+                      overflow="hidden"
+                      w="100%"
+                    >
+                      <Grid
                         dagRunState={dagRunStateFilter}
                         limit={limit}
                         runAfterGte={runAfterGte}
                         runAfterLte={runAfterLte}
                         runType={runTypeFilter}
+                        showVersionIndicatorMode={showVersionIndicatorMode}
                         triggeringUser={triggeringUserFilter}
                       />
-                    ) : undefined}
-                  </HStack>
-                )}
-              </Box>
+                    </HStack>
+                  )}
+                </Box>
+              </Flex>
             </Panel>
             {!isRightPanelCollapsed && (
               <>
@@ -232,7 +291,6 @@ export const DetailsLayout = ({ children, error, isLoading, 
tabs }: Props) => {
                     justifyContent="center"
                     position="relative"
                     w={0.5}
-                    // onClick={(e) => console.log(e)}
                   />
                 </PanelResizeHandle>
 
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
index 62de32cdb98..069976239a6 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/Gantt.tsx
@@ -16,57 +16,32 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, useToken } from "@chakra-ui/react";
-import {
-  Chart as ChartJS,
-  CategoryScale,
-  LinearScale,
-  PointElement,
-  LineElement,
-  BarElement,
-  Filler,
-  Title,
-  Tooltip,
-  Legend,
-  TimeScale,
-} from "chart.js";
-import "chart.js/auto";
-import "chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm";
-import annotationPlugin from "chartjs-plugin-annotation";
-import { useDeferredValue } from "react";
-import { Bar } from "react-chartjs-2";
-import { useTranslation } from "react-i18next";
-import { useParams, useNavigate, useLocation, useSearchParams } from 
"react-router-dom";
+import { Box, Flex } from "@chakra-ui/react";
+import { useRef } from "react";
+import type { RefObject } from "react";
+import { useParams, useSearchParams } from "react-router-dom";
 
 import { useGanttServiceGetGanttData } from "openapi/queries";
 import type { DagRunState, DagRunType } from "openapi/requests/types.gen";
-import { useColorMode } from "src/context/colorMode";
 import { useHover } from "src/context/hover";
 import { useOpenGroups } from "src/context/openGroups";
 import { useTimezone } from "src/context/timezone";
-import { GRID_BODY_OFFSET_PX } from "src/layouts/Details/Grid/constants";
+import { NavigationModes, useNavigation } from "src/hooks/navigation";
+import {
+  GANTT_AXIS_HEIGHT_PX,
+  GANTT_ROW_OFFSET_PX,
+  GANTT_TOP_PADDING_PX,
+} from "src/layouts/Details/Grid/constants";
 import { flattenNodes } from "src/layouts/Details/Grid/utils";
 import { useGridRuns } from "src/queries/useGridRuns";
 import { useGridStructure } from "src/queries/useGridStructure";
 import { useGridTiSummariesStream } from "src/queries/useGridTISummaries";
-import { getComputedCSSVariableValue } from "src/theme";
 import { isStatePending, useAutoRefresh } from "src/utils";
 
-import { createHandleBarClick, createHandleBarHover, createChartOptions, 
transformGanttData } from "./utils";
-
-ChartJS.register(
-  CategoryScale,
-  LinearScale,
-  PointElement,
-  BarElement,
-  LineElement,
-  Filler,
-  Title,
-  Tooltip,
-  Legend,
-  annotationPlugin,
-  TimeScale,
-);
+import { GanttTimeline } from "./GanttTimeline";
+import { buildGanttRowSegments, computeGanttTimeRangeMs, transformGanttData } 
from "./utils";
+
+const GANTT_STANDALONE_VIRTUALIZER_PADDING_START_PX = GANTT_TOP_PADDING_PX + 
GANTT_AXIS_HEIGHT_PX;
 
 type Props = {
   readonly dagRunState?: DagRunState | undefined;
@@ -74,24 +49,29 @@ type Props = {
   readonly runAfterGte?: string | undefined;
   readonly runAfterLte?: string | undefined;
   readonly runType?: DagRunType | undefined;
+  readonly sharedScrollContainerRef?: RefObject<HTMLDivElement | null>;
   readonly triggeringUser?: string | undefined;
 };
 
-const CHART_PADDING = 36;
-const CHART_ROW_HEIGHT = 20;
-const MIN_BAR_WIDTH = 10;
-
-export const Gantt = ({ dagRunState, limit, runAfterGte, runAfterLte, runType, 
triggeringUser }: Props) => {
-  const { dagId = "", groupId: selectedGroupId, runId = "", taskId: 
selectedTaskId } = useParams();
+export const Gantt = ({
+  dagRunState,
+  limit,
+  runAfterGte,
+  runAfterLte,
+  runType,
+  sharedScrollContainerRef,
+  triggeringUser,
+}: Props) => {
+  const standaloneScrollRef = useRef<HTMLDivElement | null>(null);
+  const usesSharedScroll = sharedScrollContainerRef !== undefined;
+
+  const scrollContainerRef = usesSharedScroll ? sharedScrollContainerRef : 
standaloneScrollRef;
+
+  const { dagId = "", runId = "" } = useParams();
   const [searchParams] = useSearchParams();
-  const { openGroupIds } = useOpenGroups();
-  const deferredOpenGroupIds = useDeferredValue(openGroupIds);
-  const { t: translate } = useTranslation("common");
+  const { openGroupIds, toggleGroupId } = useOpenGroups();
   const { selectedTimezone } = useTimezone();
-  const { colorMode } = useColorMode();
-  const { hoveredTaskId, setHoveredTaskId } = useHover();
-  const navigate = useNavigate();
-  const location = useLocation();
+  const { setHoveredTaskId } = useHover();
 
   const filterRoot = searchParams.get("root") ?? undefined;
   const includeUpstream = searchParams.get("upstream") === "true";
@@ -99,20 +79,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte, 
runAfterLte, runType, t
   const depthParam = searchParams.get("depth");
   const depth = depthParam !== null && depthParam !== "" ? 
parseInt(depthParam, 10) : undefined;
 
-  // Corresponds to border, brand.emphasized, and brand.muted
-  const [
-    lightGridColor,
-    darkGridColor,
-    lightSelectedColor,
-    darkSelectedColor,
-    lightHoverColor,
-    darkHoverColor,
-  ] = useToken("colors", ["gray.200", "gray.800", "brand.300", "brand.700", 
"brand.200", "brand.800"]);
-
-  const gridColor = colorMode === "light" ? lightGridColor : darkGridColor;
-  const selectedItemColor = colorMode === "light" ? lightSelectedColor : 
darkSelectedColor;
-  const hoveredItemColor = colorMode === "light" ? lightHoverColor : 
darkHoverColor;
-
   const { data: gridRuns, isLoading: runsLoading } = useGridRuns({
     dagRunState,
     limit,
@@ -134,7 +100,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte, 
runAfterLte, runType, t
   const selectedRun = gridRuns?.find((run) => run.run_id === runId);
   const refetchInterval = useAutoRefresh({ dagId });
 
-  // Get grid summaries for groups and mapped tasks (which have min/max times)
   const { summariesByRunId } = useGridTiSummariesStream({
     dagId,
     runIds: runId && selectedRun ? [runId] : [],
@@ -143,7 +108,6 @@ export const Gantt = ({ dagRunState, limit, runAfterGte, 
runAfterLte, runType, t
   const gridTiSummaries = summariesByRunId.get(runId);
   const summariesLoading = Boolean(runId && selectedRun && 
!summariesByRunId.has(runId));
 
-  // Single fetch for all Gantt data (individual task tries)
   const { data: ganttData, isLoading: ganttLoading } = 
useGanttServiceGetGanttData(
     { dagId, runId },
     undefined,
@@ -154,95 +118,80 @@ export const Gantt = ({ dagRunState, limit, runAfterGte, 
runAfterLte, runType, t
     },
   );
 
-  const { flatNodes } = flattenNodes(dagStructure, deferredOpenGroupIds);
+  const { flatNodes } = flattenNodes(dagStructure, openGroupIds);
+
+  const { setMode } = useNavigation({
+    onToggleGroup: toggleGroupId,
+    runs: gridRuns ?? [],
+    tasks: flatNodes,
+  });
 
   const isLoading = runsLoading || structureLoading || summariesLoading || 
ganttLoading;
 
   const allTries = ganttData?.task_instances ?? [];
   const gridSummaries = gridTiSummaries?.task_instances ?? [];
 
-  const data = isLoading || runId === "" ? [] : transformGanttData({ allTries, 
flatNodes, gridSummaries });
-
-  const labels = flatNodes.map((node) => node.id);
+  const ganttDataItems =
+    isLoading || runId === "" ? [] : transformGanttData({ allTries, flatNodes, 
gridSummaries });
 
-  // Get all unique states and their colors
-  const states = [...new Set(data.map((item) => item.state ?? "none"))];
-  const stateColorTokens = useToken(
-    "colors",
-    states.map((state) => `${state}.solid`),
-  );
-  const stateColorMap = Object.fromEntries(
-    states.map((state, index) => [
-      state,
-      getComputedCSSVariableValue(stateColorTokens[index] ?? "oklch(0.5 0 0)"),
-    ]),
-  );
+  const rowSegments = buildGanttRowSegments(flatNodes, ganttDataItems);
 
-  const chartData = {
-    datasets: [
-      {
-        backgroundColor: data.map((dataItem) => stateColorMap[dataItem.state 
?? "none"]),
-        data: Boolean(selectedRun) ? data : [],
-        maxBarThickness: CHART_ROW_HEIGHT,
-        minBarLength: MIN_BAR_WIDTH,
-      },
-    ],
-    labels,
-  };
-
-  const fixedHeight = flatNodes.length * CHART_ROW_HEIGHT + CHART_PADDING;
-  const selectedId = selectedTaskId ?? selectedGroupId;
-
-  const handleBarClick = createHandleBarClick({ dagId, data, location, 
navigate, runId });
-
-  const handleBarHover = createHandleBarHover(data, setHoveredTaskId);
-
-  const chartOptions = createChartOptions({
-    data,
-    gridColor,
-    handleBarClick,
-    handleBarHover,
-    hoveredId: hoveredTaskId,
-    hoveredItemColor,
-    labels,
-    selectedId,
-    selectedItemColor,
+  const { maxMs, minMs } = computeGanttTimeRangeMs({
+    ganttItems: ganttDataItems,
     selectedRun,
     selectedTimezone,
-    translate,
   });
 
+  const virtualizerScrollPaddingStart = usesSharedScroll
+    ? GANTT_ROW_OFFSET_PX
+    : GANTT_STANDALONE_VIRTUALIZER_PADDING_START_PX;
+
   if (runId === "" || (!isLoading && !selectedRun)) {
     return undefined;
   }
 
-  const handleChartMouseLeave = () => {
+  const handleStandaloneMouseLeave = () => {
     setHoveredTaskId(undefined);
-
-    // Clear all hover styles when mouse leaves the chart area
-    const allTasks = document.querySelectorAll<HTMLDivElement>('[id*="-"]');
-
-    allTasks.forEach((task) => {
-      task.style.backgroundColor = "";
-    });
   };
 
-  return (
-    <Box
-      height={`${fixedHeight}px`}
-      minW="250px"
-      ml={-2}
-      mt={`${GRID_BODY_OFFSET_PX}px`}
-      onMouseLeave={handleChartMouseLeave}
-      w="100%"
-    >
-      <Bar
-        data={chartData}
-        options={chartOptions}
-        style={{
-          paddingTop: flatNodes.length === 1 ? 15 : 1.5,
-        }}
+  const timeline =
+    Boolean(selectedRun) && dagId ? (
+      <GanttTimeline
+        dagId={dagId}
+        flatNodes={flatNodes}
+        ganttDataItems={ganttDataItems}
+        gridSummaries={gridSummaries}
+        maxMs={maxMs}
+        minMs={minMs}
+        onSegmentClick={() => setMode(NavigationModes.TI)}
+        rowSegments={rowSegments}
+        runId={runId}
+        scrollContainerRef={scrollContainerRef}
+        virtualizerScrollPaddingStart={virtualizerScrollPaddingStart}
       />
-    </Box>
+    ) : undefined;
+
+  if (usesSharedScroll) {
+    return (
+      <Flex flex={1} flexDirection="column" maxW="100%" minW={0} 
overflow="clip" pt={0}>
+        {timeline}
+      </Flex>
+    );
+  }
+
+  return (
+    <Flex flex={1} flexDirection="column" maxW="100%" minH={0} minW={0} 
overflow="hidden">
+      <Box
+        flex={1}
+        minH={0}
+        minW={0}
+        onMouseLeave={handleStandaloneMouseLeave}
+        overflowX="hidden"
+        overflowY="auto"
+        ref={standaloneScrollRef}
+      >
+        {timeline}
+      </Box>
+    </Flex>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
new file mode 100644
index 00000000000..ad492249978
--- /dev/null
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.test.tsx
@@ -0,0 +1,339 @@
+/* eslint-disable max-lines */
+
+/* eslint-disable unicorn/no-null */
+
+/*!
+ * 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 { act, render, screen } from "@testing-library/react";
+import type { PropsWithChildren, RefObject } from "react";
+import { createRef } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { HoverProvider } from "src/context/hover";
+import { ROW_HEIGHT } from "src/layouts/Details/Grid/constants";
+import type { GridTask } from "src/layouts/Details/Grid/utils";
+import { Wrapper } from "src/utils/Wrapper";
+
+import { GanttTimeline } from "./GanttTimeline";
+import type { GanttDataItem } from "./utils";
+
+// Mock the virtualizer so all items render regardless of scroll-container 
dimensions
+// (happy-dom does not perform layout, so getScrollElement always has height 
0).
+vi.mock("@tanstack/react-virtual", () => ({
+  useVirtualizer: ({ count, estimateSize }: { count: number; estimateSize: () 
=> number }) => ({
+    getTotalSize: () => count * estimateSize(),
+    getVirtualItems: () =>
+      Array.from({ length: count }, (_, index) => ({
+        index,
+        key: String(index),
+        lane: 0,
+        size: estimateSize(),
+        start: index * estimateSize(),
+      })),
+  }),
+}));
+
+const TestWrapper = ({ children }: PropsWithChildren) => (
+  <Wrapper>
+    <HoverProvider>{children}</HoverProvider>
+  </Wrapper>
+);
+
+// Shared time range: 10:00 → 10:10 UTC on 2024-03-14
+const MIN_MS = new Date("2024-03-14T10:00:00Z").getTime();
+const MAX_MS = new Date("2024-03-14T10:10:00Z").getTime();
+
+const BASE_NODE: GridTask = {
+  depth: 0,
+  id: "task_1",
+  is_mapped: false,
+  label: "task_1",
+} as GridTask;
+
+const makeScrollRef = (): RefObject<HTMLDivElement | null> => {
+  const ref = createRef<HTMLDivElement | null>();
+
+  (ref as { current: HTMLDivElement | null }).current = 
document.createElement("div");
+
+  return ref;
+};
+
+const defaultProps = {
+  dagId: "test_dag",
+  flatNodes: [BASE_NODE],
+  ganttDataItems: [] as Array<GanttDataItem>,
+  gridSummaries: [],
+  maxMs: MAX_MS,
+  minMs: MIN_MS,
+  rowSegments: [[]] as Array<Array<GanttDataItem>>,
+  runId: "run_1",
+  virtualizerScrollPaddingStart: ROW_HEIGHT,
+};
+
+describe("GanttTimeline segment bars", () => {
+  afterEach(() => {
+    vi.unstubAllGlobals();
+  });
+
+  it("renders a single execution bar when only start_date is present", () => {
+    const executionSegment: GanttDataItem = {
+      queued_when: null,
+      scheduled_when: null,
+      state: "success",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T10:00:00Z").getTime(), new 
Date("2024-03-14T10:05:00Z").getTime()],
+      y: "task_1",
+    };
+
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        ganttDataItems={[executionSegment]}
+        rowSegments={[[executionSegment]]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    // One link per bar — only the execution bar should appear
+    expect(screen.getAllByRole("link")).toHaveLength(1);
+  });
+
+  it("renders two bars (queued + execution) when queued_dttm is present", () 
=> {
+    const queuedSegment: GanttDataItem = {
+      queued_when: "2024-03-14T09:59:00Z",
+      scheduled_when: null,
+      state: "queued",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T09:59:00Z").getTime(), new 
Date("2024-03-14T10:00:00Z").getTime()],
+      y: "task_1",
+    };
+    const executionSegment: GanttDataItem = {
+      queued_when: "2024-03-14T09:59:00Z",
+      scheduled_when: null,
+      state: "success",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T10:00:00Z").getTime(), new 
Date("2024-03-14T10:05:00Z").getTime()],
+      y: "task_1",
+    };
+    const segments = [queuedSegment, executionSegment];
+
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        ganttDataItems={segments}
+        rowSegments={[segments]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    expect(screen.getAllByRole("link")).toHaveLength(2);
+  });
+
+  it("renders three bars (scheduled + queued + execution) when both 
scheduled_dttm and queued_dttm are present", () => {
+    const scheduledSegment: GanttDataItem = {
+      queued_when: "2024-03-14T09:59:00Z",
+      scheduled_when: "2024-03-14T09:58:00Z",
+      state: "scheduled",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T09:58:00Z").getTime(), new 
Date("2024-03-14T09:59:00Z").getTime()],
+      y: "task_1",
+    };
+    const queuedSegment: GanttDataItem = {
+      queued_when: "2024-03-14T09:59:00Z",
+      scheduled_when: "2024-03-14T09:58:00Z",
+      state: "queued",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T09:59:00Z").getTime(), new 
Date("2024-03-14T10:00:00Z").getTime()],
+      y: "task_1",
+    };
+    const executionSegment: GanttDataItem = {
+      queued_when: "2024-03-14T09:59:00Z",
+      scheduled_when: "2024-03-14T09:58:00Z",
+      state: "success",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T10:00:00Z").getTime(), new 
Date("2024-03-14T10:05:00Z").getTime()],
+      y: "task_1",
+    };
+    const segments = [scheduledSegment, queuedSegment, executionSegment];
+
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        ganttDataItems={segments}
+        rowSegments={[segments]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    expect(screen.getAllByRole("link")).toHaveLength(3);
+  });
+
+  it("renders bars for multiple tasks independently", () => {
+    const node2: GridTask = { depth: 0, id: "task_2", is_mapped: false, label: 
"task_2" } as GridTask;
+
+    // task_1: scheduled + execution (2 bars)
+    const t1Scheduled: GanttDataItem = {
+      queued_when: null,
+      scheduled_when: "2024-03-14T09:58:00Z",
+      state: "scheduled",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T09:58:00Z").getTime(), new 
Date("2024-03-14T10:00:00Z").getTime()],
+      y: "task_1",
+    };
+    const t1Exec: GanttDataItem = {
+      queued_when: null,
+      scheduled_when: "2024-03-14T09:58:00Z",
+      state: "failed",
+      taskId: "task_1",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T10:00:00Z").getTime(), new 
Date("2024-03-14T10:03:00Z").getTime()],
+      y: "task_1",
+    };
+
+    // task_2: execution only (1 bar)
+    const t2Exec: GanttDataItem = {
+      queued_when: null,
+      scheduled_when: null,
+      state: "success",
+      taskId: "task_2",
+      tryNumber: 1,
+      x: [new Date("2024-03-14T10:04:00Z").getTime(), new 
Date("2024-03-14T10:08:00Z").getTime()],
+      y: "task_2",
+    };
+
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        flatNodes={[BASE_NODE, node2]}
+        ganttDataItems={[t1Scheduled, t1Exec, t2Exec]}
+        rowSegments={[[t1Scheduled, t1Exec], [t2Exec]]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    // 2 bars for task_1 + 1 bar for task_2 = 3 total
+    expect(screen.getAllByRole("link")).toHaveLength(3);
+  });
+
+  it("renders no bars when rowSegments is empty", () => {
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        flatNodes={[BASE_NODE]}
+        ganttDataItems={[]}
+        rowSegments={[[]]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    expect(screen.queryAllByRole("link")).toHaveLength(0);
+  });
+
+  it("hides scheduled and queued bars whose pixel width is below 
MIN_SEGMENT_RENDER_PX (5 px)", () => {
+    // Simulate a 1000 px-wide Gantt body via a ResizeObserver mock.
+    // spanMs = MAX_MS − MIN_MS = 600 000 ms (10 min)
+    // A 2-second bar → (2 000 / 600 000) * 1 000 ≈ 3.33 px  <  5 px threshold 
→ hidden
+    // A 5-minute execution bar → (300 000 / 600 000) * 1 000 = 500 px → 
visible
+
+    let observerCallback: ResizeObserverCallback | undefined;
+
+    vi.stubGlobal(
+      "ResizeObserver",
+      class MockResizeObserver {
+        public constructor(cb: ResizeObserverCallback) {
+          observerCallback = cb;
+        }
+        // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+        public disconnect() {
+          /* empty */
+        }
+        // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+        public observe() {
+          /* empty */
+        }
+        // eslint-disable-next-line @typescript-eslint/class-methods-use-this
+        public unobserve() {
+          /* empty */
+        }
+      },
+    );
+
+    const scheduledSegment: GanttDataItem = {
+      queued_when: new Date(MIN_MS + 2000).toISOString(),
+      scheduled_when: new Date(MIN_MS).toISOString(),
+      state: "scheduled",
+      taskId: "task_1",
+      tryNumber: 1,
+      // 2 s wide — below the 5 px threshold at 1000 px viewport
+      x: [MIN_MS, MIN_MS + 2000],
+      y: "task_1",
+    };
+    const queuedSegment: GanttDataItem = {
+      queued_when: new Date(MIN_MS + 2000).toISOString(),
+      scheduled_when: new Date(MIN_MS).toISOString(),
+      state: "queued",
+      taskId: "task_1",
+      tryNumber: 1,
+      // 2 s wide — below the 5 px threshold at 1000 px viewport
+      x: [MIN_MS + 2000, MIN_MS + 4000],
+      y: "task_1",
+    };
+    const executionSegment: GanttDataItem = {
+      queued_when: new Date(MIN_MS + 2000).toISOString(),
+      scheduled_when: new Date(MIN_MS).toISOString(),
+      state: "success",
+      taskId: "task_1",
+      tryNumber: 1,
+      // 5 min wide — well above threshold
+      x: [MIN_MS + 4000, MIN_MS + 304_000],
+      y: "task_1",
+    };
+    const segments = [scheduledSegment, queuedSegment, executionSegment];
+
+    render(
+      <GanttTimeline
+        {...defaultProps}
+        ganttDataItems={segments}
+        rowSegments={[segments]}
+        scrollContainerRef={makeScrollRef()}
+      />,
+      { wrapper: TestWrapper },
+    );
+
+    // Fire the ResizeObserver callback to set bodyWidthPx = 1000, enabling 
the filter.
+    act(() => {
+      observerCallback?.([{ contentRect: { width: 1000 } } as 
ResizeObserverEntry], {} as ResizeObserver);
+    });
+
+    // Only the execution bar should be rendered; scheduled and queued are too 
narrow.
+    expect(screen.getAllByRole("link")).toHaveLength(1);
+  });
+});
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
new file mode 100644
index 00000000000..fd5949e070f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/GanttTimeline.tsx
@@ -0,0 +1,407 @@
+/*!
+ * 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.
+ */
+
+/* eslint-disable max-lines -- virtualized Gantt body markup is kept in one 
file for readability */
+import { Badge, Box, Flex, Text } from "@chakra-ui/react";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import dayjs from "dayjs";
+import type { RefObject } from "react";
+import { Fragment, useLayoutEffect, useRef, useState } from "react";
+import { Link, useLocation, useParams } from "react-router-dom";
+
+import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import { StateIcon } from "src/components/StateIcon";
+import TaskInstanceTooltip from "src/components/TaskInstanceTooltip";
+import { useHover } from "src/context/hover";
+import {
+  GANTT_AXIS_HEIGHT_PX,
+  GANTT_TOP_PADDING_PX,
+  ROW_HEIGHT,
+  TASK_BAR_HEIGHT_PX,
+} from "src/layouts/Details/Grid/constants";
+import type { GridTask } from "src/layouts/Details/Grid/utils";
+
+import {
+  type GanttDataItem,
+  GANTT_TIME_AXIS_TICK_COUNT,
+  buildGanttTimeAxisTicks,
+  buildMaxTryByTaskId,
+  getGanttSegmentTo,
+  gridSummariesToTaskIdMap,
+} from "./utils";
+
+/** Size of the state icon rendered inside each Gantt bar (px). The minimum 
bar width is derived
+ *  from this value so the icon is never clipped when a task has a very short 
duration. */
+const GANTT_STATE_ICON_SIZE_PX = 10;
+const MIN_BAR_WIDTH_PX = GANTT_STATE_ICON_SIZE_PX;
+
+/** Scheduled and queued bars narrower than this are not rendered — they would 
be invisible anyway
+ *  and their presence would suppress the rounded left edge of the adjacent 
execution bar. */
+const MIN_SEGMENT_RENDER_PX = 5;
+
+/** Minimum horizontal gap (px) between time-axis labels before one is 
dropped. */
+const MIN_TICK_SPACING_PX = 80;
+
+/** Short mark above the axis bottom border, aligned with each timestamp. */
+const GANTT_AXIS_TICK_HEIGHT_PX = 6;
+
+type Props = {
+  readonly dagId: string;
+  readonly flatNodes: Array<GridTask>;
+  readonly ganttDataItems: Array<GanttDataItem>;
+  readonly gridSummaries: Array<LightGridTaskInstanceSummary>;
+  readonly maxMs: number;
+  readonly minMs: number;
+  readonly onSegmentClick?: () => void;
+  readonly rowSegments: Array<Array<GanttDataItem>>;
+  readonly runId: string;
+  readonly scrollContainerRef: RefObject<HTMLDivElement | null>;
+  /** scrollPaddingStart for @tanstack/react-virtual (116 standalone, 180 with 
shared outer padding). */
+  readonly virtualizerScrollPaddingStart: number;
+};
+
+const toTooltipSummary = (
+  segment: GanttDataItem,
+  node: GridTask,
+  gridSummary: LightGridTaskInstanceSummary | undefined,
+) => {
+  if (gridSummary !== undefined && (node.isGroup ?? node.is_mapped)) {
+    return gridSummary;
+  }
+
+  return {
+    // eslint-disable-next-line unicorn/no-null
+    child_states: null,
+    max_end_date: dayjs(segment.x[1]).toISOString(),
+    min_start_date: dayjs(segment.x[0]).toISOString(),
+    // eslint-disable-next-line unicorn/no-null
+    state: segment.state ?? null,
+    task_display_name: segment.y,
+    task_id: segment.taskId,
+    try_number: segment.tryNumber,
+    ...(segment.tryNumber === undefined
+      ? {}
+      : {
+          queued_when: segment.queued_when,
+          scheduled_when: segment.scheduled_when,
+        }),
+  };
+};
+
+export const GanttTimeline = ({
+  dagId,
+  flatNodes,
+  ganttDataItems,
+  gridSummaries,
+  maxMs,
+  minMs,
+  onSegmentClick,
+  rowSegments,
+  runId,
+  scrollContainerRef,
+  virtualizerScrollPaddingStart,
+}: Props) => {
+  const location = useLocation();
+  const { groupId: selectedGroupId, taskId: selectedTaskId } = useParams();
+  const { hoveredTaskId, setHoveredTaskId } = useHover();
+  const [bodyWidthPx, setBodyWidthPx] = useState(0);
+  const bodyRef = useRef<HTMLDivElement | null>(null);
+
+  useLayoutEffect(() => {
+    const el = bodyRef.current;
+
+    if (el === null) {
+      return undefined;
+    }
+
+    const ro = new ResizeObserver((entries) => {
+      const nextWidth = entries[0]?.contentRect.width;
+
+      if (nextWidth !== undefined) {
+        setBodyWidthPx(nextWidth);
+      }
+    });
+
+    ro.observe(el);
+
+    return () => {
+      ro.disconnect();
+    };
+  }, []);
+
+  const summaryByTaskId = gridSummariesToTaskIdMap(gridSummaries);
+  // Precompute max try per task once (O(n)) so getGanttSegmentTo can do O(1) 
lookups.
+  const maxTryByTaskId = buildMaxTryByTaskId(ganttDataItems);
+  const spanMs = Math.max(1, maxMs - minMs);
+
+  // Derive tick count from available width so labels never overlap.
+  // Each "HH:MM:SS" label is ~8 chars at font-size xs; allow 
MIN_TICK_SPACING_PX per tick.
+  const tickCount =
+    bodyWidthPx > 0 ? Math.max(2, Math.floor(bodyWidthPx / 
MIN_TICK_SPACING_PX)) : GANTT_TIME_AXIS_TICK_COUNT;
+  const timeTicks = buildGanttTimeAxisTicks(minMs, maxMs, tickCount);
+
+  const rowVirtualizer = useVirtualizer({
+    count: flatNodes.length,
+    estimateSize: () => ROW_HEIGHT,
+    // @tanstack/react-virtual: scroll container ref; the hook subscribes to 
this element's scroll/resize.
+    getScrollElement: () => scrollContainerRef.current,
+    overscan: 5,
+    scrollPaddingStart: virtualizerScrollPaddingStart,
+  });
+
+  const virtualItems = rowVirtualizer.getVirtualItems();
+
+  // Hoist out of the per-segment render loop — location.pathname and 
location.search are
+  // constant within a single render, so parsing them once avoids O(segments) 
string parsing.
+  const { pathname, search } = location;
+  const baseSearchParams = new URLSearchParams(search);
+
+  const segmentLayout = (segment: GanttDataItem) => {
+    const leftPct = ((segment.x[0] - minMs) / spanMs) * 100;
+    const widthPct = ((segment.x[1] - segment.x[0]) / spanMs) * 100;
+    const widthPx = (widthPct / 100) * bodyWidthPx;
+    const minBoost = widthPx < MIN_BAR_WIDTH_PX && bodyWidthPx > 0 ? 
MIN_BAR_WIDTH_PX - widthPx : 0;
+    const widthPctAdjusted = bodyWidthPx > 0 ? ((widthPx + minBoost) / 
bodyWidthPx) * 100 : widthPct;
+
+    return {
+      leftPct,
+      widthPct: Math.min(widthPctAdjusted, 100 - leftPct),
+      widthPx,
+    };
+  };
+
+  return (
+    <Box
+      maxW="100%"
+      minW={0}
+      overflow="clip"
+      position="relative"
+      style={{ isolation: "isolate" }}
+      w="100%"
+      zIndex={0}
+    >
+      <Flex
+        bg="bg"
+        flexDirection="column"
+        flexShrink={0}
+        h={`${GANTT_TOP_PADDING_PX + GANTT_AXIS_HEIGHT_PX}px`}
+        position="sticky"
+        px={0}
+        top={0}
+        zIndex={3}
+      >
+        <Box aria-hidden flexShrink={0} h={`${GANTT_TOP_PADDING_PX}px`} />
+        <Box
+          borderBottomWidth={1}
+          borderColor="border"
+          flexShrink={0}
+          h={`${GANTT_AXIS_HEIGHT_PX}px`}
+          position="relative"
+          w="100%"
+        >
+          {timeTicks.map(({ label, labelAlign, leftPct }) => (
+            <Fragment key={`gantt-time-tick-${leftPct}`}>
+              <Box
+                aria-hidden
+                borderColor="border"
+                borderLeftWidth={1}
+                bottom={0}
+                h={`${GANTT_AXIS_TICK_HEIGHT_PX}px`}
+                left={`calc(${leftPct}% - 0.25px)`}
+                position="absolute"
+              />
+              <Text
+                bottom={`${GANTT_AXIS_TICK_HEIGHT_PX + 2}px`}
+                color="border.emphasized"
+                fontSize="xs"
+                left={`${leftPct}%`}
+                lineHeight={1}
+                position="absolute"
+                textAlign={labelAlign}
+                transform={
+                  labelAlign === "left"
+                    ? "translateX(0)"
+                    : labelAlign === "right"
+                      ? "translateX(-100%)"
+                      : "translateX(-50%)"
+                }
+                whiteSpace="nowrap"
+              >
+                {label}
+              </Text>
+            </Fragment>
+          ))}
+        </Box>
+      </Flex>
+
+      <Box maxW="100%" minW={0} overflow="hidden" position="relative" 
ref={bodyRef} w="100%" zIndex={0}>
+        <Box h={`${rowVirtualizer.getTotalSize()}px`} maxW="100%" 
position="relative" w="100%">
+          <Box
+            aria-hidden
+            h="100%"
+            left={0}
+            pointerEvents="none"
+            position="absolute"
+            top={0}
+            w="100%"
+            zIndex={0}
+          >
+            {timeTicks.map(({ leftPct }) => (
+              <Box
+                borderColor="border"
+                borderLeftWidth={1}
+                h="100%"
+                key={`gantt-body-grid-${leftPct}`}
+                left={`calc(${leftPct}% - 0.25px)`}
+                position="absolute"
+                top={0}
+                w={0}
+              />
+            ))}
+          </Box>
+          {virtualItems.map((vItem) => {
+            const node = flatNodes[vItem.index];
+
+            if (node === undefined) {
+              return undefined;
+            }
+
+            const allSegments = rowSegments[vItem.index] ?? [];
+            // Hide scheduled/queued bars that are too narrow to see. 
Re-derive adjacency
+            // from the filtered list so the adjacent execution bar keeps 
rounded corners.
+            const segments =
+              bodyWidthPx > 0
+                ? allSegments.filter((segment) =>
+                    segment.state !== "scheduled" && segment.state !== "queued"
+                      ? true
+                      : segmentLayout(segment).widthPx >= 
MIN_SEGMENT_RENDER_PX,
+                  )
+                : allSegments;
+            const taskId = node.id;
+            const isSelected = selectedTaskId === taskId || selectedGroupId 
=== taskId;
+            const isHovered = hoveredTaskId === taskId;
+            const gridSummary = summaryByTaskId.get(taskId);
+
+            return (
+              <Box
+                borderBottomWidth={1}
+                borderColor={node.isGroup ? "border.emphasized" : "border"}
+                h={`${ROW_HEIGHT}px`}
+                key={taskId}
+                left={0}
+                maxW="100%"
+                position="absolute"
+                top={0}
+                transform={`translateY(${vItem.start}px)`}
+                w="100%"
+                zIndex={1}
+              >
+                <Box
+                  bg={isSelected ? "brand.emphasized" : isHovered ? 
"brand.muted" : undefined}
+                  h="100%"
+                  maxW="100%"
+                  onMouseEnter={() => setHoveredTaskId(taskId)}
+                  onMouseLeave={() => setHoveredTaskId(undefined)}
+                  overflow="hidden"
+                  position="relative"
+                  px="3px"
+                  transition="background-color 0.2s"
+                  w="100%"
+                >
+                  {segments.map((segment, segIndex) => {
+                    const { leftPct, widthPct } = segmentLayout(segment);
+                    const to = getGanttSegmentTo({
+                      dagId,
+                      item: segment,
+                      maxTryByTaskId,
+                      pathname,
+                      runId,
+                      searchParams: baseSearchParams,
+                    });
+                    const { state, tryNumber, x } = segment;
+                    const tooltipInstance = toTooltipSummary(segment, node, 
gridSummary);
+                    const barRadius = 4;
+
+                    // Task groups don't have a try number
+                    const touchesNext =
+                      tryNumber === undefined ? false : segments[segIndex + 
1]?.tryNumber === tryNumber;
+                    const touchesPrev =
+                      tryNumber === undefined ? false : segments[segIndex - 
1]?.tryNumber === tryNumber;
+
+                    return (
+                      <TaskInstanceTooltip
+                        key={`${taskId}-${tryNumber ?? -1}-${x[0]}`}
+                        openDelay={500}
+                        positioning={{
+                          offset: { crossAxis: 0, mainAxis: 5 },
+                          placement: "bottom",
+                        }}
+                        taskInstance={tooltipInstance}
+                      >
+                        <Box
+                          as="span"
+                          display="block"
+                          h={`${TASK_BAR_HEIGHT_PX}px`}
+                          left={`${leftPct}%`}
+                          maxW={`${100 - leftPct}%`}
+                          minW={`${MIN_BAR_WIDTH_PX}px`}
+                          position="absolute"
+                          top={`${(ROW_HEIGHT - TASK_BAR_HEIGHT_PX) / 2}px`}
+                          w={`${widthPct}%`}
+                          zIndex={segIndex + 1}
+                        >
+                          <Link
+                            onClick={() => onSegmentClick?.()}
+                            replace
+                            style={{ display: "block", height: "100%", width: 
"100%" }}
+                            to={to ?? ""}
+                          >
+                            <Badge
+                              alignItems="center"
+                              borderBottomLeftRadius={touchesPrev ? 0 : 
barRadius}
+                              borderBottomRightRadius={touchesNext ? 0 : 
barRadius}
+                              borderTopLeftRadius={touchesPrev ? 0 : barRadius}
+                              borderTopRightRadius={touchesNext ? 0 : 
barRadius}
+                              colorPalette={state ?? "none"}
+                              display="flex"
+                              h="100%"
+                              justifyContent="center"
+                              minH={0}
+                              p={0}
+                              variant="solid"
+                              w="100%"
+                            >
+                              {touchesNext ? undefined : (
+                                <StateIcon size={GANTT_STATE_ICON_SIZE_PX} 
state={state} />
+                              )}
+                            </Badge>
+                          </Link>
+                        </Box>
+                      </TaskInstanceTooltip>
+                    );
+                  })}
+                </Box>
+              </Box>
+            );
+          })}
+        </Box>
+      </Box>
+    </Box>
+  );
+};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
index a0e61cc8579..1fc70a8a1ab 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.test.ts
@@ -1,5 +1,7 @@
 /* eslint-disable max-lines */
 
+/* eslint-disable unicorn/no-null */
+
 /*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -18,162 +20,118 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { ChartEvent, ActiveElement } from "chart.js";
 import dayjs from "dayjs";
-import type { TFunction } from "i18next";
-import { describe, it, expect } from "vitest";
-
-import type { GanttDataItem } from "./utils";
-import { createChartOptions, transformGanttData } from "./utils";
-
-// eslint-disable-next-line no-empty-function, 
@typescript-eslint/no-empty-function
-const noop = () => {};
-
-const defaultChartParams = {
-  gridColor: "#ccc",
-  handleBarClick: noop as (event: ChartEvent, elements: Array<ActiveElement>) 
=> void,
-  handleBarHover: noop as (event: ChartEvent, elements: Array<ActiveElement>) 
=> void,
-  hoveredId: undefined,
-  hoveredItemColor: "#eee",
-  labels: ["task_1", "task_2"],
-  selectedId: undefined,
-  selectedItemColor: "#ddd",
-  selectedTimezone: "UTC",
-  translate: ((key: string) => key) as unknown as TFunction,
-};
-
-describe("createChartOptions", () => {
-  describe("x-axis scale min/max with ISO date strings", () => {
-    it("should compute valid min/max for completed tasks with ISO dates", () 
=> {
-      const data: Array<GanttDataItem> = [
-        {
-          state: "success",
-          taskId: "task_1",
-          x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
-          y: "task_1",
-        },
-        {
-          state: "success",
-          taskId: "task_2",
-          x: ["2024-03-14T10:03:00.000Z", "2024-03-14T10:10:00.000Z"],
-          y: "task_2",
-        },
-      ];
-
-      const options = createChartOptions({
-        ...defaultChartParams,
-        data,
-        selectedRun: {
-          dag_id: "test_dag",
-          duration: 600,
-          end_date: "2024-03-14T10:10:00+00:00",
-          has_missed_deadline: false,
-          queued_at: "2024-03-14T09:59:00+00:00",
-          run_after: "2024-03-14T10:00:00+00:00",
-          run_id: "run_1",
-          run_type: "manual",
-          start_date: "2024-03-14T10:00:00+00:00",
-          state: "success",
-        },
-      });
+import { describe, expect, it } from "vitest";
 
-      const xScale = options.scales.x;
+import type { LightGridTaskInstanceSummary } from "openapi/requests/types.gen";
+import type { GridTask } from "src/layouts/Details/Grid/utils";
 
-      expect(xScale.min).toBeTypeOf("number");
-      expect(xScale.max).toBeTypeOf("number");
-      expect(Number.isNaN(xScale.min)).toBe(false);
-      expect(Number.isNaN(xScale.max)).toBe(false);
-      // max should be slightly beyond the latest end date (5% padding)
-      expect(xScale.max).toBeGreaterThan(new 
Date("2024-03-14T10:10:00.000Z").getTime());
-    });
+import {
+  type GanttDataItem,
+  buildGanttRowSegments,
+  buildGanttTimeAxisTicks,
+  buildMaxTryByTaskId,
+  GANTT_TIME_AXIS_TICK_COUNT,
+  gridSummariesToTaskIdMap,
+  transformGanttData,
+} from "./utils";
 
-    it("should compute valid min/max for running tasks", () => {
-      const now = dayjs().toISOString();
-      const data: Array<GanttDataItem> = [
-        {
-          state: "success",
-          taskId: "task_1",
-          x: ["2024-03-14T10:00:00.000Z", "2024-03-14T10:05:00.000Z"],
-          y: "task_1",
-        },
-        {
-          state: "running",
-          taskId: "task_2",
-          x: ["2024-03-14T10:05:00.000Z", now],
-          y: "task_2",
-        },
-      ];
-
-      const options = createChartOptions({
-        ...defaultChartParams,
-        data,
-        selectedRun: {
-          dag_id: "test_dag",
-          duration: 0,
-          // eslint-disable-next-line unicorn/no-null
-          end_date: null,
-          has_missed_deadline: false,
-          queued_at: "2024-03-14T09:59:00+00:00",
-          run_after: "2024-03-14T10:00:00+00:00",
-          run_id: "run_1",
-          run_type: "manual",
-          start_date: "2024-03-14T10:00:00+00:00",
-          state: "running",
-        },
-      });
+describe("buildGanttTimeAxisTicks", () => {
+  it("returns evenly spaced elapsed labels with edge alignment", () => {
+    const minMs = 0;
+    const maxMs = 60_000;
+    const ticks = buildGanttTimeAxisTicks(minMs, maxMs);
 
-      const xScale = options.scales.x;
+    expect(ticks).toHaveLength(GANTT_TIME_AXIS_TICK_COUNT);
+    expect(ticks[0]?.leftPct).toBe(0);
+    expect(ticks[0]?.label).toBe("00:00:00");
+    expect(ticks[0]?.labelAlign).toBe("left");
+    expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.leftPct).toBe(100);
+    expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.labelAlign).toBe("right");
+    expect(ticks[GANTT_TIME_AXIS_TICK_COUNT - 1]?.label).toBe("00:01:00");
+    expect(ticks[1]?.labelAlign).toBe("center");
+    expect(ticks.every((tick) => typeof tick.label === "string" && 
tick.label.length > 0)).toBe(true);
+  });
 
-      expect(xScale.min).toBeTypeOf("number");
-      expect(xScale.max).toBeTypeOf("number");
-      expect(Number.isNaN(xScale.min)).toBe(false);
-      expect(Number.isNaN(xScale.max)).toBe(false);
-    });
+  it("supports a single tick", () => {
+    const ticks = buildGanttTimeAxisTicks(1000, 1000, 1);
 
-    it("should handle empty data with running DagRun (fallback to formatted 
dates)", () => {
-      const options = createChartOptions({
-        ...defaultChartParams,
-        data: [],
-        labels: [],
-        selectedRun: {
-          dag_id: "test_dag",
-          duration: 0,
-          // eslint-disable-next-line unicorn/no-null
-          end_date: null,
-          has_missed_deadline: false,
-          queued_at: "2024-03-14T09:59:00+00:00",
-          run_after: "2024-03-14T10:00:00+00:00",
-          run_id: "run_1",
-          run_type: "manual",
-          start_date: "2024-03-14T10:00:00+00:00",
-          state: "running",
-        },
-      });
+    expect(ticks).toHaveLength(1);
+    expect(ticks[0]?.leftPct).toBe(0);
+    expect(ticks[0]?.labelAlign).toBe("left");
+    expect(ticks[0]?.label).toBe("00:00:00");
+  });
+});
 
-      const xScale = options.scales.x;
+describe("gridSummariesToTaskIdMap", () => {
+  it("indexes summaries by task_id", () => {
+    const summaries = [
+      { state: null, task_id: "a" } as LightGridTaskInstanceSummary,
+      { state: null, task_id: "b" } as LightGridTaskInstanceSummary,
+    ];
+    const map = gridSummariesToTaskIdMap(summaries);
 
-      // With empty data, min/max are formatted date strings (fallback branch)
-      expect(xScale.min).toBeTypeOf("string");
-      expect(xScale.max).toBeTypeOf("string");
-    });
+    expect(map.get("a")).toBe(summaries[0]);
+    expect(map.get("b")).toBe(summaries[1]);
+    expect(map.size).toBe(2);
+  });
+});
+
+describe("buildMaxTryByTaskId", () => {
+  it("returns the maximum try number for each task", () => {
+    const items: Array<GanttDataItem> = [
+      { taskId: "t1", tryNumber: 1, x: [0, 1], y: "t1" },
+      { taskId: "t1", tryNumber: 3, x: [0, 1], y: "t1" },
+      { taskId: "t1", tryNumber: 2, x: [0, 1], y: "t1" },
+      { taskId: "t2", tryNumber: 1, x: [0, 1], y: "t2" },
+    ];
+    const map = buildMaxTryByTaskId(items);
+
+    expect(map.get("t1")).toBe(3);
+    expect(map.get("t2")).toBe(1);
+  });
+
+  it("defaults to 1 when tryNumber is undefined", () => {
+    const items: Array<GanttDataItem> = [{ taskId: "t1", x: [0, 1], y: "t1" }];
+    const map = buildMaxTryByTaskId(items);
+
+    expect(map.get("t1")).toBe(1);
+  });
+
+  it("returns an empty map for empty input", () => {
+    expect(buildMaxTryByTaskId([]).size).toBe(0);
+  });
+});
+
+describe("buildGanttRowSegments", () => {
+  it("groups items by task id in flat node order", () => {
+    const flatNodes: Array<GridTask> = [
+      { depth: 0, id: "t1", is_mapped: false, label: "a" } as GridTask,
+      { depth: 0, id: "t2", is_mapped: false, label: "b" } as GridTask,
+    ];
+    const items: Array<GanttDataItem> = [
+      { taskId: "t2", x: [1_577_836_800_000, 1_577_923_200_000], y: "b" },
+      { taskId: "t1", x: [1_577_836_800_000, 1_577_923_200_000], y: "a" },
+    ];
+
+    const segments = buildGanttRowSegments(flatNodes, items);
+
+    expect(segments).toHaveLength(2);
+    expect(segments[0]?.map((segment) => segment.taskId)).toEqual(["t1"]);
+    expect(segments[1]?.map((segment) => segment.taskId)).toEqual(["t2"]);
   });
 });
 
 describe("transformGanttData", () => {
-  it("should skip tasks with null start_date", () => {
+  it("returns no segments when the try has no schedule, queue, or start time", 
() => {
     const result = transformGanttData({
       allTries: [
         {
-          // eslint-disable-next-line unicorn/no-null
           end_date: null,
           is_mapped: false,
-          // eslint-disable-next-line unicorn/no-null
           queued_dttm: null,
-          // eslint-disable-next-line unicorn/no-null
           scheduled_dttm: null,
-          // eslint-disable-next-line unicorn/no-null
           start_date: null,
-          // eslint-disable-next-line unicorn/no-null
           state: null,
           task_display_name: "task_1",
           task_id: "task_1",
@@ -187,17 +145,14 @@ describe("transformGanttData", () => {
     expect(result).toHaveLength(0);
   });
 
-  it("should include running tasks with valid start_date and use current time 
as end", () => {
+  it("includes running tasks with valid start_date and uses current time as 
end", () => {
     const before = dayjs();
     const result = transformGanttData({
       allTries: [
         {
-          // eslint-disable-next-line unicorn/no-null
           end_date: null,
           is_mapped: false,
-          // eslint-disable-next-line unicorn/no-null
           queued_dttm: null,
-          // eslint-disable-next-line unicorn/no-null
           scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "running",
@@ -212,25 +167,20 @@ describe("transformGanttData", () => {
 
     expect(result).toHaveLength(1);
     expect(result[0]?.state).toBe("running");
-    // End time should be approximately now (ISO string)
-    const endTime = dayjs(result[0]?.x[1]);
+    const endTime = result[0]?.x[1] ?? 0;
 
-    expect(endTime.valueOf()).toBeGreaterThanOrEqual(before.valueOf());
+    expect(endTime).toBeGreaterThanOrEqual(before.valueOf());
   });
 
-  it("should skip groups with null min_start_date or max_end_date", () => {
+  it("skips groups with null min_start_date or max_end_date", () => {
     const result = transformGanttData({
       allTries: [],
       flatNodes: [{ depth: 0, id: "group_1", is_mapped: false, isGroup: true, 
label: "group_1" }],
       gridSummaries: [
         {
-          // eslint-disable-next-line unicorn/no-null
           child_states: null,
-          // eslint-disable-next-line unicorn/no-null
           max_end_date: null,
-          // eslint-disable-next-line unicorn/no-null
           min_start_date: null,
-          // eslint-disable-next-line unicorn/no-null
           state: null,
           task_display_name: "group_1",
           task_id: "group_1",
@@ -241,15 +191,13 @@ describe("transformGanttData", () => {
     expect(result).toHaveLength(0);
   });
 
-  it("should produce ISO date strings parseable by dayjs", () => {
+  it("uses millisecond timestamps for segment bounds", () => {
     const result = transformGanttData({
       allTries: [
         {
           end_date: "2024-03-14T10:05:00+00:00",
           is_mapped: false,
-          // eslint-disable-next-line unicorn/no-null
           queued_dttm: null,
-          // eslint-disable-next-line unicorn/no-null
           scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "success",
@@ -263,17 +211,12 @@ describe("transformGanttData", () => {
     });
 
     expect(result).toHaveLength(1);
-    // x values should be valid ISO strings that dayjs can parse without NaN
-    const start = dayjs(result[0]?.x[0]);
-    const end = dayjs(result[0]?.x[1]);
-
-    expect(start.isValid()).toBe(true);
-    expect(end.isValid()).toBe(true);
-    expect(Number.isNaN(start.valueOf())).toBe(false);
-    expect(Number.isNaN(end.valueOf())).toBe(false);
+    expect(Number.isFinite(result[0]?.x[0])).toBe(true);
+    expect(Number.isFinite(result[0]?.x[1])).toBe(true);
+    expect(result[0]?.x[1]).toBeGreaterThanOrEqual(result[0]?.x[0] ?? 0);
   });
 
-  it("should produce 3 segments when scheduled_dttm and queued_dttm are 
present", () => {
+  it("produces 3 segments when scheduled_dttm and queued_dttm are present", () 
=> {
     const result = transformGanttData({
       allTries: [
         {
@@ -298,14 +241,13 @@ describe("transformGanttData", () => {
     expect(result[2]?.state).toBe("success");
   });
 
-  it("should produce 2 segments when only queued_dttm is present", () => {
+  it("produces 2 segments when only queued_dttm is present", () => {
     const result = transformGanttData({
       allTries: [
         {
           end_date: "2024-03-14T10:05:00+00:00",
           is_mapped: false,
           queued_dttm: "2024-03-14T09:59:00+00:00",
-          // eslint-disable-next-line unicorn/no-null
           scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "success",
@@ -323,15 +265,13 @@ describe("transformGanttData", () => {
     expect(result[1]?.state).toBe("success");
   });
 
-  it("should produce 1 segment when scheduled_dttm and queued_dttm are null", 
() => {
+  it("produces 1 segment when scheduled_dttm and queued_dttm are null", () => {
     const result = transformGanttData({
       allTries: [
         {
           end_date: "2024-03-14T10:05:00+00:00",
           is_mapped: false,
-          // eslint-disable-next-line unicorn/no-null
           queued_dttm: null,
-          // eslint-disable-next-line unicorn/no-null
           scheduled_dttm: null,
           start_date: "2024-03-14T10:00:00+00:00",
           state: "success",
@@ -347,4 +287,39 @@ describe("transformGanttData", () => {
     expect(result).toHaveLength(1);
     expect(result[0]?.state).toBe("success");
   });
+
+  it("sorts multiple tries by try_number", () => {
+    const result = transformGanttData({
+      allTries: [
+        {
+          end_date: "2024-03-14T10:05:00+00:00",
+          is_mapped: false,
+          queued_dttm: null,
+          scheduled_dttm: null,
+          start_date: "2024-03-14T10:00:00+00:00",
+          state: "failed",
+          task_display_name: "task_1",
+          task_id: "task_1",
+          try_number: 2,
+        },
+        {
+          end_date: "2024-03-14T09:55:00+00:00",
+          is_mapped: false,
+          queued_dttm: null,
+          scheduled_dttm: null,
+          start_date: "2024-03-14T09:50:00+00:00",
+          state: "failed",
+          task_display_name: "task_1",
+          task_id: "task_1",
+          try_number: 1,
+        },
+      ],
+      flatNodes: [{ depth: 0, id: "task_1", is_mapped: false, label: "task_1" 
}],
+      gridSummaries: [],
+    });
+
+    expect(result).toHaveLength(2);
+    expect(result[0]?.tryNumber).toBe(1);
+    expect(result[1]?.tryNumber).toBe(2);
+  });
 });
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
index 621f22e8ab9..d4cba954b62 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Gantt/utils.ts
@@ -1,5 +1,3 @@
-/* eslint-disable max-lines */
-
 /*!
  * Licensed to the Apache Software Foundation (ASF) under one
  * or more contributor license agreements.  See the NOTICE file
@@ -18,54 +16,47 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import type { ChartEvent, ActiveElement, TooltipItem } from "chart.js";
+
+/* eslint-disable max-lines -- Gantt transform, time range, links, and axis 
ticks share one module */
 import dayjs from "dayjs";
-import type { TFunction } from "i18next";
-import type { NavigateFunction, Location } from "react-router-dom";
-
-import type {
-  GanttTaskInstance,
-  GridRunsResponse,
-  LightGridTaskInstanceSummary,
-  TaskInstanceState,
-} from "openapi/requests";
+import type { To } from "react-router-dom";
+
+import type { GridRunsResponse, LightGridTaskInstanceSummary, 
TaskInstanceState } from "openapi/requests";
+import type { GanttTaskInstance } from "openapi/requests/types.gen";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import type { GridTask } from "src/layouts/Details/Grid/utils";
-import { getDuration, isStatePending } from "src/utils";
-import { formatDate } from "src/utils/datetimeUtils";
+import { isStatePending } from "src/utils";
+import { renderDuration } from "src/utils/datetimeUtils";
 import { buildTaskInstanceUrl } from "src/utils/links";
 
 export type GanttDataItem = {
   isGroup?: boolean | null;
   isMapped?: boolean | null;
+  /** Source try times for tooltips (matches TaskInstance `*_when` fields). */
+  queued_when?: string | null;
+  scheduled_when?: string | null;
   state?: TaskInstanceState | null;
   taskId: string;
   tryNumber?: number;
-  x: Array<string>;
+  /** [startMs, endMs] as Unix millisecond timestamps — pre-parsed to avoid 
repeated `new Date()` in render loops. */
+  x: [number, number];
   y: string;
 };
 
-type HandleBarClickOptions = {
+type GanttSegmentLinkParams = {
   dagId: string;
-  data: Array<GanttDataItem>;
-  location: Location;
-  navigate: NavigateFunction;
+  item: GanttDataItem;
+  /** Precomputed map from taskId → max try number; build once with 
`buildMaxTryByTaskId`. */
+  maxTryByTaskId: Map<string, number>;
+  /** `location.pathname` — hoisted out of the segment loop so it is read once 
per render. */
+  pathname: string;
   runId: string;
-};
-
-type ChartOptionsParams = {
-  data: Array<GanttDataItem>;
-  gridColor?: string;
-  handleBarClick: (event: ChartEvent, elements: Array<ActiveElement>) => void;
-  handleBarHover: (event: ChartEvent, elements: Array<ActiveElement>) => void;
-  hoveredId?: string | null;
-  hoveredItemColor?: string;
-  labels: Array<string>;
-  selectedId?: string;
-  selectedItemColor?: string;
-  selectedRun?: GridRunsResponse;
-  selectedTimezone: string;
-  translate: TFunction;
+  /**
+   * Pre-parsed copy of `location.search` — pass `new 
URLSearchParams(location.search)` built
+   * once per render. `getGanttSegmentTo` clones it internally before 
mutating, so the caller's
+   * instance is never modified.
+   */
+  searchParams: URLSearchParams;
 };
 
 type TransformGanttDataParams = {
@@ -74,12 +65,24 @@ type TransformGanttDataParams = {
   gridSummaries: Array<LightGridTaskInstanceSummary>;
 };
 
+export const gridSummariesToTaskIdMap = (
+  summaries: Array<LightGridTaskInstanceSummary>,
+): Map<string, LightGridTaskInstanceSummary> => {
+  const byId = new Map<string, LightGridTaskInstanceSummary>();
+
+  for (const summary of summaries) {
+    byId.set(summary.task_id, summary);
+  }
+
+  return byId;
+};
+
 export const transformGanttData = ({
   allTries,
   flatNodes,
   gridSummaries,
 }: TransformGanttDataParams): Array<GanttDataItem> => {
-  // Group tries by task_id
+  // Pre-index both lookups as Maps to keep the overall transform O(n+m).
   const triesByTask = new Map<string, Array<GanttTaskInstance>>();
 
   for (const ti of allTries) {
@@ -89,12 +92,13 @@ export const transformGanttData = ({
     triesByTask.set(ti.task_id, existing);
   }
 
+  const summaryByTaskId = gridSummariesToTaskIdMap(gridSummaries);
+
   return flatNodes
     .flatMap((node): Array<GanttDataItem> | undefined => {
-      const gridSummary = gridSummaries.find((ti) => ti.task_id === node.id);
+      const gridSummary = summaryByTaskId.get(node.id);
 
-      // Handle groups and mapped tasks using grid summary (aggregated min/max 
times)
-      // Use ISO so time scale and bar positions render consistently across 
browsers
+      // Groups and mapped tasks show a single aggregate bar sourced from grid 
summaries.
       if ((node.isGroup ?? node.is_mapped) && gridSummary) {
         if (gridSummary.min_start_date === null || gridSummary.max_end_date 
=== null) {
           return undefined;
@@ -106,74 +110,100 @@ export const transformGanttData = ({
             isMapped: node.is_mapped,
             state: gridSummary.state,
             taskId: gridSummary.task_id,
-            x: [
-              dayjs(gridSummary.min_start_date).toISOString(),
-              dayjs(gridSummary.max_end_date).toISOString(),
-            ],
+            x: [dayjs(gridSummary.min_start_date).valueOf(), 
dayjs(gridSummary.max_end_date).valueOf()],
             y: gridSummary.task_id,
           },
         ];
       }
 
-      // Handle individual tasks with all their tries
       if (!node.isGroup) {
         const tries = triesByTask.get(node.id);
 
         if (tries && tries.length > 0) {
-          return tries
-            .filter((tryInstance) => tryInstance.start_date !== null)
-            .flatMap((tryInstance) => {
-              const hasTaskRunning = isStatePending(tryInstance.state);
-              const endTime =
-                hasTaskRunning || tryInstance.end_date === null
-                  ? dayjs().toISOString()
-                  : tryInstance.end_date;
-              const items: Array<GanttDataItem> = [];
-
-              // Scheduled segment: from scheduled_dttm to queued_dttm (or 
start_date if no queued_dttm)
-              if (tryInstance.scheduled_dttm !== null) {
-                const scheduledEnd = tryInstance.queued_dttm ?? 
tryInstance.start_date ?? undefined;
+          const sortedTries = [...tries].sort(
+            (leftTry, rightTry) => leftTry.try_number - rightTry.try_number,
+          );
+
+          return sortedTries.flatMap((tryRow: GanttTaskInstance): 
Array<GanttDataItem> => {
+            const items: Array<GanttDataItem> = [];
+            const hasTaskRunning = isStatePending(tryRow.state);
+            const startDate: string | null = tryRow.start_date;
+            const endDate: string | null = tryRow.end_date;
+            const queuedDttm = tryRow.queued_dttm;
+            const scheduledDttm = tryRow.scheduled_dttm;
+            const startMs = startDate === null ? undefined : 
dayjs(startDate).valueOf();
+            const queuedMs = queuedDttm === null ? undefined : 
dayjs(queuedDttm).valueOf();
+            const scheduledMs = scheduledDttm === null ? undefined : 
dayjs(scheduledDttm).valueOf();
+
+            // Include scheduled/queued times in tooltip data whenever the 
timestamps exist.
+            const tryWhenForTooltip = {
+              ...(scheduledMs === undefined ? {} : { scheduled_when: 
scheduledDttm }),
+              ...(queuedMs === undefined ? {} : { queued_when: queuedDttm }),
+            };
+
+            let endMs: number;
+
+            if (hasTaskRunning) {
+              endMs = Date.now();
+            } else if (endDate === null) {
+              endMs = startMs ?? Date.now();
+            } else {
+              endMs = dayjs(endDate).valueOf();
+            }
 
+            if (scheduledMs !== undefined) {
+              const scheduledEndMs =
+                queuedMs ?? startMs ?? (hasTaskRunning || tryRow.state === 
"scheduled" ? Date.now() : endMs);
+
+              if (scheduledEndMs > scheduledMs) {
                 items.push({
                   isGroup: false,
-                  isMapped: tryInstance.is_mapped,
-                  state: "scheduled" as TaskInstanceState,
-                  taskId: tryInstance.task_id,
-                  tryNumber: tryInstance.try_number,
-                  x: [dayjs(tryInstance.scheduled_dttm).toISOString(), 
dayjs(scheduledEnd).toISOString()],
-                  y: tryInstance.task_display_name,
+                  isMapped: tryRow.is_mapped,
+                  state: "scheduled",
+                  taskId: tryRow.task_id,
+                  tryNumber: tryRow.try_number,
+                  ...tryWhenForTooltip,
+                  x: [scheduledMs, scheduledEndMs],
+                  y: tryRow.task_display_name,
                 });
               }
+            }
 
-              // Queue segment: from queued_dttm to start_date
-              if (tryInstance.queued_dttm !== null) {
+            if (queuedMs !== undefined) {
+              const queueEndMs = startMs ?? (hasTaskRunning ? Date.now() : 
endMs);
+
+              if (queueEndMs > queuedMs) {
                 items.push({
                   isGroup: false,
-                  isMapped: tryInstance.is_mapped,
-                  state: "queued" as TaskInstanceState,
-                  taskId: tryInstance.task_id,
-                  tryNumber: tryInstance.try_number,
-                  x: [
-                    dayjs(tryInstance.queued_dttm).toISOString(),
-                    dayjs(tryInstance.start_date ?? undefined).toISOString(),
-                  ],
-                  y: tryInstance.task_display_name,
+                  isMapped: tryRow.is_mapped,
+                  state: "queued",
+                  taskId: tryRow.task_id,
+                  tryNumber: tryRow.try_number,
+                  ...tryWhenForTooltip,
+                  x: [queuedMs, queueEndMs],
+                  y: tryRow.task_display_name,
                 });
               }
+            }
+
+            // Execution segment: start_date → end
+            if (startMs !== undefined) {
+              const execEndMs = Math.max(startMs, endMs);
 
-              // Execution segment: from start_date to end_date
               items.push({
                 isGroup: false,
-                isMapped: tryInstance.is_mapped,
-                state: tryInstance.state,
-                taskId: tryInstance.task_id,
-                tryNumber: tryInstance.try_number,
-                x: [dayjs(tryInstance.start_date).toISOString(), 
dayjs(endTime).toISOString()],
-                y: tryInstance.task_display_name,
+                isMapped: tryRow.is_mapped,
+                state: tryRow.state,
+                taskId: tryRow.task_id,
+                tryNumber: tryRow.try_number,
+                ...tryWhenForTooltip,
+                x: [startMs, execEndMs],
+                y: tryRow.task_display_name,
               });
+            }
 
-              return items;
-            });
+            return items;
+          });
         }
       }
 
@@ -182,251 +212,169 @@ export const transformGanttData = ({
     .filter((item): item is GanttDataItem => item !== undefined);
 };
 
-export const createHandleBarClick =
-  ({ dagId, data, location, navigate, runId }: HandleBarClickOptions) =>
-  (_: ChartEvent, elements: Array<ActiveElement>) => {
-    if (elements.length === 0 || !elements[0] || !runId) {
-      return;
-    }
+/** One entry per flat node: segments to draw in that row (tries or 
aggregate). */
+export const buildGanttRowSegments = (
+  flatNodes: Array<GridTask>,
+  items: Array<GanttDataItem>,
+): Array<Array<GanttDataItem>> => {
+  const byTaskId = new Map<string, Array<GanttDataItem>>();
 
-    const clickedData = data[elements[0].index];
+  for (const item of items) {
+    const list = byTaskId.get(item.taskId) ?? [];
 
-    if (!clickedData) {
-      return;
-    }
+    list.push(item);
+    byTaskId.set(item.taskId, list);
+  }
 
-    const { isGroup, isMapped, taskId, tryNumber } = clickedData;
+  return flatNodes.map((node) => byTaskId.get(node.id) ?? []);
+};
 
-    const taskUrl = buildTaskInstanceUrl({
-      currentPathname: location.pathname,
-      dagId,
-      isGroup: Boolean(isGroup),
-      isMapped: Boolean(isMapped),
-      runId,
-      taskId,
-    });
+export const computeGanttTimeRangeMs = ({
+  ganttItems,
+  selectedRun,
+  selectedTimezone,
+}: {
+  ganttItems: Array<GanttDataItem>;
+  selectedRun?: GridRunsResponse;
+  selectedTimezone: string;
+}): { maxMs: number; minMs: number } => {
+  const isActivePending = selectedRun !== undefined && 
isStatePending(selectedRun.state);
+  // Compute the effective end timestamp directly in milliseconds to avoid the
+  // string-format → new Date() round-trip which is browser-inconsistent.
+  const effectiveEndMs = isActivePending
+    ? dayjs().tz(selectedTimezone).valueOf()
+    : selectedRun?.end_date !== null && selectedRun?.end_date !== undefined
+      ? dayjs(selectedRun.end_date).valueOf()
+      : Date.now();
+
+  if (ganttItems.length === 0) {
+    const minMs =
+      selectedRun?.start_date !== null && selectedRun?.start_date !== undefined
+        ? dayjs(selectedRun.start_date).valueOf()
+        : Date.now();
+
+    return { maxMs: effectiveEndMs, minMs };
+  }
 
-    const searchParams = new URLSearchParams(location.search);
-    const isOlderTry =
-      tryNumber !== undefined &&
-      tryNumber <
-        Math.max(...data.filter((item) => item.taskId === taskId).map((item) 
=> item.tryNumber ?? 1));
+  let minTime = Infinity;
+  let maxTime = -Infinity;
 
-    if (isOlderTry) {
-      searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString());
-    } else {
-      searchParams.delete(SearchParamsKeys.TRY_NUMBER);
+  for (const item of ganttItems) {
+    if (item.x[0] < minTime) {
+      [minTime] = item.x;
+    }
+    if (item.x[1] > maxTime) {
+      [, maxTime] = item.x;
     }
+  }
+
+  const totalDuration = maxTime - minTime;
 
-    void Promise.resolve(
-      navigate(
-        {
-          pathname: taskUrl,
-          search: searchParams.toString(),
-        },
-        { replace: true },
-      ),
-    );
+  return {
+    maxMs: maxTime + totalDuration * 0.05,
+    minMs: minTime - totalDuration * 0.02,
   };
+};
 
-export const createHandleBarHover = (
-  data: Array<GanttDataItem>,
-  setHoveredTaskId: (taskId: string | undefined) => void,
-) => {
-  let lastHoveredTaskId: string | undefined = undefined;
-
-  return (_: ChartEvent, elements: Array<ActiveElement>) => {
-    // Clear previous hover styles
-    if (lastHoveredTaskId !== undefined) {
-      const previousTasks = document.querySelectorAll<HTMLDivElement>(
-        `#${lastHoveredTaskId.replaceAll(".", "-")}`,
-      );
-
-      previousTasks.forEach((task) => {
-        task.style.backgroundColor = "";
-      });
-    }
+/**
+ * Precompute the maximum try number for each task in O(n).
+ * Pass the result to `getGanttSegmentTo` to avoid an O(n) scan per segment.
+ */
+export const buildMaxTryByTaskId = (ganttItems: Array<GanttDataItem>): 
Map<string, number> => {
+  const map = new Map<string, number>();
+
+  for (const item of ganttItems) {
+    const current = map.get(item.taskId) ?? 0;
+
+    map.set(item.taskId, Math.max(current, item.tryNumber ?? 1));
+  }
 
-    if (elements.length > 0 && elements[0] && elements[0].index < data.length) 
{
-      const hoveredData = data[elements[0].index];
+  return map;
+};
 
-      if (hoveredData?.taskId !== undefined) {
-        lastHoveredTaskId = hoveredData.taskId;
-        setHoveredTaskId(hoveredData.taskId);
+export const getGanttSegmentTo = ({
+  dagId,
+  item,
+  maxTryByTaskId,
+  pathname,
+  runId,
+  searchParams: baseSearchParams,
+}: GanttSegmentLinkParams): To | undefined => {
+  if (!runId) {
+    return undefined;
+  }
 
-        // Apply new hover styles
-        const tasks = document.querySelectorAll<HTMLDivElement>(
-          `#${hoveredData.taskId.replaceAll(".", "-")}`,
-        );
+  const { isGroup, isMapped, taskId, tryNumber } = item;
+
+  const segmentPathname = buildTaskInstanceUrl({
+    currentPathname: pathname,
+    dagId,
+    isGroup: Boolean(isGroup),
+    isMapped: Boolean(isMapped),
+    runId,
+    taskId,
+  });
+
+  // Clone the pre-parsed params so mutations don't leak across segments.
+  const searchParams = new URLSearchParams(baseSearchParams);
+  const maxTryForTask = maxTryByTaskId.get(taskId) ?? 1;
+  const isOlderTry = tryNumber !== undefined && tryNumber < maxTryForTask;
+
+  if (isOlderTry) {
+    searchParams.set(SearchParamsKeys.TRY_NUMBER, tryNumber.toString());
+  } else {
+    searchParams.delete(SearchParamsKeys.TRY_NUMBER);
+  }
 
-        tasks.forEach((task) => {
-          task.style.backgroundColor = "var(--chakra-colors-info-subtle)";
-        });
-      }
-    } else {
-      lastHoveredTaskId = undefined;
-      setHoveredTaskId(undefined);
-    }
+  return {
+    pathname: segmentPathname,
+    search: searchParams.toString(),
   };
 };
 
-export const createChartOptions = ({
-  data,
-  gridColor,
-  handleBarClick,
-  handleBarHover,
-  hoveredId,
-  hoveredItemColor,
-  labels,
-  selectedId,
-  selectedItemColor,
-  selectedRun,
-  selectedTimezone,
-  translate,
-}: ChartOptionsParams) => {
-  const isActivePending = isStatePending(selectedRun?.state);
-  const effectiveEndDate = isActivePending
-    ? dayjs().tz(selectedTimezone).format("YYYY-MM-DD HH:mm:ss")
-    : selectedRun?.end_date;
+/** Default number of time labels along the Gantt axis (endpoints included). */
+export const GANTT_TIME_AXIS_TICK_COUNT = 8;
 
-  return {
-    animation: {
-      duration: 150,
-      easing: "linear" as const,
-    },
-    datasets: {
-      bar: {
-        minBarLength: 4,
-      },
-    },
-    indexAxis: "y" as const,
-    maintainAspectRatio: false,
-    onClick: handleBarClick,
-    onHover: (event: ChartEvent, elements: Array<ActiveElement>) => {
-      const target = event.native?.target as HTMLElement | undefined;
-
-      if (target) {
-        target.style.cursor = elements.length > 0 ? "pointer" : "default";
-      }
+export type GanttAxisTickLabelAlign = "center" | "left" | "right";
 
-      handleBarHover(event, elements);
-    },
-    plugins: {
-      annotation: {
-        annotations: [
-          // Selected task annotation
-          ...(selectedId === undefined || selectedId === "" || hoveredId === 
selectedId
-            ? []
-            : [
-                {
-                  backgroundColor: selectedItemColor,
-                  borderWidth: 0,
-                  drawTime: "beforeDatasetsDraw" as const,
-                  type: "box" as const,
-                  xMax: "max" as const,
-                  xMin: "min" as const,
-                  yMax: labels.indexOf(selectedId) + 0.5,
-                  yMin: labels.indexOf(selectedId) - 0.5,
-                },
-              ]),
-          // Hovered task annotation
-          ...(hoveredId === null || hoveredId === undefined
-            ? []
-            : [
-                {
-                  backgroundColor: hoveredItemColor,
-                  borderWidth: 0,
-                  drawTime: "beforeDatasetsDraw" as const,
-                  type: "box" as const,
-                  xMax: "max" as const,
-                  xMin: "min" as const,
-                  yMax: labels.indexOf(hoveredId) + 0.5,
-                  yMin: labels.indexOf(hoveredId) - 0.5,
-                },
-              ]),
-        ],
-        clip: false,
-      },
-      legend: {
-        display: false,
-      },
-      tooltip: {
-        callbacks: {
-          afterBody(tooltipItems: Array<TooltipItem<"bar">>) {
-            const taskInstance = data[tooltipItems[0]?.dataIndex ?? 0];
-            const startDate = formatDate(taskInstance?.x[0], selectedTimezone);
-            const endDate = formatDate(taskInstance?.x[1], selectedTimezone);
-            const lines = [
-              `${translate("startDate")}: ${startDate}`,
-              `${translate("endDate")}: ${endDate}`,
-              `${translate("duration")}: ${getDuration(taskInstance?.x[0], 
taskInstance?.x[1])}`,
-            ];
-
-            if (taskInstance?.tryNumber !== undefined) {
-              lines.unshift(`${translate("tryNumber")}: 
${taskInstance.tryNumber}`);
-            }
+export type GanttAxisTick = {
+  label: string;
+  labelAlign: GanttAxisTickLabelAlign;
+  leftPct: number;
+};
 
-            return lines;
-          },
-          label(tooltipItem: TooltipItem<"bar">) {
-            const taskInstance = data[tooltipItem.dataIndex];
+/** Elapsed time from the chart origin (`minMs`), formatted like grid duration 
labels (no wall-clock). */
+const formatElapsedMsForGanttAxis = (elapsedMs: number): string => {
+  const seconds = Math.max(0, elapsedMs / 1000);
 
-            return `${translate("state")}: 
${translate(`common:states.${taskInstance?.state}`)}`;
-          },
-        },
-      },
-    },
-    resizeDelay: 100,
-    responsive: true,
-    scales: {
-      x: {
-        grid: {
-          color: gridColor,
-          display: true,
-        },
-        max:
-          data.length > 0
-            ? (() => {
-                const maxTime = Math.max(...data.map((item) => 
dayjs(item.x[1]).valueOf()));
-                const minTime = Math.min(...data.map((item) => 
dayjs(item.x[0]).valueOf()));
-                const totalDuration = maxTime - minTime;
-
-                // add 5% to the max time to avoid the last tick being cut off
-                return maxTime + totalDuration * 0.05;
-              })()
-            : formatDate(effectiveEndDate, selectedTimezone),
-        min:
-          data.length > 0
-            ? (() => {
-                const maxTime = Math.max(...data.map((item) => 
dayjs(item.x[1]).valueOf()));
-                const minTime = Math.min(...data.map((item) => 
dayjs(item.x[0]).valueOf()));
-                const totalDuration = maxTime - minTime;
-
-                // subtract 2% from min time so background color shows before 
data
-                return minTime - totalDuration * 0.02;
-              })()
-            : formatDate(selectedRun?.start_date, selectedTimezone),
-        position: "top" as const,
-        stacked: true,
-        ticks: {
-          align: "start" as const,
-          callback: (value: number | string) => formatDate(value, 
selectedTimezone, "HH:mm:ss"),
-          maxRotation: 8,
-          maxTicksLimit: 8,
-          minRotation: 8,
-        },
-        type: "time" as const,
-      },
-      y: {
-        grid: {
-          color: gridColor,
-          display: true,
-        },
-        stacked: true,
-        ticks: {
-          display: false,
-        },
-      },
-    },
-  };
+  if (seconds <= 0.01) {
+    return "00:00:00";
+  }
+
+  return renderDuration(seconds, false) ?? "00:00:00";
+};
+
+export const buildGanttTimeAxisTicks = (
+  minMs: number,
+  maxMs: number,
+  tickCount: number = GANTT_TIME_AXIS_TICK_COUNT,
+): Array<GanttAxisTick> => {
+  const spanMs = Math.max(1, maxMs - minMs);
+  const denominator = Math.max(1, tickCount - 1);
+  const lastIndex = tickCount - 1;
+  const ticks: Array<GanttAxisTick> = [];
+
+  for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
+    const elapsedMs = (tickIndex / denominator) * spanMs;
+    const labelAlign: GanttAxisTickLabelAlign =
+      tickCount === 1 ? "left" : tickIndex === 0 ? "left" : tickIndex === 
lastIndex ? "right" : "center";
+
+    ticks.push({
+      label: formatElapsedMsForGanttAxis(elapsedMs),
+      labelAlign,
+      leftPct: (tickIndex / denominator) * 100,
+    });
+  }
+
+  return ticks;
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
index 0c798ab843a..17ec5cdb0a0 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/DurationAxis.tsx
@@ -19,5 +19,13 @@
 import { Box, type BoxProps } from "@chakra-ui/react";
 
 export const DurationAxis = (props: BoxProps) => (
-  <Box borderBottomWidth={1} left={0} position="absolute" right={0} zIndex={0} 
{...props} />
+  <Box
+    borderBottomWidth={1}
+    borderColor="border"
+    left={0}
+    position="absolute"
+    right={0}
+    zIndex={0}
+    {...props}
+  />
 );
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
index dbe30598010..9052144f470 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/Grid.tsx
@@ -20,7 +20,8 @@ import { Box, Flex, IconButton } from "@chakra-ui/react";
 import { useVirtualizer } from "@tanstack/react-virtual";
 import dayjs from "dayjs";
 import dayjsDuration from "dayjs/plugin/duration";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useRef, useState } from "react";
+import type { RefObject } from "react";
 import { useTranslation } from "react-i18next";
 import { FiChevronsRight } from "react-icons/fi";
 import { Link, useParams, useSearchParams } from "react-router-dom";
@@ -39,14 +40,9 @@ import { DurationAxis } from "./DurationAxis";
 import { DurationTick } from "./DurationTick";
 import { TaskInstancesColumn } from "./TaskInstancesColumn";
 import { TaskNames } from "./TaskNames";
-import {
-  GRID_HEADER_HEIGHT_PX,
-  GRID_HEADER_PADDING_PX,
-  GRID_OUTER_PADDING_PX,
-  ROW_HEIGHT,
-} from "./constants";
+import { GANTT_ROW_OFFSET_PX, GRID_HEADER_HEIGHT_PX, GRID_HEADER_PADDING_PX, 
ROW_HEIGHT } from "./constants";
 import { useGridRunsWithVersionFlags } from "./useGridRunsWithVersionFlags";
-import { flattenNodes } from "./utils";
+import { estimateTaskNameColumnWidthPx, flattenNodes } from "./utils";
 
 dayjs.extend(dayjsDuration);
 
@@ -56,24 +52,30 @@ type Props = {
   readonly runAfterGte?: string;
   readonly runAfterLte?: string;
   readonly runType?: DagRunType | undefined;
+  readonly sharedScrollContainerRef?: RefObject<HTMLDivElement | null>;
   readonly showGantt?: boolean;
   readonly showVersionIndicatorMode?: VersionIndicatorOptions;
   readonly triggeringUser?: string | undefined;
 };
 
+const GRID_INNER_SCROLL_PADDING_START_PX = GRID_HEADER_PADDING_PX + 
GRID_HEADER_HEIGHT_PX;
+
 export const Grid = ({
   dagRunState,
   limit,
   runAfterGte,
   runAfterLte,
   runType,
+  sharedScrollContainerRef,
   showGantt,
   showVersionIndicatorMode,
   triggeringUser,
 }: Props) => {
   const { t: translate } = useTranslation("dag");
   const gridRef = useRef<HTMLDivElement>(null);
-  const scrollContainerRef = useRef<HTMLDivElement>(null);
+  const scrollContainerRef = useRef<HTMLDivElement | null>(null);
+
+  const usesSharedScroll = Boolean(sharedScrollContainerRef && showGantt);
 
   const [selectedIsVisible, setSelectedIsVisible] = useState<boolean | 
undefined>();
   const { openGroupIds, toggleGroupId } = useOpenGroups();
@@ -141,7 +143,24 @@ export const Grid = ({
     showVersionIndicatorMode,
   });
 
-  const { flatNodes } = useMemo(() => flattenNodes(dagStructure, 
openGroupIds), [dagStructure, openGroupIds]);
+  const { flatNodes } = flattenNodes(dagStructure, openGroupIds);
+
+  const taskNameColumnWidthPx = showGantt ? 
estimateTaskNameColumnWidthPx(flatNodes) : undefined;
+
+  const taskNameColumnStyles =
+    showGantt && taskNameColumnWidthPx !== undefined
+      ? {
+          flexGrow: 0,
+          flexShrink: 0,
+          maxW: `${taskNameColumnWidthPx}px`,
+          minW: `${taskNameColumnWidthPx}px`,
+          width: `${taskNameColumnWidthPx}px`,
+        }
+      : {
+          flexGrow: 1,
+          flexShrink: 0,
+          minW: "200px",
+        };
 
   const { setMode } = useNavigation({
     onToggleGroup: toggleGroupId,
@@ -156,100 +175,115 @@ export const Grid = ({
   const rowVirtualizer = useVirtualizer({
     count: flatNodes.length,
     estimateSize: () => ROW_HEIGHT,
-    getScrollElement: () => scrollContainerRef.current,
+    // @tanstack/react-virtual: pass element resolver inline; hook tracks 
scroll container via its own subscriptions.
+    getScrollElement: () =>
+      usesSharedScroll ? (sharedScrollContainerRef?.current ?? null) : 
scrollContainerRef.current,
     overscan: 5,
+    scrollPaddingStart: usesSharedScroll ? GANTT_ROW_OFFSET_PX : 
GRID_INNER_SCROLL_PADDING_START_PX,
   });
 
   const virtualItems = rowVirtualizer.getVirtualItems();
 
+  const gridHeaderAndBody = (
+    <>
+      {/* Grid header, both bgs are needed to hide elements during horizontal 
and vertical scroll */}
+      <Flex bg="bg" display="flex" position="sticky" 
pt={`${GRID_HEADER_PADDING_PX}px`} top={0} zIndex={2}>
+        <Box bg="bg" left={0} position="sticky" zIndex={1} 
{...taskNameColumnStyles}>
+          <Flex flexDirection="column-reverse" 
height={`${GRID_HEADER_HEIGHT_PX}px`} position="relative">
+            {Boolean(gridRuns?.length) && (
+              <>
+                <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX - 8}px`} 
duration={max} />
+                <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX / 2 - 4}px`} 
duration={max / 2} />
+              </>
+            )}
+          </Flex>
+        </Box>
+        {/* Duration bars */}
+        <Flex flexDirection="row-reverse" flexShrink={0}>
+          <Flex flexShrink={0} position="relative">
+            <DurationAxis top={`${GRID_HEADER_HEIGHT_PX}px`} />
+            <DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
+            <DurationAxis top="4px" />
+            <Flex flexDirection="row-reverse">
+              {runsWithVersionFlags?.map((dr) => (
+                <Bar
+                  key={dr.run_id}
+                  max={max}
+                  onClick={handleColumnClick}
+                  run={dr}
+                  showVersionIndicatorMode={showVersionIndicatorMode}
+                />
+              ))}
+            </Flex>
+            {selectedIsVisible === undefined || !selectedIsVisible ? undefined 
: (
+              <Link to={`/dags/${dagId}`}>
+                <IconButton
+                  aria-label={translate("grid.buttons.resetToLatest")}
+                  height={`${GRID_HEADER_HEIGHT_PX - 2}px`}
+                  loading={isLoading}
+                  minW={0}
+                  ml={1}
+                  title={translate("grid.buttons.resetToLatest")}
+                  variant="surface"
+                  zIndex={1}
+                >
+                  <FiChevronsRight />
+                </IconButton>
+              </Link>
+            )}
+          </Flex>
+        </Flex>
+      </Flex>
+
+      {/* Grid body */}
+      <Flex height={`${rowVirtualizer.getTotalSize()}px`} position="relative">
+        <Box bg="bg" left={0} position="sticky" zIndex={1} 
{...taskNameColumnStyles}>
+          <TaskNames nodes={flatNodes} onRowClick={handleRowClick} 
virtualItems={virtualItems} />
+        </Box>
+        <Flex flexDirection="row-reverse" flexShrink={0}>
+          {gridRuns?.map((dr: GridRunsResponse) => (
+            <TaskInstancesColumn
+              key={dr.run_id}
+              nodes={flatNodes}
+              onCellClick={handleCellClick}
+              run={dr}
+              showVersionIndicatorMode={showVersionIndicatorMode}
+              tiSummaries={summariesByRunId.get(dr.run_id)}
+              virtualItems={virtualItems}
+            />
+          ))}
+        </Flex>
+      </Flex>
+    </>
+  );
+
   return (
     <Flex
       flexDirection="column"
+      flexGrow={showGantt ? 0 : 1}
+      flexShrink={showGantt ? 0 : undefined}
+      height={showGantt ? undefined : "100%"}
       justifyContent="flex-start"
       position="relative"
-      pt={`${GRID_OUTER_PADDING_PX}px`}
       ref={gridRef}
       tabIndex={0}
-      width={showGantt ? "1/2" : "full"}
+      w={showGantt ? "fit-content" : "full"}
     >
-      {/* Grid scroll container */}
-      <Box
-        height="calc(100vh - 140px)"
-        marginRight={showGantt ? 0 : 1}
-        overflow="auto"
-        paddingRight={showGantt ? 0 : 4}
-        position="relative"
-        ref={scrollContainerRef}
-      >
-        {/* Grid header, both bgs are needed to hide elements during 
horizontal and vertical scroll */}
-        <Flex bg="bg" display="flex" position="sticky" 
pt={`${GRID_HEADER_PADDING_PX}px`} top={0} zIndex={2}>
-          <Box bg="bg" flexGrow={1} left={0} minWidth="200px" 
position="sticky" zIndex={1}>
-            <Flex flexDirection="column-reverse" 
height={`${GRID_HEADER_HEIGHT_PX}px`} position="relative">
-              {Boolean(gridRuns?.length) && (
-                <>
-                  <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX - 8}px`} 
duration={max} />
-                  <DurationTick bottom={`${GRID_HEADER_HEIGHT_PX / 2 - 4}px`} 
duration={max / 2} />
-                </>
-              )}
-            </Flex>
-          </Box>
-          {/* Duration bars */}
-          <Flex flexDirection="row-reverse" flexShrink={0}>
-            <Flex flexShrink={0} position="relative">
-              <DurationAxis top={`${GRID_HEADER_HEIGHT_PX}px`} />
-              <DurationAxis top={`${GRID_HEADER_HEIGHT_PX / 2}px`} />
-              <DurationAxis top="4px" />
-              <Flex flexDirection="row-reverse">
-                {runsWithVersionFlags?.map((dr) => (
-                  <Bar
-                    key={dr.run_id}
-                    max={max}
-                    onClick={handleColumnClick}
-                    run={dr}
-                    showVersionIndicatorMode={showVersionIndicatorMode}
-                  />
-                ))}
-              </Flex>
-              {selectedIsVisible === undefined || !selectedIsVisible ? 
undefined : (
-                <Link to={`/dags/${dagId}`}>
-                  <IconButton
-                    aria-label={translate("grid.buttons.resetToLatest")}
-                    height={`${GRID_HEADER_HEIGHT_PX - 2}px`}
-                    loading={isLoading}
-                    minW={0}
-                    ml={1}
-                    title={translate("grid.buttons.resetToLatest")}
-                    variant="surface"
-                    zIndex={1}
-                  >
-                    <FiChevronsRight />
-                  </IconButton>
-                </Link>
-              )}
-            </Flex>
-          </Flex>
-        </Flex>
-
-        {/* Grid body */}
-        <Flex height={`${rowVirtualizer.getTotalSize()}px`} 
position="relative">
-          <Box bg="bg" flexGrow={1} flexShrink={0} left={0} minWidth="200px" 
position="sticky" zIndex={1}>
-            <TaskNames nodes={flatNodes} onRowClick={handleRowClick} 
virtualItems={virtualItems} />
-          </Box>
-          <Flex flexDirection="row-reverse" flexShrink={0}>
-            {gridRuns?.map((dr: GridRunsResponse) => (
-              <TaskInstancesColumn
-                key={dr.run_id}
-                nodes={flatNodes}
-                onCellClick={handleCellClick}
-                run={dr}
-                showVersionIndicatorMode={showVersionIndicatorMode}
-                tiSummaries={summariesByRunId.get(dr.run_id)}
-                virtualItems={virtualItems}
-              />
-            ))}
-          </Flex>
-        </Flex>
-      </Box>
+      {usesSharedScroll ? (
+        gridHeaderAndBody
+      ) : (
+        <Box
+          flex={1}
+          marginRight={showGantt ? 0 : 1}
+          minH={0}
+          overflow="auto"
+          paddingRight={showGantt ? 0 : 4}
+          position="relative"
+          ref={scrollContainerRef}
+        >
+          {gridHeaderAndBody}
+        </Box>
+      )}
     </Flex>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
index 05b32145514..ed47572760c 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskInstancesColumn.tsx
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box } from "@chakra-ui/react";
+import { Box, type BoxProps } from "@chakra-ui/react";
 import type { VirtualItem } from "@tanstack/react-virtual";
 import { useParams } from "react-router-dom";
 
@@ -27,6 +27,7 @@ import { useHover } from "src/context/hover";
 
 import { GridTI } from "./GridTI";
 import { DagVersionIndicator } from "./VersionIndicator";
+import { ROW_HEIGHT } from "./constants";
 import type { GridTask } from "./utils";
 
 type Props = {
@@ -38,7 +39,16 @@ type Props = {
   readonly virtualItems?: Array<VirtualItem>;
 };
 
-const ROW_HEIGHT = 20;
+type CellBorderProps = Pick<BoxProps, "borderBottomWidth" | "borderColor" | 
"borderTopWidth">;
+
+const taskInstanceCellBorderProps = (hideRowBorders: boolean, rowIndex: 
number): CellBorderProps =>
+  hideRowBorders
+    ? { borderBottomWidth: 0, borderTopWidth: 0 }
+    : {
+        borderBottomWidth: 1,
+        borderColor: "border",
+        borderTopWidth: rowIndex === 0 ? 1 : 0,
+      };
 
 export const TaskInstancesColumn = ({
   nodes,
@@ -69,6 +79,7 @@ export const TaskInstancesColumn = ({
   const hasMixedVersions = versionNumbers.size > 1;
 
   const isHovered = hoveredRunId === run.run_id;
+  const hideRowBorders = isSelected || isHovered;
 
   const handleMouseEnter = () => setHoveredRunId(run.run_id);
   const handleMouseLeave = () => setHoveredRunId(undefined);
@@ -94,6 +105,7 @@ export const TaskInstancesColumn = ({
         if (!taskInstance) {
           return (
             <Box
+              {...taskInstanceCellBorderProps(hideRowBorders, 
virtualItem.index)}
               height={`${ROW_HEIGHT}px`}
               key={`${node.id}-${run.run_id}`}
               left={0}
@@ -124,6 +136,8 @@ export const TaskInstancesColumn = ({
 
         return (
           <Box
+            {...taskInstanceCellBorderProps(hideRowBorders, virtualItem.index)}
+            height={`${ROW_HEIGHT}px`}
             key={node.id}
             left={0}
             position="absolute"
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
index 510d8be7143..8b9910cf0b5 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/TaskNames.tsx
@@ -27,6 +27,7 @@ import { TaskName } from "src/components/TaskName";
 import { useHover } from "src/context/hover";
 import { useOpenGroups } from "src/context/openGroups";
 
+import { ROW_HEIGHT } from "./constants";
 import type { GridTask } from "./utils";
 
 type Props = {
@@ -36,8 +37,6 @@ type Props = {
   readonly virtualItems?: Array<VirtualItem>;
 };
 
-const ROW_HEIGHT = 20;
-
 const indent = (depth: number) => `${depth * 0.75 + 0.5}rem`;
 
 export const TaskNames = ({ nodes, onRowClick, virtualItems }: Props) => {
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
index 99bd035896d..616dd2223d9 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/constants.ts
@@ -19,17 +19,20 @@
 
 // Grid layout constants - shared between Grid and Gantt for alignment
 export const ROW_HEIGHT = 20;
-export const GRID_OUTER_PADDING_PX = 64; // pt={16} = 16 * 4 = 64px
+/** Height of a task bar / badge within a row — matches the GridTI badge 
height. */
+export const TASK_BAR_HEIGHT_PX = 14;
 export const GRID_HEADER_PADDING_PX = 16; // pt={4} = 4 * 4 = 16px
 export const GRID_HEADER_HEIGHT_PX = 100; // height="100px" for duration bars
 
 // Gantt chart's x-axis height (time labels at top of chart)
 export const GANTT_AXIS_HEIGHT_PX = 36;
 
-// Total offset from top of Grid component to where task rows begin,
-// minus the Gantt axis height since the chart includes its own top axis
-export const GRID_BODY_OFFSET_PX =
-  GRID_OUTER_PADDING_PX + GRID_HEADER_PADDING_PX + GRID_HEADER_HEIGHT_PX - 
GANTT_AXIS_HEIGHT_PX;
+// Padding at top of Gantt scroll content so task rows share scrollTop with 
Grid
+// (grid scroll begins with sticky header; Gantt begins with the time axis).
+export const GANTT_TOP_PADDING_PX = GRID_HEADER_PADDING_PX + 
GRID_HEADER_HEIGHT_PX - GANTT_AXIS_HEIGHT_PX;
+
+/** Offset from scroll top to the first task row — used to align Grid and 
Gantt virtualizers. */
+export const GANTT_ROW_OFFSET_PX = GRID_HEADER_PADDING_PX + 
GRID_HEADER_HEIGHT_PX;
 
 // Version indicator constants
 export const BAR_HEIGHT = GRID_HEADER_HEIGHT_PX; // Duration bar height 
matches grid header
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
index e60012e58ba..b4b3d923e33 100644
--- 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
+++ 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/useGridRunsWithVersionFlags.ts
@@ -16,8 +16,6 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { useMemo } from "react";
-
 import type { GridRunsResponse } from "openapi/requests";
 import { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 
@@ -44,31 +42,29 @@ export const useGridRunsWithVersionFlags = ({
 }: UseGridRunsWithVersionFlagsParams): Array<GridRunWithVersionFlags> | 
undefined => {
   const isVersionIndicatorEnabled = showVersionIndicatorMode !== 
VersionIndicatorOptions.NONE;
 
-  return useMemo(() => {
-    if (!gridRuns) {
-      return undefined;
-    }
+  if (!gridRuns) {
+    return undefined;
+  }
 
-    if (!isVersionIndicatorEnabled) {
-      return gridRuns.map((run) => ({ ...run, isBundleVersionChange: false, 
isDagVersionChange: false }));
-    }
+  if (!isVersionIndicatorEnabled) {
+    return gridRuns.map((run) => ({ ...run, isBundleVersionChange: false, 
isDagVersionChange: false }));
+  }
 
-    return gridRuns.map((run, index) => {
-      const nextRun = gridRuns[index + 1];
+  return gridRuns.map((run, index) => {
+    const nextRun = gridRuns[index + 1];
 
-      const currentBundleVersion = getBundleVersion(run);
-      const nextBundleVersion = nextRun ? getBundleVersion(nextRun) : 
undefined;
-      const isBundleVersionChange =
-        currentBundleVersion !== undefined &&
-        nextBundleVersion !== undefined &&
-        currentBundleVersion !== nextBundleVersion;
+    const currentBundleVersion = getBundleVersion(run);
+    const nextBundleVersion = nextRun ? getBundleVersion(nextRun) : undefined;
+    const isBundleVersionChange =
+      currentBundleVersion !== undefined &&
+      nextBundleVersion !== undefined &&
+      currentBundleVersion !== nextBundleVersion;
 
-      const currentVersion = getMaxVersionNumber(run);
-      const nextVersion = nextRun ? getMaxVersionNumber(nextRun) : undefined;
-      const isDagVersionChange =
-        currentVersion !== undefined && nextVersion !== undefined && 
currentVersion !== nextVersion;
+    const currentVersion = getMaxVersionNumber(run);
+    const nextVersion = nextRun ? getMaxVersionNumber(nextRun) : undefined;
+    const isDagVersionChange =
+      currentVersion !== undefined && nextVersion !== undefined && 
currentVersion !== nextVersion;
 
-      return { ...run, isBundleVersionChange, isDagVersionChange };
-    });
-  }, [gridRuns, isVersionIndicatorEnabled]);
+    return { ...run, isBundleVersionChange, isDagVersionChange };
+  });
 };
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts
new file mode 100644
index 00000000000..b034a8f3100
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.test.ts
@@ -0,0 +1,59 @@
+/*!
+ * 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 { type GridTask, estimateTaskNameColumnWidthPx } from "./utils";
+
+const baseNode = {
+  id: "t1",
+  is_mapped: false,
+  label: "task_a",
+} as const;
+
+describe("estimateTaskNameColumnWidthPx", () => {
+  it("returns the layout minimum when there are no nodes", () => {
+    expect(estimateTaskNameColumnWidthPx([])).toBe(200);
+  });
+
+  it("returns at least the layout minimum for a short label", () => {
+    const nodes: Array<GridTask> = [{ ...baseNode, depth: 0, label: "a" } as 
GridTask];
+
+    expect(estimateTaskNameColumnWidthPx(nodes)).toBe(200);
+  });
+
+  it("increases width for longer labels, depth, group chevron, and mapped 
hint", () => {
+    const plain: Array<GridTask> = [{ ...baseNode, depth: 0, id: "a", label: 
"x".repeat(40) } as GridTask];
+    const deep: Array<GridTask> = [{ ...baseNode, depth: 4, id: "b", label: 
"x".repeat(40) } as GridTask];
+    const group: Array<GridTask> = [
+      { ...baseNode, depth: 0, id: "c", isGroup: true, label: "x".repeat(40) } 
as GridTask,
+    ];
+    const mapped: Array<GridTask> = [
+      { ...baseNode, depth: 0, id: "d", is_mapped: true, label: "x".repeat(40) 
} as GridTask,
+    ];
+
+    const wPlain = estimateTaskNameColumnWidthPx(plain);
+    const wDeep = estimateTaskNameColumnWidthPx(deep);
+    const wGroup = estimateTaskNameColumnWidthPx(group);
+    const wMapped = estimateTaskNameColumnWidthPx(mapped);
+
+    expect(wDeep).toBeGreaterThan(wPlain);
+    expect(wGroup).toBeGreaterThan(wPlain);
+    expect(wMapped).toBeGreaterThan(wPlain);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
index 019039a8401..242004a9120 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/utils.ts
@@ -24,6 +24,41 @@ export type GridTask = {
   isOpen?: boolean;
 } & GridNodeResponse;
 
+/** Minimum width for the task-name column (matches prior `minWidth="200px"`). 
*/
+const TASK_NAME_COLUMN_MIN_WIDTH_PX = 200;
+
+/**
+ * Chakra `rem` is typically 16px. Must match TaskNames `indent(depth)`:
+ * `(depth * 0.75 + 0.5)rem`.
+ */
+const ROOT_FONT_SIZE_PX = 16;
+
+const indentRem = (depth: number) => depth * 0.75 + 0.5;
+
+/**
+ * Approximate rendered width for the task-name column when the Gantt is shown.
+ * Task rows use absolute positioning, so the parent needs an explicit width.
+ */
+export const estimateTaskNameColumnWidthPx = (nodes: Array<GridTask>): number 
=> {
+  let max = TASK_NAME_COLUMN_MIN_WIDTH_PX;
+
+  for (const node of nodes) {
+    const indentPx = indentRem(node.depth) * ROOT_FONT_SIZE_PX;
+    // TaskNames uses fontSize="sm" (~14px); average glyph width ~8px for 
mixed labels.
+    const labelChars =
+      node.label.length +
+      (Boolean(node.is_mapped) ? 6 : 0) +
+      (node.setup_teardown_type === "setup" || node.setup_teardown_type === 
"teardown" ? 4 : 0);
+    const textPx = labelChars * 8;
+    const groupChevronPx = node.isGroup ? 28 : 0;
+    const paddingPx = 16;
+
+    max = Math.max(max, Math.ceil(indentPx + textPx + groupChevronPx + 
paddingPx));
+  }
+
+  return max;
+};
+
 export const flattenNodes = (
   nodes: Array<GridNodeResponse> | undefined,
   openGroupIds: Array<string>,
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
index 860f9a4d200..2e154fd3824 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/PanelButtons.tsx
@@ -31,11 +31,11 @@ import {
   VStack,
 } from "@chakra-ui/react";
 import { useReactFlow } from "@xyflow/react";
-import { useEffect, useMemo, useRef } from "react";
+import { useEffect, useRef } from "react";
 import { useHotkeys } from "react-hotkeys-hook";
 import { useTranslation } from "react-i18next";
 import { FiGrid } from "react-icons/fi";
-import { LuKeyboard } from "react-icons/lu";
+import { LuChartGantt, LuKeyboard } from "react-icons/lu";
 import { MdOutlineAccountTree, MdSettings } from "react-icons/md";
 import type { ImperativePanelGroupHandle } from "react-resizable-panels";
 import { useParams } from "react-router-dom";
@@ -52,7 +52,7 @@ import { SearchBar } from "src/components/SearchBar";
 import { StateBadge } from "src/components/StateBadge";
 import { Tooltip } from "src/components/ui";
 import { type ButtonGroupOption, ButtonGroupToggle } from 
"src/components/ui/ButtonGroupToggle";
-import { Checkbox } from "src/components/ui/Checkbox";
+import type { DagView } from "src/constants/dagView";
 import { dependenciesKey, directionKey } from "src/constants/localStorage";
 import type { VersionIndicatorOptions } from 
"src/constants/showVersionIndicatorOptions";
 import { dagRunTypeOptions, dagRunStateOptions } from 
"src/constants/stateOptions";
@@ -67,22 +67,20 @@ import { VersionIndicatorSelect } from 
"./VersionIndicatorSelect";
 
 type Props = {
   readonly dagRunStateFilter: DagRunState | undefined;
-  readonly dagView: "graph" | "grid";
+  readonly dagView: DagView;
   readonly limit: number;
   readonly panelGroupRef: React.RefObject<ImperativePanelGroupHandle | null>;
   readonly runAfterGte: string | undefined;
   readonly runAfterLte: string | undefined;
   readonly runTypeFilter: DagRunType | undefined;
   readonly setDagRunStateFilter: 
React.Dispatch<React.SetStateAction<DagRunState | undefined>>;
-  readonly setDagView: (x: "graph" | "grid") => void;
+  readonly setDagView: (view: DagView) => void;
   readonly setLimit: React.Dispatch<React.SetStateAction<number>>;
   readonly setRunAfterGte: React.Dispatch<React.SetStateAction<string | 
undefined>>;
   readonly setRunAfterLte: React.Dispatch<React.SetStateAction<string | 
undefined>>;
   readonly setRunTypeFilter: React.Dispatch<React.SetStateAction<DagRunType | 
undefined>>;
-  readonly setShowGantt: React.Dispatch<React.SetStateAction<boolean>>;
   readonly setShowVersionIndicatorMode: 
React.Dispatch<React.SetStateAction<VersionIndicatorOptions>>;
   readonly setTriggeringUserFilter: React.Dispatch<React.SetStateAction<string 
| undefined>>;
-  readonly showGantt: boolean;
   readonly showVersionIndicatorMode: VersionIndicatorOptions;
   readonly triggeringUserFilter: string | undefined;
 };
@@ -134,10 +132,8 @@ export const PanelButtons = ({
   setRunAfterGte,
   setRunAfterLte,
   setRunTypeFilter,
-  setShowGantt,
   setShowVersionIndicatorMode,
   setTriggeringUserFilter,
-  showGantt,
   showVersionIndicatorMode,
   triggeringUserFilter,
 }: Props) => {
@@ -158,7 +154,7 @@ export const PanelButtons = ({
     setLimit(runLimit);
   };
 
-  const enableResponsiveOptions = showGantt && Boolean(runId);
+  const enableResponsiveOptions = dagView === "gantt";
 
   const { displayRunOptions, limit: defaultLimit } = getWidthBasedConfig(
     containerWidth,
@@ -249,24 +245,30 @@ export const PanelButtons = ({
     }
   };
 
-  const dagViewOptions: Array<ButtonGroupOption<"graph" | "grid">> = useMemo(
-    () => [
-      {
-        dataTestId: "grid-view-button",
-        label: <FiGrid />,
-        title: translate("dag:panel.buttons.showGridShortcut"),
-        value: "grid",
-      },
-      {
-        label: <MdOutlineAccountTree />,
-        title: translate("dag:panel.buttons.showGraphShortcut"),
-        value: "graph",
-      },
-    ],
-    [translate],
-  );
+  const dagViewOptions: Array<ButtonGroupOption<DagView>> = [
+    {
+      dataTestId: "grid-view-button",
+      label: <FiGrid />,
+      title: translate("dag:panel.buttons.showGridShortcut"),
+      value: "grid",
+    },
+    ...(shouldShowToggleButtons
+      ? [
+          {
+            label: <LuChartGantt />,
+            title: translate("dag:panel.buttons.showGantt"),
+            value: "gantt" as const,
+          },
+        ]
+      : []),
+    {
+      label: <MdOutlineAccountTree />,
+      title: translate("dag:panel.buttons.showGraphShortcut"),
+      value: "graph",
+    },
+  ];
 
-  const handleDagViewChange = (view: "graph" | "grid") => {
+  const handleDagViewChange = (view: DagView) => {
     if (view === dagView) {
       handleFocus(view);
     } else {
@@ -287,7 +289,7 @@ export const PanelButtons = ({
   );
 
   return (
-    <Box position="absolute" pr={4} ref={containerRef} top={1} width="100%" 
zIndex={1}>
+    <Box bg="bg" pr={4} ref={containerRef} width="100%" zIndex={1}>
       <Flex justifyContent="space-between">
         <ButtonGroupToggle isIcon onChange={handleDagViewChange} 
options={dagViewOptions} value={dagView} />
         <Flex alignItems="center" gap={1} justifyContent="space-between">
@@ -537,13 +539,6 @@ export const PanelButtons = ({
                             value={runAfterRange}
                           />
                         </VStack>
-                        {shouldShowToggleButtons ? (
-                          <VStack alignItems="flex-start" px={1}>
-                            <Checkbox checked={showGantt} onChange={() => 
setShowGantt(!showGantt)} size="sm">
-                              {translate("dag:panel.buttons.showGantt")}
-                            </Checkbox>
-                          </VStack>
-                        ) : undefined}
                         <VStack alignItems="flex-start" px={1}>
                           <VersionIndicatorSelect
                             onChange={setShowVersionIndicatorMode}
@@ -560,7 +555,7 @@ export const PanelButtons = ({
         </Flex>
       </Flex>
 
-      {dagView === "grid" && (
+      {dagView !== "graph" && (
         <Flex color="fg.muted" gap={2} justifyContent="flex-end" mt={1}>
           <RunTypeLegend />
           <Tooltip
diff --git a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx 
b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
index 5e7b66c7a5e..8750cb850bd 100644
--- a/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/TaskInstance/Details.tsx
@@ -152,6 +152,18 @@ export const Details = () => {
             <Table.Cell>{translate("duration")}</Table.Cell>
             <Table.Cell>{renderDuration(tryInstance?.duration)}</Table.Cell>
           </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.scheduledWhen")}</Table.Cell>
+            <Table.Cell>
+              <Time datetime={tryInstance?.scheduled_when} />
+            </Table.Cell>
+          </Table.Row>
+          <Table.Row>
+            <Table.Cell>{translate("taskInstance.queuedWhen")}</Table.Cell>
+            <Table.Cell>
+              <Time datetime={tryInstance?.queued_when} />
+            </Table.Cell>
+          </Table.Row>
           <Table.Row>
             <Table.Cell>{translate("startDate")}</Table.Cell>
             <Table.Cell>

Reply via email to