Skip to content

Commit 4e79186

Browse files
committed
feat(webapp): Add navigation for adjacent runs for Run page (with keyboard shortcuts)
Add previous/next run navigation buttons to run detail page header Support [ and ] keyboard shortcuts to jump between adjacent runs Preserve runs table state (filters, pagination) when navigating Preload adjacent page runs at boundaries for seamless navigation Add actions prop to PageTitle component Document shortcut in keyboard shortcuts panel
1 parent c6debec commit 4e79186

File tree

4 files changed

+231
-9
lines changed

4 files changed

+231
-9
lines changed

apps/webapp/app/components/Shortcuts.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ function ShortcutContent() {
134134
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
135135
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
136136
</Shortcut>
137+
<Shortcut name="Jump to adjacent">
138+
<ShortcutKey shortcut={{ key: "[" }} variant="medium/bright" />
139+
<ShortcutKey shortcut={{ key: "]" }} variant="medium/bright" />
140+
</Shortcut>
137141
<Shortcut name="Expand all">
138142
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
139143
</Shortcut>

apps/webapp/app/components/primitives/PageHeader.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ type PageTitleProps = {
3636
to: string;
3737
text: string;
3838
};
39+
actions?: ReactNode;
3940
};
4041

41-
export function PageTitle({ title, backButton }: PageTitleProps) {
42+
export function PageTitle({ title, backButton, actions }: PageTitleProps) {
4243
return (
4344
<div className="flex items-center gap-2">
4445
{backButton && (
@@ -53,6 +54,7 @@ export function PageTitle({ title, backButton }: PageTitleProps) {
5354
</div>
5455
)}
5556
<Header2 className="flex items-center gap-1">{title}</Header2>
57+
{actions && <div className="ml-auto">{actions}</div>}
5658
</div>
5759
);
5860
}

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function TaskRunsTable({
8383
const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
8484
const { isManagedCloud } = useFeatures();
8585
const location = useOptimisticLocation();
86-
const tableStateParam = encodeURIComponent(location.search);
86+
const tableStateParam = encodeURIComponent(location.search ? `${location.search}&rt=1` : "rt=1");
8787

8888
const showCompute = isManagedCloud;
8989

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam/route.tsx

Lines changed: 223 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
ArrowUturnLeftIcon,
33
BoltSlashIcon,
44
BookOpenIcon,
5+
ChevronUpIcon,
56
ChevronDownIcon,
67
ChevronRightIcon,
78
InformationCircleIcon,
@@ -20,9 +21,9 @@ import {
2021
nanosecondsToMilliseconds,
2122
tryCatch,
2223
} from "@trigger.dev/core/v3";
23-
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
24+
import type { $Enums, RuntimeEnvironmentType } from "@trigger.dev/database";
2425
import { motion } from "framer-motion";
25-
import { useCallback, useEffect, useRef, useState } from "react";
26+
import React, { useCallback, useEffect, useRef, useState } from "react";
2627
import { useHotkeys } from "react-hotkeys-hook";
2728
import { redirect } from "remix-typedjson";
2829
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
@@ -68,7 +69,6 @@ import {
6869
eventBorderClassName,
6970
} from "~/components/runs/v3/SpanTitle";
7071
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
71-
import { env } from "~/env.server";
7272
import { useDebounce } from "~/hooks/useDebounce";
7373
import { useEnvironment } from "~/hooks/useEnvironment";
7474
import { useEventSource } from "~/hooks/useEventSource";
@@ -88,6 +88,7 @@ import {
8888
docsPath,
8989
v3BillingPath,
9090
v3RunParamsSchema,
91+
v3RunPath,
9192
v3RunRedirectPath,
9293
v3RunSpanPath,
9394
v3RunStreamingPath,
@@ -99,6 +100,11 @@ import { useSearchParams } from "~/hooks/useSearchParam";
99100
import { CopyableText } from "~/components/primitives/CopyableText";
100101
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
101102
import { getRunFiltersFromSearchParams } from "~/components/runs/v3/RunFilters";
103+
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
104+
import { $replica } from "~/db.server";
105+
import { clickhouseClient } from "~/services/clickhouseInstance.server";
106+
import { findProjectBySlug } from "~/models/project.server";
107+
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
102108

103109
const resizableSettings = {
104110
parent: {
@@ -170,6 +176,82 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
170176
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
171177
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);
172178

179+
// Load runs list data from tableState if present
180+
let runsList: {
181+
runs: Array<{ friendlyId: string }>;
182+
pagination: { next?: string; previous?: string };
183+
prevPageLastRun?: { friendlyId: string; cursor: string };
184+
nextPageFirstRun?: { friendlyId: string; cursor: string };
185+
} | null = null;
186+
const tableStateParam = url.searchParams.get("tableState");
187+
if (tableStateParam) {
188+
try {
189+
const tableStateSearchParams = new URLSearchParams(decodeURIComponent(tableStateParam));
190+
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
191+
192+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
193+
const environment = await findEnvironmentBySlug(project?.id ?? "", envParam, userId);
194+
195+
if (project && environment) {
196+
const runsListPresenter = new NextRunListPresenter($replica, clickhouseClient);
197+
const currentPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
198+
userId,
199+
projectId: project.id,
200+
...filters,
201+
pageSize: 25, // Load enough runs to provide navigation context
202+
});
203+
204+
runsList = {
205+
runs: currentPageResult.runs,
206+
pagination: currentPageResult.pagination,
207+
};
208+
209+
// Check if the current run is at the boundary and preload adjacent page if needed
210+
const currentRunIndex = currentPageResult.runs.findIndex((r) => r.friendlyId === runParam);
211+
212+
// If current run is first in list and there's a previous page, load the last run from prev page
213+
if (currentRunIndex === 0 && currentPageResult.pagination.previous) {
214+
const prevPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
215+
userId,
216+
projectId: project.id,
217+
...filters,
218+
cursor: currentPageResult.pagination.previous,
219+
direction: "backward",
220+
pageSize: 1, // We only need the last run from the previous page
221+
});
222+
if (prevPageResult.runs.length > 0) {
223+
runsList.prevPageLastRun = {
224+
friendlyId: prevPageResult.runs[0].friendlyId,
225+
cursor: currentPageResult.pagination.previous,
226+
};
227+
}
228+
}
229+
230+
// If current run is last in list and there's a next page, load the first run from next page
231+
if (currentRunIndex === currentPageResult.runs.length - 1 && currentPageResult.pagination.next) {
232+
const nextPageResult = await runsListPresenter.call(project.organizationId, environment.id, {
233+
userId,
234+
projectId: project.id,
235+
...filters,
236+
cursor: currentPageResult.pagination.next,
237+
direction: "forward",
238+
pageSize: 1, // We only need the first run from the next page
239+
});
240+
if (nextPageResult.runs.length > 0) {
241+
runsList.nextPageFirstRun = {
242+
friendlyId: nextPageResult.runs[0].friendlyId,
243+
cursor: currentPageResult.pagination.next,
244+
};
245+
}
246+
}
247+
}
248+
} catch (error) {
249+
// If there's an error parsing or loading runs list, just ignore it
250+
// and don't include the runsList in the response
251+
console.error("Error loading runs list from tableState:", error);
252+
}
253+
}
254+
173255
return json({
174256
run: result.run,
175257
trace: result.trace,
@@ -178,13 +260,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
178260
parent,
179261
tree,
180262
},
263+
runsList,
181264
});
182265
};
183266

