From dc74266fcb78817cddea54675461a497c6dd3257 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 09:40:03 +0100 Subject: [PATCH 1/4] feat(webapp): live-update the runs list on task pages --- .../task-page-runs-live-updating.md | 6 + .../useRunsLiveReload.ts | 15 +- .../route.tsx | 161 ++++++++++++++++-- .../route.tsx | 149 ++++++++++++++-- 4 files changed, 301 insertions(+), 30 deletions(-) create mode 100644 .server-changes/task-page-runs-live-updating.md 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/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..3687674c867 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,17 @@ 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. + if (taskSlug) { + searchParams.append("tasks", taskSlug); + } searchParams.set("includeNewRuns", "true"); searchParams.set("since", String(since)); } @@ -138,6 +143,7 @@ export function useRunsLiveReload({ organizationSlug, projectSlug, environmentSlug, + taskSlug, }: { runs: ListedRun[]; hasAnyRuns: boolean; @@ -145,6 +151,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 +241,7 @@ export function useRunsLiveReload({ appendNewRunsSearchParams(searchParams, { locationSearch: location.search, since: knownNewestRunMs, + taskSlug, }); } @@ -242,6 +254,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..48c64e13c71 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 @@ -1,8 +1,28 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; +import { + useFetcher, + useLocation, + useNavigation, + useRevalidator, + type MetaFunction, +} from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; +import { + type MutableRefObject, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + TypedAwait, + typeddefer, + type UseDataFunctionReturn, + useTypedFetcher, + useTypedLoaderData, +} from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { ClockIcon } from "~/assets/icons/ClockIcon"; @@ -32,6 +52,7 @@ import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; import * as Property from "~/components/primitives/PropertyTable"; import { ResizableHandle, @@ -95,6 +116,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 { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -181,6 +203,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -231,6 +254,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 +295,9 @@ export default function Page() { onCreate={openCreateSchedule} disabled={isCreatingSchedule} /> + {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} }> {(list) => list ? ( -
- -
+ ) : ( ) @@ -397,6 +425,111 @@ export default function Page() { ); } +type TaskRunList = NonNullable["runList"]>>; + +/** + * Compact "N new runs" button, shown in the page top bar to the left of the + * time filter when the live-reload hook has detected newer runs. + */ +function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + + + ); +} + +/** + * Runs table with live updating. 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 → 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. + */ +function TaskRunsList({ + list, + taskSlug, + onNewRunsCountChange, + showNewRunsRef, +}: { + list: TaskRunList; + 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 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 ( +
+ +
+ ); +} + /** * "Create schedule" button with a limit-exceeded intercept. When the project * is already at its schedules limit, clicking opens a dialog explaining the 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..be27c378127 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,15 +1,20 @@ -import { type MetaFunction } from "@remix-run/react"; +import { type MetaFunction, useLocation, useNavigation, useRevalidator } 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 { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; +import { type MutableRefObject, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { + TypedAwait, + typeddefer, + type UseDataFunctionReturn, + useTypedLoaderData, +} from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; -import { LinkButton } from "~/components/primitives/Buttons"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; @@ -21,6 +26,7 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { PulsingDot } from "~/components/primitives/PulsingDot"; import * as Property from "~/components/primitives/PropertyTable"; import { ResizableHandle, @@ -35,6 +41,7 @@ import { $replica } from "~/db.server"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; @@ -52,6 +59,7 @@ import { v3TestTaskPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; +import { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -121,6 +129,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + includeHasAnyRuns: true, }) .catch(() => null); @@ -144,6 +153,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 +182,9 @@ export default function Page() {
Runs
+ {newRunsCount > 0 ? ( + showNewRunsRef.current()} /> + ) : null} @@ -200,17 +219,12 @@ export default function Page() { }> {(list) => list ? ( -
- -
+ ) : ( ) @@ -233,6 +247,111 @@ export default function Page() { ); } +type TaskRunList = NonNullable["runList"]>>; + +/** + * Compact "N new runs" button, shown in the page top bar to the left of the + * time filter when the live-reload hook has detected newer runs. + */ +function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { + return ( + + + + ); +} + +/** + * Runs table with live updating. 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 → 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. + */ +function TaskRunsList({ + list, + taskSlug, + onNewRunsCountChange, + showNewRunsRef, +}: { + list: TaskRunList; + 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 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 ( +
+ +
+ ); +} + function TaskDetailSidebar({ task, testPath, From f32b0a2952d50d7f91f44ba7268742d3868a4544 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 09:45:22 +0100 Subject: [PATCH 2/4] chore(webapp): format task pages with oxfmt --- .../route.tsx | 20 ++++++++++--------- .../route.tsx | 20 ++++++++++--------- 2 files changed, 22 insertions(+), 18 deletions(-) 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 48c64e13c71..f05c5ca40a4 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 @@ -482,15 +482,17 @@ function TaskRunsList({ 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 { 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"); 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 be27c378127..de4ec8ce1ac 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 @@ -304,15 +304,17 @@ function TaskRunsList({ 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 { 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"); From 4861ad5ee1b9a5dbccca70ab5da9815de4a1b160 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 09:54:14 +0100 Subject: [PATCH 3/4] fix(webapp): keep the task-page new-runs count consistent with the list --- .../useRunsLiveReload.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 3687674c867..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 @@ -55,9 +55,14 @@ function appendNewRunsSearchParams( 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. + // 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)); From ca95a03b249359d95c3779bd280a2dcdd47d8136 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 10:14:41 +0100 Subject: [PATCH 4/4] refactor(webapp): extract shared TaskRunsList for both task pages --- .../app/components/runs/v3/TaskRunsList.tsx | 117 +++++++++++++++ .../route.tsx | 137 +----------------- .../route.tsx | 125 +--------------- 3 files changed, 126 insertions(+), 253 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/TaskRunsList.tsx 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 ( + + + + ); +} + +/** + * 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.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index f05c5ca40a4..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 @@ -1,28 +1,8 @@ import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; -import { - useFetcher, - useLocation, - useNavigation, - useRevalidator, - type MetaFunction, -} from "@remix-run/react"; +import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { - type MutableRefObject, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { - TypedAwait, - typeddefer, - type UseDataFunctionReturn, - useTypedFetcher, - useTypedLoaderData, -} from "remix-typedjson"; +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { ClockIcon } from "~/assets/icons/ClockIcon"; @@ -52,7 +32,6 @@ import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { PulsingDot } from "~/components/primitives/PulsingDot"; import * as Property from "~/components/primitives/PropertyTable"; import { ResizableHandle, @@ -77,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"; @@ -116,7 +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 { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -425,113 +403,6 @@ export default function Page() { ); } -type TaskRunList = NonNullable["runList"]>>; - -/** - * Compact "N new runs" button, shown in the page top bar to the left of the - * time filter when the live-reload hook has detected newer runs. - */ -function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { - return ( - - - - ); -} - -/** - * Runs table with live updating. 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 → 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. - */ -function TaskRunsList({ - list, - taskSlug, - onNewRunsCountChange, - showNewRunsRef, -}: { - list: TaskRunList; - 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 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 ( -
- -
- ); -} - /** * "Create schedule" button with a limit-exceeded intercept. When the project * is already at its schedules limit, clicking opens a dialog explaining the 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 de4ec8ce1ac..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,20 +1,15 @@ -import { type MetaFunction, useLocation, useNavigation, useRevalidator } from "@remix-run/react"; +import { type MetaFunction } from "@remix-run/react"; import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { type MutableRefObject, Suspense, useEffect, useMemo, useRef, useState } from "react"; -import { - TypedAwait, - typeddefer, - type UseDataFunctionReturn, - useTypedLoaderData, -} from "remix-typedjson"; +import { Suspense, useMemo, useRef, useState } from "react"; +import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; import { DirectionSchema, ListPagination } from "~/components/ListPagination"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { LinkButton } from "~/components/primitives/Buttons"; import { ChartCard } from "~/components/primitives/charts/ChartCard"; import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext"; import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; @@ -26,7 +21,6 @@ import { DateTime } from "~/components/primitives/DateTime"; import { Header2 } from "~/components/primitives/Headers"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { PulsingDot } from "~/components/primitives/PulsingDot"; import * as Property from "~/components/primitives/PropertyTable"; import { ResizableHandle, @@ -35,13 +29,11 @@ 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"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; @@ -59,7 +51,7 @@ import { v3TestTaskPath, } from "~/utils/pathBuilder"; import { parseFiniteInt } from "~/utils/searchParams"; -import { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; +import { NewRunsButton, TaskRunsList } from "~/components/runs/v3/TaskRunsList"; export const meta: MetaFunction = ({ data }) => { const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; @@ -247,113 +239,6 @@ export default function Page() { ); } -type TaskRunList = NonNullable["runList"]>>; - -/** - * Compact "N new runs" button, shown in the page top bar to the left of the - * time filter when the live-reload hook has detected newer runs. - */ -function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { - return ( - - - - ); -} - -/** - * Runs table with live updating. 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 → 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. - */ -function TaskRunsList({ - list, - taskSlug, - onNewRunsCountChange, - showNewRunsRef, -}: { - list: TaskRunList; - 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 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 ( -
- -
- ); -} - function TaskDetailSidebar({ task, testPath,