Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/task-page-runs-live-updating.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---

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.
117 changes: 117 additions & 0 deletions apps/webapp/app/components/runs/v3/TaskRunsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { useLocation, useNavigation, useRevalidator } from "@remix-run/react";
import { type MutableRefObject, useEffect } from "react";
import { Button } from "~/components/primitives/Buttons";
import { PulsingDot } from "~/components/primitives/PulsingDot";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { useSearchParams } from "~/hooks/useSearchParam";
import type { NextRunList } from "~/presenters/v3/NextRunListPresenter.server";
import { useRunsLiveReload } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload";
import { TaskRunsTable } from "./TaskRunsTable";

/**
* Compact "N new runs" button, shown in a task page's header to the left of the
* time filter when the live-reload hook has detected newer runs.
*/
export function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) {
return (
<span className="flex duration-150 animate-in fade-in-0">
<Button
variant="secondary/small"
className="text-text-bright"
onClick={onClick}
LeadingIcon={<PulsingDot className="h-2 w-2" />}
tooltip="Refresh to see new runs"
aria-label="New runs created. Refresh to see new runs."
>
{count >= 100 ? "99+ new runs" : `${count} new ${count === 1 ? "run" : "runs"}`}
</Button>
</span>
);
}

/**
* Runs table with live updating, shared by the standard and scheduled task
* landing pages. Mirrors the Runs list page: active rows are patched in place
* (status/timing/cost). The "N new runs" count is surfaced to the top-bar
* button via `onNewRunsCountChange` (count drives visibility) and
* `showNewRunsRef` (the latest click action), since the button lives outside
* this deferred boundary. The task lives in the route path rather than a
* `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task.
*/
export function TaskRunsList({
list,
taskSlug,
onNewRunsCountChange,
showNewRunsRef,
}: {
list: NextRunList;
taskSlug: string;
onNewRunsCountChange: (count: number) => void;
showNewRunsRef: MutableRefObject<() => void>;
}) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const navigation = useNavigation();
const location = useLocation();
const { has, replace } = useSearchParams();
const revalidator = useRevalidator();

// Loading a new version of this same page (time filter / pagination change).
const isLoading =
navigation.state === "loading" &&
navigation.location !== undefined &&
navigation.location.pathname === location.pathname &&
navigation.location.search !== location.search;

const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload(
{
runs: list.runs,
hasAnyRuns: list.hasAnyRuns,
isLoading,
organizationSlug: organization.slug,
projectSlug: project.slug,
environmentSlug: environment.slug,
taskSlug,
}
);

const onClickShowNewRuns = () => {
const isPaginated = has("cursor") || has("direction");
dismissNewRuns();
if (isPaginated) {
replace({ cursor: undefined, direction: undefined });
return;
}
revalidator.revalidate();
};
Comment thread
samejr marked this conversation as resolved.

// Surface the banner to the top-bar button rendered by the page: keep the
// ref's action current, mirror the count up, and clear it when this boundary
// unmounts (e.g. the table re-suspends on a filter change).
useEffect(() => {
showNewRunsRef.current = onClickShowNewRuns;
}, [onClickShowNewRuns, showNewRunsRef]);
useEffect(() => {
onNewRunsCountChange(newRunsCount);
}, [newRunsCount, onNewRunsCountChange]);
useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]);

