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" ;
2425import { motion } from "framer-motion" ;
25- import { useCallback , useEffect , useRef , useState } from "react" ;
26+ import React , { useCallback , useEffect , useRef , useState } from "react" ;
2627import { useHotkeys } from "react-hotkeys-hook" ;
2728import { redirect } from "remix-typedjson" ;
2829import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon" ;
@@ -68,7 +69,6 @@ import {
6869 eventBorderClassName ,
6970} from "~/components/runs/v3/SpanTitle" ;
7071import { TaskRunStatusIcon , runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus" ;
71- import { env } from "~/env.server" ;
7272import { useDebounce } from "~/hooks/useDebounce" ;
7373import { useEnvironment } from "~/hooks/useEnvironment" ;
7474import { 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";
99100import { CopyableText } from "~/components/primitives/CopyableText" ;
100101import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types" ;
101102import { 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
103109const 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
184267type LoaderData = SerializeFrom < typeof loader > ;
185268
186269export 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+
14561561function 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