feat(webapp): fix agent overview page scroll bug + layout fixes on task and agent pages (#4454)

## Summary

The task, scheduled task and agent pages now name their runs table with
its own title bar, and the controls that page the table sit beside it
rather than in the bar at the top of the page. The top bar keeps just
the date filter.

Two agent page layout bugs are fixed along the way: scrolling a wide
runs table sideways dragged the charts off screen with it, and the
details panel stopped short of the bottom of the window.

## Fix

The charts moved because the runs table had no horizontal scroller of
its own. `stickyHeader` swaps the table's `overflow-x-auto` for
`overflow-visible`, so the overflow escaped up to the page scroll box,
and setting only `overflow-y-auto` on that box leaves the computed
`overflow-x` at `visible`, which CSS then promotes to `auto`. The chart
grid is a sibling inside that box, so it scrolled too. The table now
keeps its own scroller (the same rule the queues list already documents)
and the page box clips x so this cannot recur.

The short panel was a second `PageContainer` wrapping the agent routes.
`PageContainer` is `grid-rows-[auto_1fr]`, so a lone child lands in the
`auto` row and its `h-full` resolves against content height instead of
the viewport.

This also reverts the global tooltip `max-w-[230px]` introduced in
[#4131](https://github.com/triggerdotdev/trigger.dev/pull/4131), so
longer tooltips are no longer squeezed into a narrow column.

### Agent overview page showing table now scrolling
<img width="3452" height="1648" alt="CleanShot 2026-08-01 at 12 04
38@2x"
src="https://github.com/user-attachments/assets/ef1ac55d-8ffb-4278-983b-031ed21c1f55"
/>
This commit is contained in:
James Ritchie
2026-08-01 16:26:26 +01:00
committed by GitHub
parent cb9aefd49b
commit 14824b0955
10 changed files with 236 additions and 130 deletions
@@ -175,10 +175,12 @@ function MetricsLayoutMain({ children, scroll }: { children: ReactNode; scroll:
return (
<div className="flex h-full min-h-0 flex-col">
{filters}
{/* overflow-x-clip: without it `overflow-y-auto` promotes x to auto and wide content drags
the charts sideways. Wide children must scroll in their own container. */}
<div
className={
scroll === "page"
? "flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto py-2.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
? "flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto overflow-x-clip py-2.5 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
: "flex min-h-0 flex-1 flex-col overflow-hidden"
}
>
@@ -286,7 +288,7 @@ function MetricsLayoutFilters({
return (
<div
className={cn(
"flex h-10 shrink-0 items-center justify-between gap-2 border-b border-grid-dimmed pl-2.5 pr-3",
"flex h-10 shrink-0 items-center justify-between gap-2 border-b border-grid-dimmed px-2",
className
)}
>
@@ -1,6 +1,6 @@
import { cn } from "~/utils/cn";
const headerVariants = {
export const headerVariants = {
header1: {
text: "font-sans text-2xl leading-5 md:leading-6 lg:leading-7 font-semibold tracking-tight",
spacing: "mb-2",
@@ -181,7 +181,7 @@ type TableCellBasicProps = {
type TableHeaderCellProps = TableCellBasicProps & {
hiddenLabel?: boolean;
tooltip?: ReactNode;
/** Extra class merged onto the tooltip content — e.g. widen it past the default max-width. */
/** Extra class merged onto the tooltip content. */
tooltipContentClassName?: string;
disableTooltipHoverableContent?: boolean;
/**
+73 -6
View File
@@ -3,9 +3,19 @@ import { motion } from "framer-motion";
import { type ReactNode, useRef } from "react";
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { cn } from "~/utils/cn";
import { headerVariants } from "./Headers";
import { ShortcutKey } from "./ShortcutKey";
export type Variants = "underline" | "pipe-divider" | "segmented";
/** `"title"` names the table below it: header2 text, filter-bar height, underline on the border. */
export type Variants = "underline" | "pipe-divider" | "segmented" | "title";
/** Shared with `TitleBar` so the tabbed and tab-less bars match. */
export const TITLE_BAR_CHROME = "flex h-10 shrink-0 gap-x-6 border-b border-grid-bright";
const titleTabLabel = cn(headerVariants.header2.text, "transition duration-200");
const titleTabIndicator = "h-0.5 w-full bg-indigo-500";
const titleTabIndicatorIdle =
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100";
export type TabsProps = {
tabs: {
@@ -58,6 +68,10 @@ export function TabContainer({
);
}
if (variant === "title") {
return <div className={cn(TITLE_BAR_CHROME, "items-stretch", className)}>{children}</div>;
}
if (variant === "underline") {
return (
<div className={cn(`flex gap-x-6 border-b border-grid-bright`, className)}>{children}</div>
@@ -117,6 +131,39 @@ export function TabLink({
);
}
if (variant === "title") {
return (
<NavLink to={to} className="group flex h-full flex-col focus-custom" end={end}>
{({ isActive, isPending }) => {
const active = isActive || isPending;
return (
<>
<div className="flex flex-1 items-center">
<span
className={cn(
titleTabLabel,
active ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)}
>
{children}
</span>
</div>
{active ? (
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={titleTabIndicator}
/>
) : (
<div className={titleTabIndicatorIdle} />
)}
</>
);
}}
</NavLink>
);
}
if (variant === "pipe-divider") {
return (
<NavLink
@@ -177,11 +224,13 @@ export function TabButton({
isActive,
layoutId,
shortcut,
variant = "underline",
...props
}: {
isActive: boolean;
shortcut?: ShortcutDefinition;
layoutId: string;
variant?: Variants;
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
const ref = useRef<HTMLButtonElement>(null);
@@ -197,10 +246,13 @@ export function TabButton({
});
}
const title = variant === "title";
return (
<button
className={cn(
"group flex flex-col items-center pt-1 focus-custom",
"group flex flex-col items-center focus-custom",
title ? "h-full" : "pt-1",
props.className,
props.disabled && "pointer-events-none opacity-50"
)}
@@ -209,8 +261,18 @@ export function TabButton({
{...props}
>
<>
<div className="flex items-center gap-1">
<span className={"text-sm transition duration-200 text-text-bright"}>
<div className={cn("flex items-center gap-1", title && "flex-1")}>
<span
className={cn(
"transition duration-200",
title
? cn(
headerVariants.header2.text,
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
)
: "text-sm text-text-bright"
)}
>
{props.children}
</span>
{shortcut && <ShortcutKey className={cn("")} shortcut={shortcut} variant={"small"} />}
@@ -219,10 +281,15 @@ export function TabButton({
<motion.div
layoutId={layoutId}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="mt-1 h-0.5 w-full bg-indigo-500"
className={cn("h-0.5 w-full bg-indigo-500", !title && "mt-1")}
/>
) : (
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
<div
className={cn(
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100",
!title && "mt-1"
)}
/>
)}
</>
</button>
@@ -0,0 +1,26 @@
import { type ReactNode } from "react";
import { cn } from "~/utils/cn";
import { Header2 } from "./Headers";
import { TITLE_BAR_CHROME } from "./Tabs";
/**
* Names the table below it. Bottom rule only — it doubles as the table's top edge, so render the
* table with `showTopBorder={false}`. Use `TabContainer variant="title"` for the tabbed form.
*/
export function TitleBar({
title,
children,
className,
}: {
title: ReactNode;
/** Right-aligned controls. */
children?: ReactNode;
className?: string;
}) {
return (
<div className={cn(TITLE_BAR_CHROME, "items-center justify-between pl-2.5 pr-1.5", className)}>
<Header2>{title}</Header2>
{children ? <div className="flex items-center gap-1.5">{children}</div> : null}
</div>
);
}
@@ -42,7 +42,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-w-[230px] overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 focus-visible:outline-hidden",
"z-50 overflow-hidden animate-in data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 focus-visible:outline-hidden",
variantClasses[variant],
className
)}
@@ -234,28 +234,10 @@ export default function Page() {
</PageAccessories>
</NavBar>
<MetricsLayout.Root>
{/* Filters — the pinned bar under the NavBar: the TimeFilter and pagination that used to
be fused with the tabs now live here, above the charts (Queues list pattern). Left and
right clusters are child divs; the slot's baked justify-between spreads them. */}
<MetricsLayout.Filters>
<div className="flex items-center gap-2">
<TimeFilter defaultPeriod="7d" labelName={tabLabel} />
</div>
<div className="flex items-center gap-2">
{tab === "sessions" ? (
<Suspense fallback={null}>
<TypedAwait resolve={sessionList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
) : (
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
)}
</div>
</MetricsLayout.Filters>
{/* Activity / LLM spend / Token charts as a fixed-height chart row (three-up), synced +
@@ -315,23 +297,45 @@ export default function Page() {
{/* Tabs alone on their row (Queue detail pattern), then the table below them. */}
<MetricsLayout.Content>
<TabContainer className="px-3">
<TabButton
isActive={tab === "sessions"}
layoutId="agent-page-tabs"
onClick={() => setTab("sessions")}
>
Sessions
</TabButton>
<TabButton
isActive={tab === "runs"}
layoutId="agent-page-tabs"
onClick={() => setTab("runs")}
>
Runs
</TabButton>
</TabContainer>
<AgentContentArea tab={tab} sessionList={sessionList} runList={runList} />
{/* Single child so Content's gap-2.5 can't separate the bar from the table. */}
<div className="flex flex-col">
<TabContainer variant="title" className="justify-between border-y px-2">
<div className="flex items-stretch gap-x-6">
<TabButton
isActive={tab === "sessions"}
layoutId="agent-page-tabs"
variant="title"
onClick={() => setTab("sessions")}
>
Sessions
</TabButton>
<TabButton
isActive={tab === "runs"}
layoutId="agent-page-tabs"
variant="title"
onClick={() => setTab("runs")}
>
Runs
</TabButton>
</div>
<div className="flex items-center gap-1.5">
{tab === "sessions" ? (
<Suspense fallback={null}>
<TypedAwait resolve={sessionList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
) : (
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
)}
</div>
</TabContainer>
<AgentContentArea tab={tab} sessionList={sessionList} runList={runList} />
</div>
</MetricsLayout.Content>
<MetricsLayout.Sidebar
@@ -356,8 +360,7 @@ function AgentContentArea({
sessionList,
runList,
}: { tab: AgentTab } & Pick<LoaderData, "sessionList" | "runList">) {
// The table flows in the page-level scroll (MetricsLayout.Root scroll="page"); a sticky header
// keeps the column labels pinned as the whole column scrolls.
// No `stickyHeader` — it drops the table's own overflow-x-auto and the charts scroll with it.
return tab === "sessions" ? (
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={sessionList} errorElement={<TableLoading />}>
@@ -367,7 +370,7 @@ function AgentContentArea({
sessions={list.sessions}
filters={list.filters}
hasFilters={list.hasFilters}
stickyHeader
showTopBorder={false}
/>
) : (
<TableLoading />
@@ -386,7 +389,7 @@ function AgentContentArea({
filters={list.filters}
runs={list.runs}
variant="dimmed"
stickyHeader
showTopBorder={false}
/>
) : (
<TableLoading />
@@ -1,10 +1,6 @@
import { Outlet } from "@remix-run/react";
import { PageContainer } from "~/components/layout/AppLayout";
// No PageContainer — the child pages render their own; nesting two collapses the inner one's height.
export default function Page() {
return (
<PageContainer>
<Outlet />
</PageContainer>
);
return <Outlet />;
}
@@ -28,6 +28,7 @@ import {
DialogTrigger,
} from "~/components/primitives/Dialog";
import { Header2 } from "~/components/primitives/Headers";
import { TitleBar } from "~/components/primitives/TitleBar";
import { InfoPanel } from "~/components/primitives/InfoPanel";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { PaginationControls } from "~/components/primitives/Pagination";
@@ -292,31 +293,15 @@ export default function Page() {
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="scheduled-task-main" min="300px">
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
{/* Top bar — title on the left; actions + TimeFilter + pagination on the right.
h-10 matches the right-hand sidebar header height. */}
<div className="flex min-h-10 items-center gap-2 border-b border-grid-dimmed bg-background-bright py-2 pl-3 pr-2">
<Header2>Runs</Header2>
<div className="flex min-h-10 items-center gap-2 border-b border-grid-dimmed bg-background-bright px-2 py-2">
<TimeFilter defaultPeriod="7d" labelName="Runs" />
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
<CreateScheduleButton
isAtLimit={isAtLimit}
limits={limits}
canUpgrade={canUpgrade}
canPurchaseSchedules={scheduleList?.canPurchaseSchedules ?? false}
extraSchedules={scheduleList?.extraSchedules ?? 0}
maxScheduleQuota={scheduleList?.maxScheduleQuota ?? 0}
planScheduleLimit={scheduleList?.planScheduleLimit ?? 0}
schedulePricing={scheduleList?.schedulePricing ?? null}
onCreate={openCreateSchedule}
disabled={isCreatingSchedule}
/>
{newRunsCount > 0 ? (
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
) : null}
<TimeFilter defaultPeriod="7d" labelName="Runs" />
<LinkButton
variant="secondary/small"
to={v3RunsPath(organization, project, environment, filters)}
LeadingIcon={RunsIcon}
// -7px halves the icon's default 6px of space on each side.
leadingIconClassName="-mx-[7px]"
>
View all runs
</LinkButton>
@@ -335,11 +320,18 @@ export default function Page() {
>
Bulk replay
</LinkButton>
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
<CreateScheduleButton
isAtLimit={isAtLimit}
limits={limits}
canUpgrade={canUpgrade}
canPurchaseSchedules={scheduleList?.canPurchaseSchedules ?? false}
extraSchedules={scheduleList?.extraSchedules ?? 0}
maxScheduleQuota={scheduleList?.maxScheduleQuota ?? 0}
planScheduleLimit={scheduleList?.planScheduleLimit ?? 0}
schedulePricing={scheduleList?.schedulePricing ?? null}
onCreate={openCreateSchedule}
disabled={isCreatingSchedule}
/>
</div>
</div>
@@ -363,23 +355,39 @@ export default function Page() {
{/* Runs table */}
<ResizablePanel id="scheduled-task-content" min="160px">
<div className="h-full overflow-hidden">
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* -mt-px absorbs the spare pixel below the handle's rule, centring the title. */}
<TitleBar title="Runs" className="-mt-px">
{newRunsCount > 0 ? (
<NewRunsButton
count={newRunsCount}
onClick={() => showNewRunsRef.current()}
/>
) : null}
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
</TitleBar>
<div className="min-h-0 overflow-hidden">
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
@@ -20,6 +20,7 @@ import { statusColor } from "~/components/primitives/charts/statusColors";
import { CopyableText } from "~/components/primitives/CopyableText";
import { DateTime } from "~/components/primitives/DateTime";
import { Header2 } from "~/components/primitives/Headers";
import { TitleBar } from "~/components/primitives/TitleBar";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
@@ -234,21 +235,8 @@ export default function Page() {
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="task-main" min="300px">
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* Top bar — title on the left; TimeFilter + pagination on the right.
h-10 matches the right-hand sidebar header height. */}
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
<Header2>Runs</Header2>
<div className="ml-auto flex items-center gap-1.5">
{newRunsCount > 0 ? (
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
) : null}
<TimeFilter defaultPeriod="7d" labelName="Runs" />
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
</div>
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright px-2">
<TimeFilter defaultPeriod="7d" labelName="Runs" />
</div>
<ResizablePanelGroup orientation="vertical" className="max-h-full">
@@ -263,23 +251,39 @@ export default function Page() {
{/* Runs table */}
<ResizablePanel id="task-content" min="160px">
<div className="h-full overflow-hidden">
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
{/* -mt-px absorbs the spare pixel below the handle's rule, centring the title. */}
<TitleBar title="Runs" className="-mt-px">
{newRunsCount > 0 ? (
<NewRunsButton
count={newRunsCount}
onClick={() => showNewRunsRef.current()}
/>
) : null}
<Suspense fallback={null}>
<TypedAwait resolve={runList} errorElement={null}>
{(list) => (list ? <ListPagination list={list} /> : null)}
</TypedAwait>
</Suspense>
</TitleBar>
<div className="min-h-0 overflow-hidden">
<Suspense fallback={<TableLoading />}>
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
}
</TypedAwait>
</Suspense>
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>