Skip to content

Commit dc74266

Browse files
committed
feat(webapp): live-update the runs list on task pages
1 parent be45cf9 commit dc74266

4 files changed

Lines changed: 301 additions & 30 deletions

File tree

  • .server-changes
  • apps/webapp/app/routes
    • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index
    • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam
    • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam
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.

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,17 @@ 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.
59+
if (taskSlug) {
60+
searchParams.append("tasks", taskSlug);
61+
}
5762
searchParams.set("includeNewRuns", "true");
5863
searchParams.set("since", String(since));
5964
}
@@ -138,13 +143,19 @@ export function useRunsLiveReload({
138143
organizationSlug,
139144
projectSlug,
140145
environmentSlug,
146+
taskSlug,
141147
}: {
142148
runs: ListedRun[];
143149
hasAnyRuns: boolean;
144150
isLoading: boolean;
145151
organizationSlug: string;
146152
projectSlug: string;
147153
environmentSlug: string;
154+
/**
155+
* When set, scopes new-run detection to this task. Used by the task landing
156+
* pages, where the task is a route path param rather than a `tasks` filter.
157+
*/
158+
taskSlug?: string;
148159
}) {
149160
const location = useLocation();
150161
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
@@ -230,6 +241,7 @@ export function useRunsLiveReload({
230241
appendNewRunsSearchParams(searchParams, {
231242
locationSearch: location.search,
232243
since: knownNewestRunMs,
244+
taskSlug,
233245
});
234246
}
235247

@@ -242,6 +254,7 @@ export function useRunsLiveReload({
242254
knownNewestRunMs,
243255
runsPollFetcher,
244256
runsResourcesBasePath,
257+
taskSlug,
245258
]
246259
);
247260

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

Lines changed: 147 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
11
import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid";
2-
import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react";
2+
import {
3+
useFetcher,
4+
useLocation,
5+
useNavigation,
6+
useRevalidator,
7+
type MetaFunction,
8+
} from "@remix-run/react";
39
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
4-
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
5-
import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
10+
import {
11+
type MutableRefObject,
12+
Suspense,
13+
useCallback,
14+
useEffect,
15+
useMemo,
16+
useRef,
17+
useState,
18+
} from "react";
19+
import {
20+
TypedAwait,
21+
typeddefer,
22+
type UseDataFunctionReturn,
23+
useTypedFetcher,
24+
useTypedLoaderData,
25+
} from "remix-typedjson";
626
import { z } from "zod";
727
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
828
import { ClockIcon } from "~/assets/icons/ClockIcon";
@@ -32,6 +52,7 @@ import { InfoPanel } from "~/components/primitives/InfoPanel";
3252
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
3353
import { PaginationControls } from "~/components/primitives/Pagination";
3454
import { Paragraph } from "~/components/primitives/Paragraph";
55+
import { PulsingDot } from "~/components/primitives/PulsingDot";
3556
import * as Property from "~/components/primitives/PropertyTable";
3657
import {
3758
ResizableHandle,
@@ -95,6 +116,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu
95116
import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
96117
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
97118
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
119+
import { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload";
98120

99121
export const meta: MetaFunction<typeof loader> = ({ data }) => {
100122
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
@@ -181,6 +203,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
181203
to,
182204
cursor,
183205
direction,
206+
includeHasAnyRuns: true,
184207
})
185208
.catch(() => null);
186209

@@ -231,6 +254,13 @@ export default function Page() {
231254
!!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed;
232255
const isAtLimit = !!limits && limits.used >= limits.limit;
233256

257+
// New-runs banner state is lifted here so the button can live in the top bar,
258+
// while the count/action originate from the live-reload hook inside the
259+
// deferred runs table below. Count drives visibility; the ref exposes the
260+
// click action (kept current by TaskRunsList each render).
261+
const [newRunsCount, setNewRunsCount] = useState(0);
262+
const showNewRunsRef = useRef<() => void>(() => {});
263+
234264
return (
235265
<PageContainer>
236266
<NavBar>
@@ -265,6 +295,9 @@ export default function Page() {
265295
onCreate={openCreateSchedule}
266296
disabled={isCreatingSchedule}
267297
/>
298+
{newRunsCount > 0 ? (
299+
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
300+
) : null}
268301
<TimeFilter defaultPeriod="7d" labelName="Runs" />
269302
<LinkButton
270303
variant="secondary/small"
@@ -321,17 +354,12 @@ export default function Page() {
321354
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
322355
{(list) =>
323356
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>
357+
<TaskRunsList
358+
list={list}
359+
taskSlug={task.slug}
360+
onNewRunsCountChange={setNewRunsCount}
361+
showNewRunsRef={showNewRunsRef}
362+
/>
335363
) : (
336364
<TableLoading />
337365
)
@@ -397,6 +425,111 @@ export default function Page() {
397425
);
398426
}
399427

428+
type TaskRunList = NonNullable<Awaited<UseDataFunctionReturn<typeof loader>["runList"]>>;
429+
430+
/**
431+
* Compact "N new runs" button, shown in the page top bar to the left of the
432+
* time filter when the live-reload hook has detected newer runs.
433+
*/
434+
function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) {
435+
return (
436+
<span className="flex duration-150 animate-in fade-in-0">
437+
<Button
438+
variant="secondary/small"
439+
className="text-text-bright"
440+
onClick={onClick}
441+
LeadingIcon={<PulsingDot className="h-2 w-2" />}
442+
tooltip="Refresh to see new runs"
443+
aria-label="New runs created. Refresh to see new runs."
444+
>
445+
{count >= 100 ? "99+ new runs" : `${count} new ${count === 1 ? "run" : "runs"}`}
446+
</Button>
447+
</span>
448+
);
449+
}
450+
451+
/**
452+
* Runs table with live updating. Mirrors the Runs list page: active rows are
453+
* patched in place (status/timing/cost). The "N new runs" count is surfaced to
454+
* the top-bar button via `onNewRunsCountChange` (count → visibility) and
455+
* `showNewRunsRef` (the latest click action), since the button lives outside
456+
* this deferred boundary. The task lives in the route path rather than a
457+
* `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task.
458+
*/
459+
function TaskRunsList({
460+
list,
461+
taskSlug,
462+
onNewRunsCountChange,
463+
showNewRunsRef,
464+
}: {
465+
list: TaskRunList;
466+
taskSlug: string;
467+
onNewRunsCountChange: (count: number) => void;
468+
showNewRunsRef: MutableRefObject<() => void>;
469+
}) {
470+
const organization = useOrganization();
471+
const project = useProject();
472+
const environment = useEnvironment();
473+
const navigation = useNavigation();
474+
const location = useLocation();
475+
const { has, replace } = useSearchParams();
476+
const revalidator = useRevalidator();
477+
478+
// Loading a new version of this same page (time filter / pagination change).
479+
const isLoading =
480+
navigation.state === "loading" &&
481+
navigation.location !== undefined &&
482+
navigation.location.pathname === location.pathname &&
483+
navigation.location.search !== location.search;
484+
485+
const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload({
486+
runs: list.runs,
487+
hasAnyRuns: list.hasAnyRuns,
488+
isLoading,
489+
organizationSlug: organization.slug,
490+
projectSlug: project.slug,
491+
environmentSlug: environment.slug,
492+
taskSlug,
493+
});
494+
495+
const onClickShowNewRuns = () => {
496+
const isPaginated = has("cursor") || has("direction");
497+
dismissNewRuns();
498+
if (isPaginated) {
499+
replace({ cursor: undefined, direction: undefined });
500+
return;
501+
}
502+
revalidator.revalidate();
503+
};
504+
505+
// Surface the banner to the top-bar button rendered by Page: keep the ref's
506+
// action current, mirror the count up, and clear it when this boundary
507+
// unmounts (e.g. the table re-suspends on a filter change).
508+
useEffect(() => {
509+
showNewRunsRef.current = onClickShowNewRuns;
510+
}, [onClickShowNewRuns, showNewRunsRef]);
511+
useEffect(() => {
512+
onNewRunsCountChange(newRunsCount);
513+
}, [newRunsCount, onNewRunsCountChange]);
514+
useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]);
515+
516+
return (
517+
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
518+
<TaskRunsTable
519+
total={visibleRuns.length}
520+
hasFilters={list.hasFilters}
521+
filters={list.filters}
522+
runs={visibleRuns}
523+
childrenStatusesBasePath={childrenStatusesBasePath}
524+
isLoading={isLoading}
525+
variant="dimmed"
526+
showTopBorder={false}
527+
stickyHeader
528+
/>
529+
</div>
530+
);
531+
}
532+
400533
/**
401534
* "Create schedule" button with a limit-exceeded intercept. When the project
402535
* is already at its schedules limit, clicking opens a dialog explaining the

0 commit comments

Comments
 (0)