Repository: dagster-io/dagster
Pull request #29849: [ui] New list items, flagged Jobs list implementation
Author: login: hellendag
Created: 2025-05-07T18:39:50Z | Merged: 2025-05-08T18:56:44Z

## Summary & Motivation

Introduce a handful of new components, intended to support new list designs for Observe UI changes and beyond. Open to renaming and API discussion.

- `ListItem`, which is a pretty simple list item abstraction intended to show:
  - A linked main piece of content, `left`
  - A horizontal list of interactive controls or non-interactive content, `right`
  - Hover states, focus states, borders, etc. for the individual list items
- `HorizontalControls`, which renders the provided children in a horizontal list with vertical keylines between them
- `HoverButton`, a button style intended more for hover states (plus tooltips or popovers) instead of actual button clicks. Still implemented as a button because it's an interactive element.

The intent is that this list item will generally be used in virtualized lists, since we should be virtualizing pretty much all of our lists at this point.

Additionally, I have implemented the Jobs list page with the new components, behind the Observe UI feature flag.

## How I Tested These Changes

With flag disabled, verify that the existing Jobs list renders correctly, including virtualization windowing.

With flag enabled, verify virtualization rendering and windowing, plus link behavior, hover and focus states, and interactivity with `right` items.



Diff:
diff --git a/js_modules/dagster-ui/packages/ui-components/package.json b/js_modules/dagster-ui/packages/ui-components/package.json
index 7735166fc5ef3..a9fed508ffd6f 100644
--- a/js_modules/dagster-ui/packages/ui-components/package.json
+++ b/js_modules/dagster-ui/packages/ui-components/package.json
@@ -35,6 +35,7 @@
   "dependencies": {
     "@react-hook/resize-observer": "^1.2.6",
     "amator": "^1.1.0",
+    "clsx": "^2.1.1",
     "codemirror": "^5.65.2",
     "deepmerge": "^4.2.2",
     "react-dates": "^21.8.0",
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/HorizontalControls.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/HorizontalControls.tsx
new file mode 100644
index 0000000000000..6fb6fab434022
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/HorizontalControls.tsx
@@ -0,0 +1,24 @@
+import {ReactNode} from 'react';
+
+import {Box} from './Box';
+import styles from './css/HorizontalControls.module.css';
+
+type Control = {key: string; control: ReactNode};
+
+interface Props {
+  controls: Control[];
+}
+
+export const HorizontalControls = ({controls}: Props) => {
+  return (
+    <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}} className={styles.container}>
+      {controls.map(({key, control}) =>
+        control ? (
+          <div key={key} className={styles.item}>
+            {control}
+          </div>
+        ) : null,
+      )}
+    </Box>
+  );
+};
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/HoverButton.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/HoverButton.tsx
new file mode 100644
index 0000000000000..11dc8b8f4e226
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/HoverButton.tsx
@@ -0,0 +1,8 @@
+import {ComponentProps} from 'react';
+
+import {UnstyledButton} from './UnstyledButton';
+import styles from './css/HoverButton.module.css';
+
+export const HoverButton = (props: ComponentProps<typeof UnstyledButton>) => {
+  return <UnstyledButton {...props} className={styles.button} />;
+};
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/ListItem.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/ListItem.tsx
new file mode 100644
index 0000000000000..782e4837840fd
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/ListItem.tsx
@@ -0,0 +1,40 @@
+import {ChangeEvent, HTMLProps, ReactNode, forwardRef} from 'react';
+
+import {Checkbox} from './Checkbox';
+import styles from './css/ListItem.module.css';
+
+interface Props {
+  index: number;
+  checked?: boolean;
+  onToggle?: (checked: boolean) => void;
+  href: string;
+  renderLink: (props: HTMLProps<HTMLAnchorElement>) => ReactNode;
+  left: ReactNode;
+  right: ReactNode;
+}
+
+const defaultRenderLink = ({href, ...props}: HTMLProps<HTMLAnchorElement>): ReactNode => (
+  <a href={href} {...props} />
+);
+
+export const ListItem = forwardRef<HTMLDivElement, Props>(
+  ({index, checked = false, onToggle, href, renderLink, left, right}, ref) => {
+    const link = renderLink ?? defaultRenderLink;
+    return (
+      <div className={styles.listItem} data-index={index} ref={ref}>
+        {onToggle ? (
+          <div className={styles.checkboxContainer}>
+            <Checkbox
+              format="check"
+              checked={checked}
+              size="small"
+              onChange={(e: ChangeEvent<HTMLInputElement>) => onToggle(e.target.checked)}
+            />
+          </div>
+        ) : null}
+        {link({href, className: styles.listItemAnchor, children: left})}
+        <div className={styles.right}>{right}</div>
+      </div>
+    );
+  },
+);
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/__stories__/ListItem.stories.tsx b/js_modules/dagster-ui/packages/ui-components/src/components/__stories__/ListItem.stories.tsx
new file mode 100644
index 0000000000000..494797f73e876
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/__stories__/ListItem.stories.tsx
@@ -0,0 +1,367 @@
+import {Meta} from '@storybook/react';
+import {HTMLProps, useCallback, useState} from 'react';
+
+import {BaseButton} from '../BaseButton';
+import {Box} from '../Box';
+import {Button} from '../Button';
+import {Colors} from '../Color';
+import {HorizontalControls} from '../HorizontalControls';
+import {Icon} from '../Icon';
+import {ListItem} from '../ListItem';
+import {Menu, MenuItem} from '../Menu';
+import {Popover} from '../Popover';
+import {Caption} from '../Text';
+import {Tooltip} from '../Tooltip';
+
+// eslint-disable-next-line import/no-default-export
+export default {
+  title: 'ListItem',
+  component: ListItem,
+} as Meta;
+
+export const Default = () => {
+  return (
+    <div style={{width: '600px'}}>
+      <ListItem
+        index={0}
+        renderLink={renderLink}
+        href="/jobs/foo"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            foo
+          </Box>
+        }
+        right={<div>Right</div>}
+      />
+      <ListItem
+        index={1}
+        renderLink={renderLink}
+        href="/jobs/bar"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            bar
+          </Box>
+        }
+        right={<div>Right</div>}
+      />
+      <ListItem
+        index={2}
+        renderLink={renderLink}
+        href="/jobs/baz"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            baz
+          </Box>
+        }
+        right={<div>Right</div>}
+      />
+    </div>
+  );
+};
+
+type AutomationType = 'sensor' | 'schedule' | 'none';
+
+const AutomationButton = ({type, enabled = false}: {type: AutomationType; enabled?: boolean}) => {
+  const icon = () => {
+    if (type === 'none') {
+      return <Icon name="status" color={Colors.accentGray()} />;
+    }
+
+    return <Icon name={type} color={enabled ? Colors.accentGreen() : Colors.accentGray()} />;
+  };
+
+  return (
+    <BaseButton
+      icon={icon()}
+      iconColor={enabled && type !== 'none' ? Colors.accentGreen() : Colors.accentGray()}
+      fillColor="transparent"
+      fillColorHover={Colors.backgroundLightHover()}
+      strokeColor="transparent"
+      strokeColorHover="transparent"
+    />
+  );
+};
+
+const renderLink = (props: HTMLProps<HTMLAnchorElement>) => <a {...props} />;
+
+export const JobList = () => {
+  const [checkedItems, setCheckedItems] = useState<Set<string>>(() => new Set());
+
+  const onToggle = useCallback((item: string) => {
+    setCheckedItems((current) => {
+      const copy = new Set(current);
+      if (copy.has(item)) {
+        copy.delete(item);
+      } else {
+        copy.add(item);
+      }
+      return copy;
+    });
+  }, []);
+
+  return (
+    <div style={{width: '100%'}}>
+      <ListItem
+        index={0}
+        renderLink={renderLink}
+        checked={checkedItems.has('foo')}
+        onToggle={() => onToggle('foo')}
+        href="/jobs/foo"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            foo
+          </Box>
+        }
+        right={
+          <HorizontalControls
+            controls={[
+              {
+                key: 'latest-run',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <Button intent="none">2 mins ago</Button>
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'automations',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <AutomationButton type="sensor" enabled />
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'menu',
+                control: (
+                  <Popover
+                    content={
+                      <Menu>
+                        <MenuItem text="View job" />
+                      </Menu>
+                    }
+                    placement="bottom-end"
+                  >
+                    <Button intent="none" icon={<Icon name="more_horiz" />} />
+                  </Popover>
+                ),
+              },
+            ]}
+          />
+        }
+      />
+      <ListItem
+        index={1}
+        renderLink={renderLink}
+        checked={checkedItems.has('bar')}
+        onToggle={() => onToggle('bar')}
+        href="/jobs/bar"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            bar
+          </Box>
+        }
+        right={
+          <HorizontalControls
+            controls={[
+              {
+                key: 'latest-run',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <Button intent="none">2 mins ago</Button>
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'automations',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <AutomationButton type="schedule" enabled />
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'menu',
+                control: (
+                  <Popover
+                    content={
+                      <Menu>
+                        <MenuItem text="View job" />
+                      </Menu>
+                    }
+                    placement="bottom-end"
+                  >
+                    <Button intent="none" icon={<Icon name="more_horiz" />} />
+                  </Popover>
+                ),
+              },
+            ]}
+          />
+        }
+      />
+      <ListItem
+        index={2}
+        renderLink={renderLink}
+        checked={checkedItems.has('baz')}
+        onToggle={() => onToggle('baz')}
+        href="/jobs/baz"
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            baz
+          </Box>
+        }
+        right={
+          <HorizontalControls
+            controls={[
+              {
+                key: 'latest-run',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <Button intent="none">2 mins ago</Button>
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'automations',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <AutomationButton type="sensor" />
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'menu',
+                control: (
+                  <Popover
+                    content={
+                      <Menu>
+                        <MenuItem text="View job" />
+                      </Menu>
+                    }
+                    placement="bottom-end"
+                  >
+                    <Button intent="none" icon={<Icon name="more_horiz" />} />
+                  </Popover>
+                ),
+              },
+            ]}
+          />
+        }
+      />
+      <ListItem
+        index={3}
+        renderLink={renderLink}
+        href="/jobs/gorp"
+        checked={checkedItems.has('gorp')}
+        onToggle={() => onToggle('gorp')}
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+            <Icon name="job" />
+            gorp
+          </Box>
+        }
+        right={
+          <HorizontalControls
+            controls={[
+              {
+                key: 'latest-run',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <Button intent="none">1 hour ago</Button>
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'automations',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <AutomationButton type="none" />
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'menu',
+                control: (
+                  <Popover
+                    content={
+                      <Menu>
+                        <MenuItem text="View job" />
+                      </Menu>
+                    }
+                    placement="bottom-end"
+                  >
+                    <Button intent="none" icon={<Icon name="more_horiz" />} />
+                  </Popover>
+                ),
+              },
+            ]}
+          />
+        }
+      />
+      <ListItem
+        index={4}
+        renderLink={renderLink}
+        href="/jobs/multi-line"
+        checked={checkedItems.has('multi-line')}
+        onToggle={() => onToggle('multi-line')}
+        left={
+          <Box flex={{direction: 'row', gap: 8, alignItems: 'flex-start'}}>
+            <Box margin={{top: 2}}>
+              <Icon name="job" />
+            </Box>
+            <Box flex={{direction: 'column', gap: 4}}>
+              <div>multi-line</div>
+              <Caption>
+                This is a description of this job, with maybe some metadata like the status or
+                something.
+              </Caption>
+            </Box>
+          </Box>
+        }
+        right={
+          <HorizontalControls
+            controls={[
+              {
+                key: 'latest-run',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <Button intent="none">3 hours ago</Button>
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'automations',
+                control: (
+                  <Tooltip content="Hello" placement="top">
+                    <AutomationButton type="none" />
+                  </Tooltip>
+                ),
+              },
+              {
+                key: 'menu',
+                control: (
+                  <Popover
+                    content={
+                      <Menu>
+                        <MenuItem text="View job" />
+                      </Menu>
+                    }
+                    placement="bottom-end"
+                  >
+                    <Button intent="none" icon={<Icon name="more_horiz" />} />
+                  </Popover>
+                ),
+              },
+            ]}
+          />
+        }
+      />
+    </div>
+  );
+};
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/css/HorizontalControls.module.css b/js_modules/dagster-ui/packages/ui-components/src/components/css/HorizontalControls.module.css
new file mode 100644
index 0000000000000..3124a352fd6f0
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/css/HorizontalControls.module.css
@@ -0,0 +1,23 @@
+.container {
+  pointer-events: none;
+}
+
+.container.container .item {
+  display: block;
+  position: relative;
+  pointer-events: all;
+}
+
+.container .item:not(:first-child) {
+  padding-left: 8px;
+}
+
+.container .item:not(:first-child)::before {
+  content: '';
+  width: 1px;
+  height: 14px;
+  background-color: var(--color-keyline-default);
+  position: absolute;
+  top: calc(50% - 7px);
+  left: 0;
+}
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/css/HoverButton.module.css b/js_modules/dagster-ui/packages/ui-components/src/components/css/HoverButton.module.css
new file mode 100644
index 0000000000000..89659988123cd
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/css/HoverButton.module.css
@@ -0,0 +1,12 @@
+.button.button {
+  font-size: 14px;
+  padding: 6px 8px;
+  background-color: transparent;
+  transition: background-color 150ms linear;
+  border-radius: 8px;
+  line-height: 20px;
+}
+
+.button.button:hover {
+  background-color: var(--color-background-light-hover);
+}
diff --git a/js_modules/dagster-ui/packages/ui-components/src/components/css/ListItem.module.css b/js_modules/dagster-ui/packages/ui-components/src/components/css/ListItem.module.css
new file mode 100644
index 0000000000000..0c6e83a0a4204
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-components/src/components/css/ListItem.module.css
@@ -0,0 +1,104 @@
+.listItem {
+  background-color: var(--color-background-default);
+  display: flex;
+  flex-direction: row;
+  position: relative;
+  transition:
+    background-color 0.1s ease-in-out,
+    box-shadow 0.1s ease-in-out;
+  box-shadow: inset 0 -1px 0 0 var(--color-keyline-default);
+  user-select: none;
+  width: 100%;
+  overflow: hidden;
+}
+
+.listItem:first-child:last-child,
+.listItem:last-child {
+  box-shadow: none;
+}
+
+.listItem:hover {
+  background-color: var(--color-background-default-hover);
+  box-shadow:
+    inset 0 -1px 0 0 var(--color-keyline-default),
+    inset 0 1px 0 0 var(--color-keyline-default);
+}
+
+.listItem:first-child:last-child:hover {
+  box-shadow: none;
+}
+
+.listItem:has(+ .listItem:hover) {
+  box-shadow: none;
+}
+
+.listItem:hover .listItemAnchor {
+  color: var(--color-text-default);
+}
+
+.listItemAnchor {
+  padding: 12px 20px 12px 24px;
+  transition: color 0.1s ease-in-out;
+  flex: 1;
+  overflow: hidden;
+}
+
+.listItemAnchor:link,
+.listItemAnchor:visited,
+.listItemAnchor:hover,
+.listItemAnchor:active {
+  color: var(--color-text-light);
+  text-decoration: none;
+}
+
+.listItem:has(.listItemAnchor:focus) {
+  background-color: var(--color-background-blue);
+  box-shadow:
+    inset 0 -1px 0 0 var(--color-background-blue),
+    inset 0 1px 0 0 var(--color-background-blue);
+}
+
+.listItemAnchor:focus,
+.listItemAnchor:active {
+  outline: none;
+}
+
+.listItemAnchor:hover {
+  color: var(--color-text-default);
+}
+
+.listItem .listItemAnchor div[role='img'] {
+  background-color: var(--color-text-light);
+  transition: background-color 0.1s ease-in-out;
+}
+
+.listItem:hover .listItemAnchor div[role='img'] {
+  background-color: var(--color-text-default);
+}
+
+.listItem .checkboxContainer {
+  position: absolute;
+  left: 6px;
+  top: 12px;
+  bottom: 0;
+  opacity: 0;
+  transition: opacity 0.1s ease-in-out;
+}
+
+.listItem:hover .checkboxContainer,
+.listItem:has(.checkboxContainer input:checked) .checkboxContainer {
+  opacity: 1;
+}
+
+.left {
+  flex: 1;
+}
+
+.right {
+  align-self: center;
+  padding-right: 24px;
+}
+
+.right > * {
+  pointer-events: all;
+}
diff --git a/js_modules/dagster-ui/packages/ui-components/src/index.ts b/js_modules/dagster-ui/packages/ui-components/src/index.ts
index ba72a801271cf..04d1b555887d4 100644
--- a/js_modules/dagster-ui/packages/ui-components/src/index.ts
+++ b/js_modules/dagster-ui/packages/ui-components/src/index.ts
@@ -17,8 +17,11 @@ export * from './components/Dialog';
 export * from './components/DisclosureTriangleButton';
 export * from './components/ErrorBoundary';
 export * from './components/Group';
