Skip to content

Commit 6fcb799

Browse files
committed
Merge remote-tracking branch 'origin/main' into samejr/Settings-page-layouts
2 parents e8d4447 + 3e53404 commit 6fcb799

14 files changed

Lines changed: 444 additions & 82 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,6 @@ ailogger-output.log
7979

8080
# local git worktree checkouts (not source) — keeps oxfmt/oxlint from descending into them
8181
.worktrees/
82+
83+
# local planning/design docs, not committed
84+
**/docs/superpowers/

.server-changes/sso-entitlement.md

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+
SSO and Directory Sync are no longer restricted to Enterprise plans — get in touch and we can turn them on for your organization whatever plan you're on.
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)