Skip to content

Commit 3e53404

Browse files
authored
feat(webapp): live-update the runs list on task pages (#4377)
## Summary The runs list on a task's page now updates live, matching the main Runs page. Run rows update their status, duration, and cost in place as runs progress, and a "N new runs" button appears in the header when newer runs come in so you can pull them into the list without a manual refresh. This applies to both standard and scheduled task pages. ## Design It reuses the Runs page's polling hook. A task page scopes its runs by the task in the URL path rather than a `tasks` query filter, so the hook now takes an optional task slug and scopes new-run detection to it. The "new runs" button sits in the header, outside the deferred runs table, so the count is lifted to the page and the click action is passed through a ref. That keeps the table streaming on first load instead of blocking the header on the runs query. When newer runs come in, a `1 new run` button appears in the task page header, to the left of the time filter. Clicking it pulls the new runs into the list.
1 parent e8a2dbd commit 3e53404

5 files changed

Lines changed: 179 additions & 26 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The runs list on a task's page now updates live — run statuses change and newly triggered runs appear without a manual refresh, matching the main Runs page.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { useLocation, useNavigation, useRevalidator } from "@remix-run/react";
2+
import { type MutableRefObject, useEffect } from "react";
3+
import { Button } from "~/components/primitives/Buttons";
4+
import { PulsingDot } from "~/components/primitives/PulsingDot";
5+
import { useEnvironment } from "~/hooks/useEnvironment";
6+
import { useOrganization } from "~/hooks/useOrganizations";
7+
import { useProject } from "~/hooks/useProject";
8+
import { useSearchParams } from "~/hooks/useSearchParam";
9+
import type { NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
10+
import { useRunsLiveReload } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload";
11+
import { TaskRunsTable } from "./TaskRunsTable";
12+
13+
/**
14+
* Compact "N new runs" button, shown in a task page's header to the left of the
15+
* time filter when the live-reload hook has detected newer runs.
16+
*/
17+
export function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) {
18+
return (
19+
<span className="flex duration-150 animate-in fade-in-0">
20+
<Button
21+
variant="secondary/small"
22+
className="text-text-bright"
23+
onClick={onClick}
24+
LeadingIcon={<PulsingDot className="h-2 w-2" />}
25+
tooltip="Refresh to see new runs"
26+
aria-label="New runs created. Refresh to see new runs."
27+
>
28+
{count >= 100 ? "99+ new runs" : `${count} new ${count === 1 ? "run" : "runs"}`}
29+
</Button>
30+
</span>
31+
);
32+
}
33+
34+
/**
35+
* Runs table with live updating, shared by the standard and scheduled task
36+
* landing pages. Mirrors the Runs list page: active rows are patched in place
37+
* (status/timing/cost). The "N new runs" count is surfaced to the top-bar
38+
* button via `onNewRunsCountChange` (count drives visibility) and
39+
* `showNewRunsRef` (the latest click action), since the button lives outside
40+
* this deferred boundary. The task lives in the route path rather than a
41+
* `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task.
42+
*/
43+
export function TaskRunsList({
44+
list,
45+
taskSlug,
46+
onNewRunsCountChange,
47+
showNewRunsRef,
48+
}: {
49+
list: NextRunList;
50+
taskSlug: string;
51+
onNewRunsCountChange: (count: number) => void;
52+
showNewRunsRef: MutableRefObject<() => void>;
53+
}) {
54+
const organization = useOrganization();
55+
const project = useProject();
56+
const environment = useEnvironment();
57+
const navigation = useNavigation();
58+
const location = useLocation();
59+
const { has, replace } = useSearchParams();
60+
const revalidator = useRevalidator();
61+
62+
// Loading a new version of this same page (time filter / pagination change).
63+
const isLoading =
64+
navigation.state === "loading" &&
65+
navigation.location !== undefined &&
66+
navigation.location.pathname === location.pathname &&
67+
navigation.location.search !== location.search;
68+
69+
const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload(
70+
{
71+
runs: list.runs,
72+
hasAnyRuns: list.hasAnyRuns,
73+
isLoading,
74+
organizationSlug: organization.slug,
75+
projectSlug: project.slug,
76+
environmentSlug: environment.slug,
77+
taskSlug,
78+
}
79+
);
80+
81+
const onClickShowNewRuns = () => {
82+
const isPaginated = has("cursor") || has("direction");
83+
dismissNewRuns();
84+
if (isPaginated) {
85+
replace({ cursor: undefined, direction: undefined });
86+
return;
87+
}
88+
revalidator.revalidate();
89+
};
90+
91+
// Surface the banner to the top-bar button rendered by the page: keep the
92+
// ref's action current, mirror the count up, and clear it when this boundary
93+
// unmounts (e.g. the table re-suspends on a filter change).
94+
useEffect(() => {
95+
showNewRunsRef.current = onClickShowNewRuns;
96+
}, [onClickShowNewRuns, showNewRunsRef]);
97+
useEffect(() => {
98+
onNewRunsCountChange(newRunsCount);
99+
}, [newRunsCount, onNewRunsCountChange]);
100+
useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]);
101+
102+
return (
103+
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
104+
<TaskRunsTable
105+
total={visibleRuns.length}
106+
hasFilters={list.hasFilters}
107+
filters={list.filters}
108+
runs={visibleRuns}
109+
childrenStatusesBasePath={childrenStatusesBasePath}
110+
isLoading={isLoading}
111+
variant="dimmed"
112+
showTopBorder={false}
113+
stickyHeader
114+
/>
115+
</div>
116+
);
117+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,22 @@ function isNewRunsCheckTick(tick: number) {
4848

4949
function appendNewRunsSearchParams(
5050
searchParams: URLSearchParams,
51-
{ locationSearch, since }: { locationSearch: string; since: number }
51+
{ locationSearch, since, taskSlug }: { locationSearch: string; since: number; taskSlug?: string }
5252
) {
5353
const filterParams = filterParamsWithoutPagination(locationSearch);
5454
for (const [key, value] of filterParams) {
5555
searchParams.append(key, value);
5656
}
57+
// On the task landing pages the task lives in the route path, not the query
58+
// string, so scope the new-runs count to this task explicitly. The task pages
59+
// list every run of the task (their loaders apply no rootOnly filter), so
60+
// force rootOnly off for the count too: a rootOnly preference persisted from
61+
// the main Runs page would otherwise make the count skip child runs the list
62+
// is showing, so the "N new runs" button could under-count or never appear.
63+
if (taskSlug) {
64+
searchParams.append("tasks", taskSlug);
65+
searchParams.set("rootOnly", "false");
66+
}
5767
searchParams.set("includeNewRuns", "true");
5868
searchParams.set("since", String(since));
5969
}
@@ -138,13 +148,19 @@ export function useRunsLiveReload({
138148
organizationSlug,
139149
projectSlug,
140150
environmentSlug,
151+
taskSlug,
141152
}: {
142153
runs: ListedRun[];
143154
hasAnyRuns: boolean;
144155
isLoading: boolean;
145156
organizationSlug: string;
146157
projectSlug: string;
147158
environmentSlug: string;
159+
/**
160+
* When set, scopes new-run detection to this task. Used by the task landing
161+
* pages, where the task is a route path param rather than a `tasks` filter.
162+
*/
163+
taskSlug?: string;
148164
}) {
149165
const location = useLocation();
150166
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
@@ -230,6 +246,7 @@ export function useRunsLiveReload({
230246
appendNewRunsSearchParams(searchParams, {
231247
locationSearch: location.search,
232248
since: knownNewestRunMs,
249+
taskSlug,
233250
});
234251
}
235252

@@ -242,6 +259,7 @@ export function useRunsLiveReload({
242259
knownNewestRunMs,
243260
runsPollFetcher,
244261
runsResourcesBasePath,
262+
taskSlug,
245263
]
246264
);
247265

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
5656
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
5757
import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType";
5858
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
59-
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
6059
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
6160
import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions";
6261
import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar";
@@ -95,6 +94,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu
9594
import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
9695
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
9796
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
97+
import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList";
9898

9999
export const meta: MetaFunction<typeof loader> = ({ data }) => {
100100
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
@@ -181,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
181181
to,
182182
cursor,
183183
direction,
184+
includeHasAnyRuns: true,
184185
})
185186
.catch(() => null);
186187

@@ -231,6 +232,13 @@ export default function Page() {
231232
!!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed;
232233
const isAtLimit = !!limits && limits.used >= limits.limit;
233234

235+
// New-runs banner state is lifted here so the button can live in the top bar,
236+
// while the count/action originate from the live-reload hook inside the
237+
// deferred runs table below. Count drives visibility; the ref exposes the
238+
// click action (kept current by TaskRunsList each render).
239+
const [newRunsCount, setNewRunsCount] = useState(0);
240+
const showNewRunsRef = useRef<() => void>(() => {});
241+
234242
return (
235243
<PageContainer>
236244
<NavBar>
@@ -265,6 +273,9 @@ export default function Page() {
265273
onCreate={openCreateSchedule}
266274
disabled={isCreatingSchedule}
267275
/>
276+
{newRunsCount > 0 ? (
277+
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
278+
) : null}
268279
<TimeFilter defaultPeriod="7d" labelName="Runs" />
269280
<LinkButton
270281
variant="secondary/small"
@@ -321,17 +332,12 @@ export default function Page() {
321332
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
322333
{(list) =>
323334
list ? (
324-
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
325-
<TaskRunsTable
326-
total={list.runs.length}
327-
hasFilters={list.hasFilters}
328-
filters={list.filters}
329-
runs={list.runs}
330-
variant="dimmed"
331-
showTopBorder={false}
332-
stickyHeader
333-
/>
334-
</div>
335+
<TaskRunsList
336+
list={list}
337+
taskSlug={task.slug}
338+
onNewRunsCountChange={setNewRunsCount}
339+
showNewRunsRef={showNewRunsRef}
340+
/>
335341
) : (
336342
<TableLoading />
337343
)

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { type MetaFunction } from "@remix-run/react";
22
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
33
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
4-
import { Suspense, useMemo } from "react";
4+
import { Suspense, useMemo, useRef, useState } from "react";
55
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
66
import { z } from "zod";
77
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
@@ -29,7 +29,6 @@ import {
2929
} from "~/components/primitives/Resizable";
3030
import { Spinner } from "~/components/primitives/Spinner";
3131
import { TextLink } from "~/components/primitives/TextLink";
32-
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
3332
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
3433
import { $replica } from "~/db.server";
3534
import { useEnvironment } from "~/hooks/useEnvironment";
@@ -52,6 +51,7 @@ import {
5251
v3TestTaskPath,
5352
} from "~/utils/pathBuilder";
5453
import { parseFiniteInt } from "~/utils/searchParams";
54+
import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList";
5555

5656
export const meta: MetaFunction<typeof loader> = ({ data }) => {
5757
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
@@ -121,6 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
121121
to,
122122
cursor,
123123
direction,
124+
includeHasAnyRuns: true,
124125
})
125126
.catch(() => null);
126127

@@ -144,6 +145,13 @@ export default function Page() {
144145
});
145146
const queuesPath = v3QueuesPath(organization, project, environment);
146147

148+
// New-runs banner state is lifted here so the button can live in the top bar,
149+
// while the count/action originate from the live-reload hook inside the
150+
// deferred runs table below. Count drives visibility; the ref exposes the
151+
// click action (kept current by TaskRunsList each render).
152+
const [newRunsCount, setNewRunsCount] = useState(0);
153+
const showNewRunsRef = useRef<() => void>(() => {});
154+
147155
return (
148156
<PageContainer>
149157
<NavBar>
@@ -166,6 +174,9 @@ export default function Page() {
166174
<div className="flex h-10 items-center border-b border-grid-dimmed bg-background-bright pl-3 pr-2">
167175
<Header2>Runs</Header2>
168176
<div className="ml-auto flex items-center gap-1.5">
177+
{newRunsCount > 0 ? (
178+
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
179+
) : null}
169180
<TimeFilter defaultPeriod="7d" labelName="Runs" />
170181
<Suspense fallback={null}>
171182
<TypedAwait resolve={runList} errorElement={null}>
@@ -200,17 +211,12 @@ export default function Page() {
200211
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
201212
{(list) =>
202213
list ? (
203-
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
204-
<TaskRunsTable
205-
total={list.runs.length}
206-
hasFilters={list.hasFilters}
207-
filters={list.filters}
208-
runs={list.runs}
209-
variant="dimmed"
210-
showTopBorder={false}
211-
stickyHeader
212-
/>
213-
</div>
214+
<TaskRunsList
215+
list={list}
216+
taskSlug={task.slug}
217+
onNewRunsCountChange={setNewRunsCount}
218+
showNewRunsRef={showNewRunsRef}
219+
/>
214220
) : (
215221
<TableLoading />
216222
)

0 commit comments

Comments
 (0)