+export * from './components/HorizontalControls';
+export * from './components/HoverButton';
 export * from './components/Icon';
 export * from './components/ifPlural';
+export * from './components/ListItem';
 export * from './components/MainContent';
 export * from './components/Menu';
 export * from './components/MetadataTable';
diff --git a/js_modules/dagster-ui/packages/ui-core/client.json b/js_modules/dagster-ui/packages/ui-core/client.json
index 5182e0ecdda21..6893bdede65b3 100644
--- a/js_modules/dagster-ui/packages/ui-core/client.json
+++ b/js_modules/dagster-ui/packages/ui-core/client.json
@@ -175,8 +175,8 @@
   "TypeExplorerContainerQuery": "40bdf55720a8542ddb727910ef82280a8e8e2300d3cc1fb9df11a7d8d957ee06",
   "TypeListContainerQuery": "c92c874af7b1e7be221281fa265743a9c426f909ffc7500f302540ef9a6cf8f2",
   "GraphExplorerRootQuery": "783b0fe0cd1e949b5bbe312bc8025f2085c691a96772a7a5b478394b6da38cf1",
-  "SingleNonSDAAssetQuery": "29857d908046c996dc1d44a6b08b20d025aa63a0cd689a5962d29cca71d12894",
   "SingleJobQuery": "5ff8f070e59507f5369f1a19abb9a72cfa12439ab04a08dc340866885f6e4702",
