diff --git a/.server-changes/task-page-runs-live-updating.md b/.server-changes/task-page-runs-live-updating.md
new file mode 100644
index 00000000000..a082fdfb3d5
--- /dev/null
+++ b/.server-changes/task-page-runs-live-updating.md
@@ -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.
diff --git a/apps/webapp/app/components/runs/v3/TaskRunsList.tsx b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx
new file mode 100644
index 00000000000..1b9830ec54f
--- /dev/null
+++ b/apps/webapp/app/components/runs/v3/TaskRunsList.tsx
@@ -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 (
+
+ }
+ 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"}`}
+
+
+ );
+}
+
+/**
+ * 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();
+ };
+
+ // 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 (
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts
index a8d26ac350b..e443fb84cfb 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts
@@ -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");
+ }
searchParams.set("includeNewRuns", "true");
searchParams.set("since", String(since));
}
@@ -138,6 +148,7 @@ export function useRunsLiveReload({
organizationSlug,
projectSlug,
environmentSlug,
+ taskSlug,
}: {
runs: ListedRun[];
hasAnyRuns: boolean;
@@ -145,6 +156,11 @@ export function useRunsLiveReload({
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();
@@ -230,6 +246,7 @@ export function useRunsLiveReload({
appendNewRunsSearchParams(searchParams, {
locationSearch: location.search,
since: knownNewestRunMs,
+ taskSlug,
});
}
@@ -242,6 +259,7 @@ export function useRunsLiveReload({
knownNewestRunMs,
runsPollFetcher,
runsResourcesBasePath,
+ taskSlug,
]
);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
index 9ef03b5c189..398c6135531 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx
@@ -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";
@@ -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 = ({ data }) => {
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
@@ -181,6 +181,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
+ includeHasAnyRuns: true,
})
.catch(() => null);
@@ -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 (
@@ -265,6 +273,9 @@ export default function Page() {
onCreate={openCreateSchedule}
disabled={isCreatingSchedule}
/>
+ {newRunsCount > 0 ? (
+ showNewRunsRef.current()} />
+ ) : null}
}>
{(list) =>
list ? (
-
-
-
+
) : (
)
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx
index f38124f2596..ac765406ce1 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx
@@ -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";
@@ -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";
@@ -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 = ({ data }) => {
const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug;
@@ -121,6 +121,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
to,
cursor,
direction,
+ includeHasAnyRuns: true,
})
.catch(() => null);
@@ -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 (
@@ -166,6 +174,9 @@ export default function Page() {
Runs
+ {newRunsCount > 0 ? (
+
showNewRunsRef.current()} />
+ ) : null}
@@ -200,17 +211,12 @@ export default function Page() {
}>
{(list) =>
list ? (
-
-
-
+
) : (
)