return (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={visibleRuns.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={visibleRuns}
childrenStatusesBasePath={childrenStatusesBasePath}
isLoading={isLoading}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,22 @@ function isNewRunsCheckTick(tick: number) {

function appendNewRunsSearchParams(
searchParams: URLSearchParams,
{ locationSearch, since }: { locationSearch: string; since: number }
{ locationSearch, since, taskSlug }: { locationSearch: string; since: number; taskSlug?: string }
) {
const filterParams = filterParamsWithoutPagination(locationSearch);
for (const [key, value] of filterParams) {
searchParams.append(key, value);
}
// On the task landing pages the task lives in the route path, not the query
// string, so scope the new-runs count to this task explicitly. The task pages
// list every run of the task (their loaders apply no rootOnly filter), so
// force rootOnly off for the count too: a rootOnly preference persisted from
// the main Runs page would otherwise make the count skip child runs the list
// is showing, so the "N new runs" button could under-count or never appear.
if (taskSlug) {
searchParams.append("tasks", taskSlug);
searchParams.set("rootOnly", "false");
}
Comment thread
samejr marked this conversation as resolved.
searchParams.set("includeNewRuns", "true");
searchParams.set("since", String(since));
}
Expand Down Expand Up @@ -138,13 +148,19 @@ export function useRunsLiveReload({
organizationSlug,
projectSlug,
environmentSlug,
taskSlug,
}: {
runs: ListedRun[];
hasAnyRuns: boolean;
isLoading: boolean;
organizationSlug: string;
projectSlug: string;
environmentSlug: string;
/**
* When set, scopes new-run detection to this task. Used by the task landing
* pages, where the task is a route path param rather than a `tasks` filter.
*/
taskSlug?: string;
}) {
const location = useLocation();
const runsPollFetcher = useTypedFetcher<typeof liveRunsLoader>();
Expand Down Expand Up @@ -230,6 +246,7 @@ export function useRunsLiveReload({
appendNewRunsSearchParams(searchParams, {
locationSearch: location.search,
since: knownNewestRunMs,
taskSlug,
});
}

Expand All @@ -242,6 +259,7 @@ export function useRunsLiveReload({
knownNewestRunMs,
runsPollFetcher,
runsResourcesBasePath,
taskSlug,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import { ScheduleTypeIcon, scheduleTypeName } from "~/components/runs/v3/ScheduleType";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
import { ScheduleLimitActions } from "~/components/schedules/ScheduleLimitActions";
import { SchedulesUsageBar } from "~/components/schedules/SchedulesUsageBar";
Expand Down Expand Up @@ -95,6 +94,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu
import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route";
import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
Expand Down Expand Up @@ -181,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
includeHasAnyRuns: true,
})
.catch(() => null);

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

// New-runs banner state is lifted here so the button can live in the top bar,
// while the count/action originate from the live-reload hook inside the
// deferred runs table below. Count drives visibility; the ref exposes the
// click action (kept current by TaskRunsList each render).
const [newRunsCount, setNewRunsCount] = useState(0);
const showNewRunsRef = useRef<() => void>(() => {});

return (
<PageContainer>
<NavBar>
Expand Down Expand Up @@ -265,6 +273,9 @@ export default function Page() {
onCreate={openCreateSchedule}
disabled={isCreatingSchedule}
/>
{newRunsCount > 0 ? (
<NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} />
) : null}
<TimeFilter defaultPeriod="7d" labelName="Runs" />
<LinkButton
variant="secondary/small"
Expand Down Expand Up @@ -321,17 +332,12 @@ export default function Page() {
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { type MetaFunction } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { Suspense, useMemo } from "react";
import { Suspense, useMemo, useRef, useState } from "react";
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { BeakerIcon } from "~/assets/icons/BeakerIcon";
Expand Down Expand Up @@ -29,7 +29,6 @@ import {
} from "~/components/primitives/Resizable";
import { Spinner } from "~/components/primitives/Spinner";
import { TextLink } from "~/components/primitives/TextLink";
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
import { TimeFilter, timeFilterFromTo } from "~/components/runs/v3/SharedFilters";
import { $replica } from "~/db.server";
import { useEnvironment } from "~/hooks/useEnvironment";
Expand All @@ -52,6 +51,7 @@ import {
v3TestTaskPath,
} from "~/utils/pathBuilder";
import { parseFiniteInt } from "~/utils/searchParams";
import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
Expand Down Expand Up @@ -121,6 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
includeHasAnyRuns: true,
})
.catch(() => null);

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

// New-runs banner state is lifted here so the button can live in the top bar,
// while the count/action originate from the live-reload hook inside the
// deferred runs table below. Count drives visibility; the ref exposes the
// click action (kept current by TaskRunsList each render).
const [newRunsCount, setNewRunsCount] = useState(0);
const showNewRunsRef = useRef<() => void>(() => {});

return (
<PageContainer>
<NavBar>
Expand All @@ -166,6 +174,9 @@ export default function Page() {
<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}>
Expand Down Expand Up @@ -200,17 +211,12 @@ export default function Page() {
<TypedAwait resolve={runList} errorElement={<TableLoading />}>
{(list) =>
list ? (
<div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<TaskRunsTable
total={list.runs.length}
hasFilters={list.hasFilters}
filters={list.filters}
runs={list.runs}
variant="dimmed"
showTopBorder={false}
stickyHeader
/>
</div>
<TaskRunsList
list={list}
taskSlug={task.slug}
onNewRunsCountChange={setNewRunsCount}
showNewRunsRef={showNewRunsRef}
/>
) : (
<TableLoading />
)
Expand Down