+  "SingleNonSDAAssetQuery": "29857d908046c996dc1d44a6b08b20d025aa63a0cd689a5962d29cca71d12894",
   "SingleScheduleQuery": "508a47e32ce04ba5be52c66cd592b74147bf98ec85b9f5d0e4db45172bd9a897",
   "SingleSensorQuery": "dbda5ba47d4ba10f8c527c9a7cd45fba0811276441a17a8ac6f173ed588f025b",
   "WorkspaceAssetsQuery": "908c14a37f5b6e18787dc75b91df4ea06a7aa452a342809a85f099336bc91705",
diff --git a/js_modules/dagster-ui/packages/ui-core/src/instance/JobMenu.tsx b/js_modules/dagster-ui/packages/ui-core/src/instance/JobMenu.tsx
index 7ec4c7179df55..b9298e87ef57d 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/instance/JobMenu.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/instance/JobMenu.tsx
@@ -156,7 +156,7 @@ export const JobMenu = (props: Props) => {
         }
         position="bottom-left"
       >
-        <Button icon={<Icon name="expand_more" />} />
+        <Button icon={<Icon name="more_horiz" />} intent="none" />
       </Popover>
     </>
   );
diff --git a/js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx b/js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx
index e75f5c9adb82a..7df2ece2c3c70 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx
@@ -164,7 +164,7 @@ export const JobsPageContent = () => {
         </Box>
       ) : null}
       {loading && !repoCount ? (
-        <Box padding={64}>
+        <Box padding={64} flex={{direction: 'row', justifyContent: 'center'}}>
           <SpinnerWithText label="Loading jobs…" />
         </Box>
       ) : (
diff --git a/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx b/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
index 61a22adc034a4..b762d11ea3f02 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
@@ -3,11 +3,13 @@ import {useVirtualizer} from '@tanstack/react-virtual';
 import {useMemo, useRef} from 'react';
 
 import {OVERVIEW_COLLAPSED_KEY} from './OverviewExpansionKey';
+import {useFeatureFlags} from '../app/Flags';
 import {Container, Inner, TABLE_HEADER_HEIGHT} from '../ui/VirtualizedTable';
 import {findDuplicateRepoNames} from '../ui/findDuplicateRepoNames';
 import {useRepoExpansionState} from '../ui/useRepoExpansionState';
 import {VirtualizedJobHeader, VirtualizedJobRow} from '../workspace/VirtualizedJobRow';
-import {RepoRow} from '../workspace/VirtualizedWorkspaceTable';
+import {VirtualizedObserveJobRow} from '../workspace/VirtualizedObserveJobRow';
+import {DynamicRepoRow} from '../workspace/VirtualizedWorkspaceTable';
 import {repoAddressAsHumanString} from '../workspace/repoAddressAsString';
 import {RepoAddress} from '../workspace/types';
 
@@ -28,6 +30,7 @@ type RowType =
   | {type: 'job'; repoAddress: RepoAddress; isJob: boolean; name: string};
 
 export const OverviewJobsTable = ({repos}: Props) => {
+  const {flagUseNewObserveUIs} = useFeatureFlags();
   const parentRef = useRef<HTMLDivElement | null>(null);
   const allKeys = useMemo(
     () => repos.map(({repoAddress}) => repoAddressAsHumanString(repoAddress)),
@@ -71,41 +74,69 @@ export const OverviewJobsTable = ({repos}: Props) => {
   return (
     <div style={{overflow: 'hidden'}}>
       <Container ref={parentRef}>
-        <VirtualizedJobHeader />
+        {flagUseNewObserveUIs ? null : <VirtualizedJobHeader />}
         <Inner $totalHeight={totalHeight}>
-          {items.map(({index, key, size, start}) => {
-            const row: RowType = flattened[index]!;
-            const type = row!.type;
-            return type === 'header' ? (
-              <RepoRow
-                repoAddress={row.repoAddress}
-                key={key}
-                height={size}
-                start={start}
-                onToggle={onToggle}
-                onToggleAll={onToggleAll}
-                expanded={expandedKeys.includes(repoAddressAsHumanString(row.repoAddress))}
-                showLocation={duplicateRepoNames.has(row.repoAddress.name)}
-                rightElement={
-                  <Tooltip
-                    content={row.jobCount === 1 ? '1 job' : `${row.jobCount} jobs`}
-                    placement="top"
-                  >
-                    <Tag>{row.jobCount}</Tag>
-                  </Tooltip>
-                }
-              />
-            ) : (
-              <VirtualizedJobRow
-                key={key}
-                name={row.name}
-                isJob={row.isJob}
-                repoAddress={row.repoAddress}
-                height={size}
-                start={start}
-              />
-            );
-          })}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${items[0]?.start ?? 0}px)`,
+            }}
+          >
+            {items.map(({index, key}) => {
+              const row: RowType = flattened[index]!;
+              const type = row!.type;
+
+              if (type === 'header') {
+                return (
+                  <DynamicRepoRow
+                    key={key}
+                    repoAddress={row.repoAddress}
+                    ref={rowVirtualizer.measureElement}
+                    index={index}
+                    onToggle={onToggle}
+                    onToggleAll={onToggleAll}
+                    expanded={expandedKeys.includes(repoAddressAsHumanString(row.repoAddress))}
+                    showLocation={duplicateRepoNames.has(row.repoAddress.name)}
+                    rightElement={
+                      <Tooltip
+                        content={row.jobCount === 1 ? '1 job' : `${row.jobCount} jobs`}
+                        placement="top"
+                      >
+                        <Tag>{row.jobCount}</Tag>
+                      </Tooltip>
+                    }
+                  />
+                );
+              }
+
+              if (flagUseNewObserveUIs) {
+                return (
+                  <VirtualizedObserveJobRow
+                    key={key}
+                    index={index}
+                    ref={rowVirtualizer.measureElement}
+                    name={row.name}
+                    isJob={row.isJob}
+                    repoAddress={row.repoAddress}
+                  />
+                );
+              }
+
+              return (
+                <VirtualizedJobRow
+                  key={key}
+                  index={index}
+                  ref={rowVirtualizer.measureElement}
+                  name={row.name}
+                  isJob={row.isJob}
+                  repoAddress={row.repoAddress}
+                />
+              );
+            })}
+          </div>
         </Inner>
       </Container>
     </div>
diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/RunStatusPez.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/RunStatusPez.tsx
index c872313221cc8..b82e4aaa9df56 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/runs/RunStatusPez.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/runs/RunStatusPez.tsx
@@ -31,35 +31,48 @@ interface ListProps {
   fade: boolean;
   jobName: string;
   runs: RunTimeFragment[];
+  forceCount?: number;
 }
 
 export const RunStatusPezList = (props: ListProps) => {
-  const {fade, jobName, runs} = props;
+  const {fade, jobName, runs, forceCount} = props;
   const count = runs.length;
   const countForStep = Math.max(MIN_OPACITY_STEPS, count);
   const step = (MAX_OPACITY - MIN_OPACITY) / countForStep;
+
+  let items: (RunTimeFragment | null)[] = [...runs];
+  if (forceCount) {
+    if (forceCount > items.length) {
+      items.unshift(...Array(forceCount - items.length).fill(null));
+    } else {
+      items = items.slice(0, forceCount);
+    }
+  }
+
   return (
     <Box flex={{direction: 'row', alignItems: 'center', gap: 2}}>
-      {runs.map((run, ii) => (
-        <Popover
-          key={run.id}
-          position="top"
-          interactionKind="hover"
-          content={
-            <div>
-              <RunStatusOverlay run={run} name={jobName} />
-            </div>
-          }
-          hoverOpenDelay={100}
-        >
-          <RunStatusPez
+      {items.map((run, ii) => {
+        const opacity = fade ? MAX_OPACITY - (count - ii - 1) * step : 1.0;
+        if (!run) {
+          return <Pez key={`empty-${ii}`} $color={Colors.backgroundLighter()} $opacity={opacity} />;
+        }
+
+        return (
+          <Popover
             key={run.id}
-            runId={run.id}
-            status={run.status}
-            opacity={fade ? MAX_OPACITY - (count - ii - 1) * step : 1.0}
-          />
-        </Popover>
-      ))}
+            position="top"
+            interactionKind="hover"
+            content={
+              <div>
+                <RunStatusOverlay run={run} name={jobName} />
+              </div>
+            }
+            hoverOpenDelay={100}
+          >
+            <RunStatusPez key={run.id} runId={run.id} status={run.status} opacity={opacity} />
+          </Popover>
+        );
+      })}
     </Box>
   );
 };
@@ -97,7 +110,7 @@ export const RunStatusOverlay = ({name, run}: OverlayProps) => {
 const OverlayContainer = styled.div`
   padding: 4px;
   font-size: 12px;
-  width: 220px;
+  width: 240px;
 `;
 
 const OverlayTitle = styled.div`
diff --git a/js_modules/dagster-ui/packages/ui-core/src/runs/__stories__/RunStatusPez.stories.tsx b/js_modules/dagster-ui/packages/ui-core/src/runs/__stories__/RunStatusPez.stories.tsx
index f79c04a45afcf..30efed7153b2f 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/runs/__stories__/RunStatusPez.stories.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/runs/__stories__/RunStatusPez.stories.tsx
@@ -66,6 +66,12 @@ export const List = () => {
           fade
           runs={wrapToFragment(generateRunMocks(10, [tenDaysAgo, now]))}
         />
+        <RunStatusPezList
+          jobName={fakeRepo}
+          fade
+          runs={wrapToFragment(generateRunMocks(4, [tenDaysAgo, now]))}
+          forceCount={10}
+        />
         <RunStatusPezList
           jobName={fakeRepo}
           fade
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/SingleJobQuery.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/SingleJobQuery.tsx
new file mode 100644
index 0000000000000..f6f1eef70690a
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/SingleJobQuery.tsx
@@ -0,0 +1,34 @@
+import {gql} from '../apollo-client';
+import {RUN_TIME_FRAGMENT} from '../runs/RunUtils';
+import {SCHEDULE_SWITCH_FRAGMENT} from '../schedules/ScheduleSwitchFragment';
+import {SENSOR_SWITCH_FRAGMENT} from '../sensors/SensorSwitchFragment';
+
+export const SINGLE_JOB_QUERY = gql`
+  query SingleJobQuery($selector: PipelineSelector!) {
+    pipelineOrError(params: $selector) {
+      ... on Pipeline {
+        id
+        name
+        isJob
+        isAssetJob
+        description
+        runs(limit: 5) {
+          id
+          ...RunTimeFragment
+        }
+        schedules {
+          id
+          ...ScheduleSwitchFragment
+        }
+        sensors {
+          id
+          ...SensorSwitchFragment
+        }
+      }
+    }
+  }
+
+  ${RUN_TIME_FRAGMENT}
+  ${SCHEDULE_SWITCH_FRAGMENT}
+  ${SENSOR_SWITCH_FRAGMENT}
+`;
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedJobRow.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedJobRow.tsx
index 883c1c2ce93ce..7f107444438f1 100644
--- a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedJobRow.tsx
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedJobRow.tsx
@@ -1,12 +1,12 @@
 import {Box, MiddleTruncate, useDelayedState} from '@dagster-io/ui-components';
-import {useMemo} from 'react';
+import {forwardRef, useMemo} from 'react';
 import {Link} from 'react-router-dom';
 import styled from 'styled-components';
 
 import {CaptionText, LoadingOrNone} from './VirtualizedWorkspaceTable';
 import {buildPipelineSelector} from './WorkspaceContext/util';
 import {RepoAddress} from './types';
-import {SingleJobQuery, SingleJobQueryVariables} from './types/VirtualizedJobRow.types';
+import {SingleJobQuery, SingleJobQueryVariables} from './types/SingleJobQuery.types';
 import {workspacePathFromAddress} from './workspacePath';
 import {gql, useQuery} from '../apollo-client';
 import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../app/QueryRefresh';
@@ -17,114 +17,115 @@ import {RunStatusPezList} from '../runs/RunStatusPez';
 import {RUN_TIME_FRAGMENT} from '../runs/RunUtils';
 import {SCHEDULE_SWITCH_FRAGMENT} from '../schedules/ScheduleSwitchFragment';
 import {SENSOR_SWITCH_FRAGMENT} from '../sensors/SensorSwitchFragment';
-import {HeaderCell, HeaderRow, Row, RowCell} from '../ui/VirtualizedTable';
+import {HeaderCell, HeaderRow, RowCell} from '../ui/VirtualizedTable';
 
 const TEMPLATE_COLUMNS = '1.5fr 1fr 180px 96px 80px';
 
 interface JobRowProps {
   name: string;
+  index: number;
   isJob: boolean;
   repoAddress: RepoAddress;
-  height: number;
-  start: number;
 }
 
-export const VirtualizedJobRow = (props: JobRowProps) => {
-  const {name, isJob, repoAddress, start, height} = props;
+export const VirtualizedJobRow = forwardRef(
+  (props: JobRowProps, ref: React.ForwardedRef<HTMLDivElement>) => {
+    const {name, isJob, repoAddress, index} = props;
 
-  // Wait 100ms before querying in case we're scrolling the table really fast
-  const shouldQuery = useDelayedState(100);
-  const queryResult = useQuery<SingleJobQuery, SingleJobQueryVariables>(SINGLE_JOB_QUERY, {
-    variables: {
-      selector: buildPipelineSelector(repoAddress, name),
-    },
-    skip: !shouldQuery,
-  });
-  useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
+    // Wait 100ms before querying in case we're scrolling the table really fast
+    const shouldQuery = useDelayedState(100);
+    const queryResult = useQuery<SingleJobQuery, SingleJobQueryVariables>(SINGLE_JOB_QUERY, {
+      variables: {
+        selector: buildPipelineSelector(repoAddress, name),
+      },
+      skip: !shouldQuery,
+    });
+    useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
 
-  const {data} = queryResult;
-  const pipeline =
-    data?.pipelineOrError.__typename === 'Pipeline' ? data?.pipelineOrError : undefined;
+    const {data} = queryResult;
+    const pipeline =
+      data?.pipelineOrError.__typename === 'Pipeline' ? data?.pipelineOrError : undefined;
 
-  const {schedules, sensors} = useMemo(() => {
-    if (pipeline) {
-      const {schedules, sensors} = pipeline;
-      return {schedules, sensors};
-    }
-    return {schedules: [], sensors: []};
-  }, [pipeline]);
+    const {schedules, sensors} = useMemo(() => {
+      if (pipeline) {
+        const {schedules, sensors} = pipeline;
+        return {schedules, sensors};
+      }
+      return {schedules: [], sensors: []};
+    }, [pipeline]);
 
-  const latestRuns = useMemo(() => {
-    if (pipeline) {
-      const {runs} = pipeline;
-      if (runs.length) {
-        return [...runs];
+    const latestRuns = useMemo(() => {
+      if (pipeline) {
+        const {runs} = pipeline;
+        if (runs.length) {
+          return [...runs];
+        }
       }
-    }
-    return [];
-  }, [pipeline]);
+      return [];
+    }, [pipeline]);
 
-  return (
-    <Row $height={height} $start={start}>
-      <RowGrid border="bottom">
-        <RowCell>
-          <div style={{maxWidth: '100%', whiteSpace: 'nowrap', fontWeight: 500}}>
-            <Link to={workspacePathFromAddress(repoAddress, `/jobs/${name}`)}>
-              <MiddleTruncate text={name} />
-            </Link>
-          </div>
-          <CaptionText>{pipeline?.description || ''}</CaptionText>
-        </RowCell>
-        <RowCell>
-          {schedules.length || sensors.length ? (
-            <Box flex={{direction: 'column', alignItems: 'flex-start', gap: 8}}>
-              <ScheduleSensorTagContainer>
-                <ScheduleOrSensorTag
-                  schedules={schedules}
-                  sensors={sensors}
-                  repoAddress={repoAddress}
-                />
-              </ScheduleSensorTagContainer>
-            </Box>
-          ) : (
-            <LoadingOrNone queryResult={queryResult} />
-          )}
-        </RowCell>
-        <RowCell>
-          {latestRuns[0] ? (
-            <LastRunSummary
-              run={latestRuns[0]}
-              showButton={false}
-              showHover
-              showSummary={false}
-              name={name}
-            />
-          ) : (
-            <LoadingOrNone queryResult={queryResult} />
-          )}
-        </RowCell>
-        <RowCell>
-          {latestRuns.length ? (
-            <Box padding={{top: 4}}>
-              <RunStatusPezList jobName={name} runs={[...latestRuns].reverse()} fade />
+    return (
+      <div data-index={index} ref={ref}>
+        <RowGrid border="bottom">
+          <RowCell>
+            <div style={{maxWidth: '100%', whiteSpace: 'nowrap', fontWeight: 500}}>
+              <Link to={workspacePathFromAddress(repoAddress, `/jobs/${name}`)}>
+                <MiddleTruncate text={name} />
+              </Link>
+            </div>
+            <CaptionText>{pipeline?.description || ''}</CaptionText>
+          </RowCell>
+          <RowCell>
+            {schedules.length || sensors.length ? (
+              <Box flex={{direction: 'column', alignItems: 'flex-start', gap: 8}}>
+                <ScheduleSensorTagContainer>
+                  <ScheduleOrSensorTag
+                    schedules={schedules}
+                    sensors={sensors}
+                    repoAddress={repoAddress}
+                  />
+                </ScheduleSensorTagContainer>
+              </Box>
+            ) : (
+              <LoadingOrNone queryResult={queryResult} />
+            )}
+          </RowCell>
+          <RowCell>
+            {latestRuns[0] ? (
+              <LastRunSummary
+                run={latestRuns[0]}
+                showButton={false}
+                showHover
+                showSummary={false}
+                name={name}
+              />
+            ) : (
+              <LoadingOrNone queryResult={queryResult} />
+            )}
+          </RowCell>
+          <RowCell>
+            {latestRuns.length ? (
+              <Box padding={{top: 4}}>
+                <RunStatusPezList jobName={name} runs={[...latestRuns].reverse()} fade />
+              </Box>
+            ) : (
+              <LoadingOrNone queryResult={queryResult} />
+            )}
+          </RowCell>
+          <RowCell>
+            <Box flex={{justifyContent: 'flex-end'}} style={{marginTop: '-2px'}}>
+              <JobMenu
+                job={{name, isJob, runs: latestRuns}}
+                isAssetJob={pipeline ? pipeline.isAssetJob : 'loading'}
+                repoAddress={repoAddress}
+              />
             </Box>
-          ) : (
-            <LoadingOrNone queryResult={queryResult} />
-          )}
-        </RowCell>
-        <RowCell>
-          <Box flex={{justifyContent: 'flex-end'}} style={{marginTop: '-2px'}}>
-            <JobMenu
-              job={{name, isJob, runs: latestRuns}}
-              isAssetJob={pipeline ? pipeline.isAssetJob : 'loading'}
-              repoAddress={repoAddress}
-            />
-          </Box>
-        </RowCell>
-      </RowGrid>
-    </Row>
-  );
-};
+          </RowCell>
+        </RowGrid>
+      </div>
+    );
+  },
+);
 
 export const VirtualizedJobHeader = () => {
   return (
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedObserveJobRow.tsx b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedObserveJobRow.tsx
new file mode 100644
index 0000000000000..e78b278d50d10
--- /dev/null
+++ b/js_modules/dagster-ui/packages/ui-core/src/workspace/VirtualizedObserveJobRow.tsx
@@ -0,0 +1,316 @@
+import {
+  Box,
+  Colors,
+  HorizontalControls,
+  HoverButton,
+  Icon,
+  ListItem,
+  MiddleTruncate,
+  Popover,
+  Skeleton,
+  Tooltip,
+  useDelayedState,
+} from '@dagster-io/ui-components';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import {forwardRef, useMemo} from 'react';
+import {Link} from 'react-router-dom';
+
+import {SINGLE_JOB_QUERY} from './SingleJobQuery';
+import {SingleJobQuery, SingleJobQueryVariables} from './types/SingleJobQuery.types';
+import {JobMenu} from '../instance/JobMenu';
+import {RunStatusIndicator} from '../runs/RunStatusDots';
+import {RunStatusOverlay, RunStatusPezList} from '../runs/RunStatusPez';
+import {buildPipelineSelector} from './WorkspaceContext/util';
+import {RepoAddress} from './types';
+import {workspacePathFromAddress} from './workspacePath';
+import {useQuery} from '../apollo-client';
+import {FIFTEEN_SECONDS, useQueryRefreshAtInterval} from '../app/QueryRefresh';
+import {ScheduleSwitch} from '../schedules/ScheduleSwitch';
+import {humanCronString} from '../schedules/humanCronString';
+import {ScheduleSwitchFragment} from '../schedules/types/ScheduleSwitchFragment.types';
+import {SensorSwitch} from '../sensors/SensorSwitch';
+import {SensorSwitchFragment} from '../sensors/types/SensorSwitchFragment.types';
+
+dayjs.extend(relativeTime);
+
+interface JobRowProps {
+  index: number;
+  name: string;
+  isJob: boolean;
+  repoAddress: RepoAddress;
+}
+
+export const VirtualizedObserveJobRow = forwardRef(
+  (props: JobRowProps, ref: React.ForwardedRef<HTMLDivElement>) => {
+    const {index, name, isJob, repoAddress} = props;
+
+    // Wait 100ms before querying in case we're scrolling the table really fast
+    const shouldQuery = useDelayedState(100);
+    const queryResult = useQuery<SingleJobQuery, SingleJobQueryVariables>(SINGLE_JOB_QUERY, {
+      variables: {
+        selector: buildPipelineSelector(repoAddress, name),
+      },
+      skip: !shouldQuery,
+    });
+    useQueryRefreshAtInterval(queryResult, FIFTEEN_SECONDS);
+
+    const {data} = queryResult;
+    const pipeline =
+      data?.pipelineOrError.__typename === 'Pipeline' ? data?.pipelineOrError : undefined;
+
+    const {schedules, sensors} = useMemo(() => {
+      if (pipeline) {
+        const {schedules, sensors} = pipeline;
+        return {schedules, sensors};
+      }
+      return {schedules: [], sensors: []};
+    }, [pipeline]);
+
+    const latestRuns = useMemo(() => {
+      if (pipeline) {
+        const {runs} = pipeline;
+        if (runs.length) {
+          return [...runs];
+        }
+      }
+      return [];
+    }, [pipeline]);
+
+    const right = () => {
+      if (queryResult.loading && !queryResult.data) {
+        return <Skeleton $width={200} $height={24} />;
+      }
+
+      return (
+        <HorizontalControls
+          controls={[
+            {
+              key: 'latest-run',
+              control: latestRuns[0]?.startTime ? (
+                <Popover
+                  key={latestRuns[0].id}
+                  position="top"
+                  interactionKind="hover"
+                  content={
+                    <div>
+                      <RunStatusOverlay run={latestRuns[0]} name={name} />
+                    </div>
+                  }
+                  hoverOpenDelay={100}
+                >
+                  <HoverButton>
+                    <Box flex={{direction: 'row', alignItems: 'center', gap: 8}}>
+                      <RunStatusIndicator status={latestRuns[0].status} />
+                      {dayjs(latestRuns[0].startTime * 1000).fromNow()}
+                    </Box>
+                  </HoverButton>
+                </Popover>
+              ) : null,
+            },
+            {
+              key: 'schedules',
+              control:
+                schedules.length > 0 ? (
+                  <AutomationButton
+                    type="schedule"
+                    enabled={schedules.some(
+                      (schedule) => schedule.scheduleState.status === 'RUNNING',
+                    )}
+                    automations={schedules}
+                    repoAddress={repoAddress}
+                  />
+                ) : null,
+            },
+            {
+              key: 'sensors',
+              control:
+                sensors.length > 0 ? (
+                  <AutomationButton
+                    type="sensor"
+                    enabled={sensors.some((sensor) => sensor.sensorState.status === 'RUNNING')}
+                    automations={sensors}
+                    repoAddress={repoAddress}
+                  />
+                ) : null,
+            },
+            {
+              key: 'automations',
+              control:
+                sensors.length === 0 && schedules.length === 0 ? (
+                  <Tooltip content="No automations" placement="top">
+                    <AutomationButton type="none" />
+                  </Tooltip>
+                ) : null,
+            },
+            {
+              key: 'runs',
+              control: (
+                <Box padding={{horizontal: 8}}>
+                  <RunStatusPezList
+                    jobName={name}
+                    runs={[...latestRuns].reverse()}
+                    fade
+                    forceCount={5}
+                  />
+                </Box>
+              ),
+            },
+            {
+              key: 'menu',
+              control: (
+                <JobMenu
+                  job={{name, isJob, runs: latestRuns}}
+                  isAssetJob={pipeline ? pipeline.isAssetJob : 'loading'}
+                  repoAddress={repoAddress}
+                />
+              ),
+            },
+          ]}
+        />
+      );
+    };
+
+    return (
+      <ListItem
+        ref={ref}
+        index={index}
+        href={workspacePathFromAddress(repoAddress, `/jobs/${name}`)}
+        renderLink={({href, ...props}) => <Link to={href || '#'} {...props} />}
+        left={
+          <Box flex={{direction: 'row', gap: 12, alignItems: 'center'}}>
+            <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}}>
+              <Icon name="job" />
+              {name}
+            </Box>
+            {pipeline?.description ? (
+              <Tooltip
+                content={<div style={{width: 320}}>{pipeline.description}</div>}
+                placement="top"
+              >
+                <Icon name="info" color={Colors.textLight()} />
+              </Tooltip>
+            ) : null}
+          </Box>
+        }
+        right={right()}
+      />
+    );
+  },
+);
+
+type AutomationType = 'sensor' | 'schedule' | 'none';
+
+interface AutomationButtonProps {
+  type: AutomationType;
+  enabled?: boolean;
+  automations?: ScheduleSwitchFragment[] | SensorSwitchFragment[];
+  repoAddress?: RepoAddress;
+}
+
+const AutomationButton = ({type, automations, enabled, repoAddress}: AutomationButtonProps) => {
+  const icon = () => {
+    if (type === 'none') {
+      return <Icon name="status" color={Colors.accentGray()} />;
+    }
+
+    return <Icon name={type} color={enabled ? Colors.accentGreen() : Colors.accentGray()} />;
+  };
+
+  const button = (
+    <HoverButton>
+      <Box padding={{vertical: 2}}>{icon()}</Box>
+    </HoverButton>
+  );
+
+  if (type === 'none') {
+    return button;
+  }
+
+  const count = automations?.length || 0;
+  const headerText =
+    type === 'sensor'
+      ? count === 1
+        ? '1 sensor'
+        : `${count} sensors`
+      : count === 1
+        ? '1 schedule'
+        : `${count} schedules`;
+
+  return (
+    <Popover
+      position="top"
+      interactionKind="hover"
+      content={
+        <Box
+          flex={{direction: 'column'}}
+          style={{width: 320, overflowX: 'hidden'}}
+          background={Colors.backgroundLighter()}
+        >
+          <Box
+            flex={{direction: 'row', gap: 8, alignItems: 'center'}}
+            padding={{vertical: 12, horizontal: 12}}
+            border="bottom"
+          >
+            <strong style={{fontSize: 14}}>{headerText}</strong>
+          </Box>
+          <div style={{overflowY: 'auto', maxHeight: 240}}>
+            {automations?.length && repoAddress ? (
+              automations.map((automation, ii) => {
+                const displayName =
+                  automation.__typename === 'Sensor'
+                    ? automation.name
+                    : humanCronString(automation.cronSchedule, {
+                        longTimezoneName: automation.executionTimezone || 'UTC',
+                      });
+                return (
+                  <Box
+                    key={automation.id + '-' + ii}
+                    flex={{
+                      direction: 'row',
+                      gap: 12,
+                      alignItems: 'center',
+                      justifyContent: 'space-between',
+                    }}
+                    padding={12}
+                    border={ii === 0 ? null : 'top'}
+                  >
+                    <Box
+                      flex={{direction: 'row', alignItems: 'center', gap: 8}}
+                      style={{width: '100%', overflow: 'hidden'}}
+                    >
+                      <Icon name={automation.__typename === 'Schedule' ? 'schedule' : 'sensor'} />
+                      <Link
+                        to={workspacePathFromAddress(
+                          repoAddress,
+                          automation.__typename === 'Schedule'
+                            ? `/schedules/${automation.name}`
+                            : `/sensors/${automation.name}`,
+                        )}
+                        style={{flex: 1, overflow: 'hidden', width: '100%'}}
+                      >
+                        <MiddleTruncate text={displayName} />
+                      </Link>
+                    </Box>
+                    <Box flex={{direction: 'row', alignItems: 'center', gap: 8}}>
+                      {automation.__typename === 'Schedule' ? (
+                        <ScheduleSwitch schedule={automation} repoAddress={repoAddress} />
+                      ) : (
+                        <SensorSwitch sensor={automation} repoAddress={repoAddress} />
+                      )}
+                    </Box>
+                  </Box>
+                );
+              })
+            ) : (
+              <div>No automations</div>
+            )}
+          </div>
+        </Box>
+      }
+      hoverOpenDelay={100}
+    >
+      {button}
+    </Popover>
+  );
+};
diff --git a/js_modules/dagster-ui/packages/ui-core/src/workspace/types/VirtualizedJobRow.types.ts b/js_modules/dagster-ui/packages/ui-core/src/workspace/types/SingleJobQuery.types.ts
similarity index 100%
rename from js_modules/dagster-ui/packages/ui-core/src/workspace/types/VirtualizedJobRow.types.ts
rename to js_modules/dagster-ui/packages/ui-core/src/workspace/types/SingleJobQuery.types.ts
diff --git a/js_modules/dagster-ui/yarn.lock b/js_modules/dagster-ui/yarn.lock
index 208ab0f5ea838..aa4e0a2b732e7 100644
--- a/js_modules/dagster-ui/yarn.lock
+++ b/js_modules/dagster-ui/yarn.lock
@@ -3756,6 +3756,7 @@ __metadata:
     babel-loader: "npm:^9.1.2"
     babel-plugin-macros: "npm:^3.1.0"
     buildkite-test-collector: "npm:^1.8.1"
+    clsx: "npm:^2.1.1"
     codemirror: "npm:^5.65.2"
     core-js: "npm:^3.21.1"
     deepmerge: "npm:^4.2.2"


Reviews:
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T18:49:53Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:36:53Z
user: login: salazarm
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:40:54Z
user: login: salazarm
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:41:48Z
user: login: hellendag
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T17:54:45Z

Review comments:
diff_hunk: @@ -0,0 +1,40 @@
+import {ChangeEvent, HTMLProps, ReactNode, forwardRef} from 'react';
+
+import {Checkbox} from './Checkbox';
+import styles from './css/ListItem.module.css';
+
+interface Props {
+  index: number;
+  checked?: boolean;
+  onToggle?: (checked: boolean) => void;
+  href: string;
+  renderLink: (props: HTMLProps<HTMLAnchorElement>) => ReactNode;
path: js_modules/dagster-ui/packages/ui-components/src/components/ListItem.tsx
user: login: hellendag
body: Feels a little bad and I could avoid this by moving the component into `ui-core`. Lmk if you have strong opinions.
created_at: 2025-05-07T18:46:54Z
author_association: MEMBER
diff_hunk: @@ -35,6 +35,7 @@
   "dependencies": {
     "@react-hook/resize-observer": "^1.2.6",
     "amator": "^1.1.0",
+    "clsx": "^2.1.1",
path: js_modules/dagster-ui/packages/ui-components/package.json
user: login: hellendag
body: Didn't end up using this in the PR, but we'll use it soon enough now that we're using more CSS modules.
created_at: 2025-05-07T18:47:09Z
author_association: MEMBER
diff_hunk: @@ -164,7 +164,7 @@ export const JobsPageContent = () => {
         </Box>
       ) : null}
       {loading && !repoCount ? (
-        <Box padding={64}>
+        <Box padding={64} flex={{direction: 'row', justifyContent: 'center'}}>
           <SpinnerWithText label="Loading jobs…" />
         </Box>
path: js_modules/dagster-ui/packages/ui-core/src/jobs/JobsPageContent.tsx
user: login: hellendag
body: I think maybe this has been left-aligned (incorrectly) for a while, but we haven't noticed because we're not looking at Jobs as often as we used to.
created_at: 2025-05-07T18:48:07Z
author_association: MEMBER
diff_hunk: @@ -71,41 +74,69 @@ export const OverviewJobsTable = ({repos}: Props) => {
   return (
     <div style={{overflow: 'hidden'}}>
       <Container ref={parentRef}>
-        <VirtualizedJobHeader />
+        {flagUseNewObserveUIs ? null : <VirtualizedJobHeader />}
         <Inner $totalHeight={totalHeight}>
-          {items.map(({index, key, size, start}) => {
-            const row: RowType = flattened[index]!;
-            const type = row!.type;
-            return type === 'header' ? (
-              <RepoRow
-                repoAddress={row.repoAddress}
-                key={key}
-                height={size}
-                start={start}
-                onToggle={onToggle}
-                onToggleAll={onToggleAll}
-                expanded={expandedKeys.includes(repoAddressAsHumanString(row.repoAddress))}
-                showLocation={duplicateRepoNames.has(row.repoAddress.name)}
-                rightElement={
-                  <Tooltip
-                    content={row.jobCount === 1 ? '1 job' : `${row.jobCount} jobs`}
-                    placement="top"
-                  >
-                    <Tag>{row.jobCount}</Tag>
-                  </Tooltip>
-                }
-              />
-            ) : (
-              <VirtualizedJobRow
-                key={key}
-                name={row.name}
-                isJob={row.isJob}
-                repoAddress={row.repoAddress}
-                height={size}
-                start={start}
-              />
-            );
-          })}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${items[0]?.start ?? 0}px)`,
+            }}
path: js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
user: login: hellendag
body: This windowing approach matches what is done in tanstack example code.
created_at: 2025-05-07T18:48:40Z
author_association: MEMBER
diff_hunk: @@ -31,35 +31,48 @@ interface ListProps {
   fade: boolean;
   jobName: string;
   runs: RunTimeFragment[];
+  forceCount?: number;
 }
 
 export const RunStatusPezList = (props: ListProps) => {
-  const {fade, jobName, runs} = props;
+  const {fade, jobName, runs, forceCount} = props;
   const count = runs.length;
   const countForStep = Math.max(MIN_OPACITY_STEPS, count);
   const step = (MAX_OPACITY - MIN_OPACITY) / countForStep;
+
+  let items: (RunTimeFragment | null)[] = [...runs];
+  if (forceCount) {
+    if (forceCount > items.length) {
+      items.unshift(...Array(forceCount - items.length).fill(null));
+    } else {
+      items = items.slice(0, forceCount);
+    }
+  }
path: js_modules/dagster-ui/packages/ui-core/src/runs/RunStatusPez.tsx
user: login: hellendag
body: A new change to `RunStatusPezList`. @braunjj asks that the pez list occupy the same amount of space even if there are fewer than N items in the list, so this allows us to fill the space with placeholder pez items.
created_at: 2025-05-07T18:49:24Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,18 @@
+import {Children, ReactNode} from 'react';
+
+import {Box} from './Box';
+import styles from './css/HorizontalControls.module.css';
+
+interface Props {
+  children: ReactNode;
+}
+
+export const HorizontalControls = ({children}: Props) => {
+  return (
+    <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}} className={styles.container}>
+      {Children.map(children, (child) =>
path: js_modules/dagster-ui/packages/ui-components/src/components/HorizontalControls.tsx
user: login: salazarm
body: hmm, not a big fan of using children this way, would rather just have a prop of ReactNode[] | ReactNode I guess?
created_at: 2025-05-08T17:36:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -71,41 +74,69 @@ export const OverviewJobsTable = ({repos}: Props) => {
   return (
     <div style={{overflow: 'hidden'}}>
       <Container ref={parentRef}>
-        <VirtualizedJobHeader />
+        {flagUseNewObserveUIs ? null : <VirtualizedJobHeader />}
         <Inner $totalHeight={totalHeight}>
-          {items.map(({index, key, size, start}) => {
-            const row: RowType = flattened[index]!;
-            const type = row!.type;
-            return type === 'header' ? (
-              <RepoRow
-                repoAddress={row.repoAddress}
-                key={key}
-                height={size}
-                start={start}
-                onToggle={onToggle}
-                onToggleAll={onToggleAll}
-                expanded={expandedKeys.includes(repoAddressAsHumanString(row.repoAddress))}
-                showLocation={duplicateRepoNames.has(row.repoAddress.name)}
-                rightElement={
-                  <Tooltip
-                    content={row.jobCount === 1 ? '1 job' : `${row.jobCount} jobs`}
-                    placement="top"
-                  >
-                    <Tag>{row.jobCount}</Tag>
-                  </Tooltip>
-                }
-              />
-            ) : (
-              <VirtualizedJobRow
-                key={key}
-                name={row.name}
-                isJob={row.isJob}
-                repoAddress={row.repoAddress}
-                height={size}
-                start={start}
-              />
-            );
-          })}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${items[0]?.start ?? 0}px)`,
+            }}
path: js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
user: login: salazarm
body: Is that a new change? Seems kind of weird but I guess it means you only need to dynamically modify the CSS of a single element. 
created_at: 2025-05-08T17:40:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,18 @@
+import {Children, ReactNode} from 'react';
+
+import {Box} from './Box';
+import styles from './css/HorizontalControls.module.css';
+
+interface Props {
+  children: ReactNode;
+}
+
+export const HorizontalControls = ({children}: Props) => {
+  return (
+    <Box flex={{direction: 'row', gap: 8, alignItems: 'center'}} className={styles.container}>
+      {Children.map(children, (child) =>
path: js_modules/dagster-ui/packages/ui-components/src/components/HorizontalControls.tsx
user: login: hellendag
body: Yeah, I'm fine with that too.
created_at: 2025-05-08T17:53:46Z
author_association: MEMBER
diff_hunk: @@ -71,41 +74,69 @@ export const OverviewJobsTable = ({repos}: Props) => {
   return (
     <div style={{overflow: 'hidden'}}>
       <Container ref={parentRef}>
-        <VirtualizedJobHeader />
+        {flagUseNewObserveUIs ? null : <VirtualizedJobHeader />}
         <Inner $totalHeight={totalHeight}>
-          {items.map(({index, key, size, start}) => {
-            const row: RowType = flattened[index]!;
-            const type = row!.type;
-            return type === 'header' ? (
-              <RepoRow
-                repoAddress={row.repoAddress}
-                key={key}
-                height={size}
-                start={start}
-                onToggle={onToggle}
-                onToggleAll={onToggleAll}
-                expanded={expandedKeys.includes(repoAddressAsHumanString(row.repoAddress))}
-                showLocation={duplicateRepoNames.has(row.repoAddress.name)}
-                rightElement={
-                  <Tooltip
-                    content={row.jobCount === 1 ? '1 job' : `${row.jobCount} jobs`}
-                    placement="top"
-                  >
-                    <Tag>{row.jobCount}</Tag>
-                  </Tooltip>
-                }
-              />
-            ) : (
-              <VirtualizedJobRow
-                key={key}
-                name={row.name}
-                isJob={row.isJob}
-                repoAddress={row.repoAddress}
-                height={size}
-                start={start}
-              />
-            );
-          })}
+          <div
+            style={{
+              position: 'absolute',
+              top: 0,
+              left: 0,
+              width: '100%',
+              transform: `translateY(${items[0]?.start ?? 0}px)`,
+            }}
path: js_modules/dagster-ui/packages/ui-core/src/overview/OverviewJobsTable.tsx
user: login: hellendag
body: Might be new, I'm not sure. It looked a little cleaner so I gave it a try and it seemed pretty good.
created_at: 2025-05-08T17:54:36Z
author_association: MEMBER

PR comments:
user: login: hellendag
created_at: 2025-05-07T18:40:07Z
body: * **#29849** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29849?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29849?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#29844** <a href="https://app.graphite.dev/github/pr/dagster-io/dagster/29844?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `master`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-07T18:42:11Z
body: Deploy preview for _dagit-storybook_ ready!

✅ Preview
https://dagit-storybook-k87g0ruyl-elementl.vercel.app
https://dish-observe-list-items.components-storybook.dagster-docs.io

Built with commit cbcff8ffc95f083a076352c2f5c341f216206650.
This pull request is being automatically deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-07T18:42:48Z
body: Deploy preview for _dagit-core-storybook_ ready!

✅ Preview
https://dagit-core-storybook-kmwk38zp9-elementl.vercel.app
https://dish-observe-list-items.core-storybook.dagster-docs.io

Built with commit cbcff8ffc95f083a076352c2f5c341f216206650.
This pull request is being automatically deployed with [vercel-action](https://github.com/marketplace/actions/vercel-action)
author_association: CONTRIBUTOR
user: login: hellendag
created_at: 2025-05-08T18:56:14Z
body: ### Merge activity

* **May 8, 1:56 PM CDT**: A user started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/29849).
* **May 8, 1:56 PM CDT**: @hellendag merged this pull request with [Graphite](https://app.graphite.dev/github/pr/dagster-io/dagster/29849).

author_association: MEMBER