184267
type LoaderData = SerializeFrom<typeof loader>;
185268

186269
export default function Page() {
187-
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
270+
const { run, trace, resizable, maximumLiveReloadingSetting, runsList } = useLoaderData<typeof loader>();
188271
const organization = useOrganization();
189272
const project = useProject();
190273
const environment = useEnvironment();
@@ -193,9 +276,11 @@ export default function Page() {
193276
isCompleted: run.completedAt !== null,
194277
});
195278
const { value } = useSearchParams();
196-
const params = decodeURIComponent(value("tableState") ?? "");
197-
const searchParams = new URLSearchParams(params);
198-
const filters = getRunFiltersFromSearchParams(searchParams);
279+
const tableState = decodeURIComponent(value("tableState") ?? "");
280+
const tableStateSearchParams = new URLSearchParams(tableState);
281+
const filters = getRunFiltersFromSearchParams(tableStateSearchParams);
282+
283+
const [previousRunPath, nextRunPath] = useAdjacentRunPaths({organization, project, environment, tableStateSearchParams, run, runsList});
199284

200285
return (
201286
<>
@@ -206,6 +291,12 @@ export default function Page() {
206291
text: "Runs",
207292
}}
208293
title={<CopyableText value={run.friendlyId} variant="text-below"/>}
294+
actions={
295+
tableState && (<div className="flex">
296+
<PreviousRunButton to={previousRunPath} />
297+
<NextRunButton to={nextRunPath} />
298+
</div>)
299+
}
209300
/>
210301
{environment.type === "DEVELOPMENT" && <DevDisconnectedBanner isConnected={isConnected} />}
211302
<PageAccessories>
@@ -282,13 +373,15 @@ export default function Page() {
282373
trace={trace}
283374
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
284375
resizable={resizable}
376+
runsList={runsList}
285377
/>
286378
) : (
287379
<NoLogsView
288380
run={run}
289381
trace={trace}
290382
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
291383
resizable={resizable}
384+
runsList={runsList}
292385
/>
293386
)}
294387
</PageBody>
@@ -1437,6 +1530,7 @@ function KeyboardShortcuts({
14371530
return (
14381531
<>
14391532
<ArrowKeyShortcuts />
1533+
<AdjacentRunsShortcuts />
14401534
<ShortcutWithAction
14411535
shortcut={{ key: "e" }}
14421536
action={() => expandAllBelowDepth(0)}
@@ -1453,6 +1547,17 @@ function KeyboardShortcuts({
14531547
);
14541548
}
14551549

1550+
function AdjacentRunsShortcuts({
1551+
}) {
1552+
return (<div className="flex items-center gap-0.5">
1553+
<ShortcutKey shortcut={{ key: "[" }} variant="medium" className="ml-0 mr-0 px-1" />
1554+
<ShortcutKey shortcut={{ key: "]" }} variant="medium" className="ml-0 mr-0 px-1" />
1555+
<Paragraph variant="extra-small" className="ml-1.5 whitespace-nowrap">
1556+
Adjacent runs
1557+
</Paragraph>
1558+
</div>);
1559+
}
1560+
14561561
function ArrowKeyShortcuts() {
14571562
return (
14581563
<div className="flex items-center gap-0.5">
@@ -1531,3 +1636,114 @@ function SearchField({ onChange }: { onChange: (value: string) => void }) {
15311636
/>
15321637
);
15331638
}
1639+
1640+
function useAdjacentRunPaths({
1641+
organization,
1642+
project,
1643+
environment,
1644+
tableStateSearchParams,
1645+
run,
1646+
runsList,
1647+
}: {
1648+
organization: { slug: string };
1649+
project: { slug: string };
1650+
environment: { slug: string };
1651+
tableStateSearchParams: URLSearchParams;
1652+
run: { friendlyId: string };
1653+
runsList: {
1654+
runs: Array<{ friendlyId: string }>;
1655+
pagination: { next?: string; previous?: string };
1656+
prevPageLastRun?: { friendlyId: string; cursor: string };
1657+
nextPageFirstRun?: { friendlyId: string; cursor: string };
1658+
} | null;
1659+
}): [string | null, string | null] {
1660+
return React.useMemo(() => {
1661+
if (!runsList || runsList.runs.length === 0) {
1662+
return [null, null];
1663+
}
1664+
1665+
const currentIndex = runsList.runs.findIndex((r) => r.friendlyId === run.friendlyId);
1666+
1667+
if (currentIndex === -1) {
1668+
return [null, null];
1669+
}
1670+
1671+
// Determine previous run: use prevPageLastRun if at first position, otherwise use previous run in list
1672+
let previousRun: { friendlyId: string } | null = null;
1673+
const previousRunTableState = new URLSearchParams(tableStateSearchParams.toString());
1674+
if (currentIndex > 0) {
1675+
previousRun = runsList.runs[currentIndex - 1];
1676+
} else if (runsList.prevPageLastRun) {
1677+
previousRun = runsList.prevPageLastRun;
1678+
// Update tableState with the new cursor for the previous page
1679+
previousRunTableState.set("cursor", runsList.prevPageLastRun.cursor);
1680+
previousRunTableState.set("direction", "backward");
1681+
}
1682+
1683+
// Determine next run: use nextPageFirstRun if at last position, otherwise use next run in list
1684+
let nextRun: { friendlyId: string } | null = null;
1685+
const nextRunTableState = new URLSearchParams(tableStateSearchParams.toString());
1686+
if (currentIndex < runsList.runs.length - 1) {
1687+
nextRun = runsList.runs[currentIndex + 1];
1688+
} else if (runsList.nextPageFirstRun) {
1689+
nextRun = runsList.nextPageFirstRun;
1690+
// Update tableState with the new cursor for the next page
1691+
nextRunTableState.set("cursor", runsList.nextPageFirstRun.cursor);
1692+
nextRunTableState.set("direction", "forward");
1693+
}
1694+
1695+
const previousURLSearchParams = new URLSearchParams();
1696+
previousURLSearchParams.set("tableState", previousRunTableState.toString());
1697+
const previousRunPath = previousRun
1698+
? v3RunPath(organization, project, environment, previousRun, previousURLSearchParams)
1699+
: null;
1700+
1701+
const nextURLSearchParams = new URLSearchParams();
1702+
nextURLSearchParams.set("tableState", nextRunTableState.toString());
1703+
const nextRunPath = nextRun
1704+
? v3RunPath(organization, project, environment, nextRun, nextURLSearchParams)
1705+
: null;
1706+
1707+
return [previousRunPath, nextRunPath];
1708+
}, [organization, project, environment, tableStateSearchParams, run.friendlyId, runsList]);
1709+
}
1710+
1711+
1712+
function PreviousRunButton({ to }: { to: string | null }) {
1713+
return (
1714+
<div className={cn("peer/prev order-1", !to && "pointer-events-none")}>
1715+
<LinkButton
1716+
to={to ? to : '#'}
1717+
variant={"minimal/small"}
1718+
LeadingIcon={ChevronUpIcon}
1719+
className={cn(
1720+
"flex items-center rounded-r-none border-r-0 pl-2 pr-[0.5625rem]",
1721+
!to && "cursor-not-allowed opacity-50"
1722+
)}
1723+
onClick={(e) => !to && e.preventDefault()}
1724+
shortcut={{ key: "[" }}
1725+
tooltip="Previous Run"
1726+
/>
1727+
</div>
1728+
);
1729+
}
1730+
1731+
function NextRunButton({ to }: { to: string | null }) {
1732+
return (
1733+
<div className={cn("peer/next order-3", !to && "pointer-events-none")}>
1734+
<LinkButton
1735+
to={to ? to : '#'}
1736+
variant={"minimal/small"}
1737+
TrailingIcon={ChevronDownIcon}
1738+
className={cn(
1739+
"flex items-center rounded-l-none border-l-0 pl-[0.5625rem] pr-2",
1740+
!to && "cursor-not-allowed opacity-50"
1741+
)}
1742+
onClick={(e) => !to && e.preventDefault()}
1743+
shortcut={{ key: "]" }}
1744+
tooltip="Next Run"
1745+
/>
1746+
</div>
1747+
);
1748+
}
1749+

0 commit comments

Comments
 (0)