Skip to content

Commit 1b7f1fa

Browse files
feat(core): page between jobs from the detail view
Previously the only way to move between jobs was back to the queue list and into the next one. Add prev/next navigation to the job detail page so you can step through a queue in place: - Chevron buttons (left of the title) plus arrow-key shortcuts (left/right), ignored while typing in inputs. - Paging follows the same list, status filter, and sort the queue page was showing — the queue's status/sort ride along in the URL (added to jobSearchSchema), so prev/next on a Failed-filtered view stays within failed jobs. Deep links / command palette / Runs fall back to the full queue list for that queue. - Reuses the shared useJobs query (same cache key as the list) so navigation is instant, and auto-fetches the next page when you reach the last loaded job so next doesn't dead-end. Buttons disable at the ends of the list.
1 parent aefd22c commit 1b7f1fa

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

packages/core/src/ui/pages/job.tsx

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Check,
55
CheckCircle2,
66
ChevronDown,
7+
ChevronLeft,
78
ChevronRight,
89
Clock,
910
Copy,
@@ -29,9 +30,16 @@ import { StatusBadge } from "@/components/shared/status-badge";
2930
import { Badge } from "@/components/ui/badge";
3031
import { Button } from "@/components/ui/button";
3132
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
33+
import {
34+
Tooltip,
35+
TooltipContent,
36+
TooltipTrigger,
37+
} from "@/components/ui/tooltip";
38+
import type { JobStatus } from "@/core/types";
3239
import {
3340
useJob,
3441
useJobLogs,
42+
useJobs,
3543
usePromoteJob,
3644
useRemoveJob,
3745
useRetryJob,
@@ -72,6 +80,85 @@ export function JobPage({
7280
const promoteMutation = usePromoteJob();
7381
const [copied, setCopied] = React.useState(false);
7482

83+
// Prev/next navigation: page through the same job list (and filter/sort)
84+
// the user was viewing on the queue page, without going back to the list.
85+
const listStatus =
86+
search.status && search.status !== "all"
87+
? (search.status as JobStatus)
88+
: undefined;
89+
const {
90+
data: listData,
91+
hasNextPage,
92+
fetchNextPage,
93+
isFetchingNextPage,
94+
} = useJobs(queueName, listStatus, search.sort);
95+
const jobList = React.useMemo(
96+
() => listData?.pages.flatMap((page) => page.data) ?? [],
97+
[listData],
98+
);
99+
const currentIndex = jobList.findIndex((j) => j.id === jobId);
100+
const prevJob = currentIndex > 0 ? jobList[currentIndex - 1] : undefined;
101+
const nextJob =
102+
currentIndex >= 0 && currentIndex < jobList.length - 1
103+
? jobList[currentIndex + 1]
104+
: undefined;
105+
106+
const goToJob = React.useCallback(
107+
(targetId: string | undefined) => {
108+
if (!targetId) return;
109+
navigate({
110+
to: "/queues/$queueName/jobs/$jobId",
111+
params: { queueName, jobId: targetId },
112+
search,
113+
});
114+
},
115+
[navigate, queueName, search],
116+
);
117+
118+
// When sitting on the last loaded job, pull the next page so the "next"
119+
// button becomes available instead of dead-ending mid-queue.
120+
React.useEffect(() => {
121+
if (
122+
currentIndex >= 0 &&
123+
currentIndex >= jobList.length - 1 &&
124+
hasNextPage &&
125+
!isFetchingNextPage
126+
) {
127+
fetchNextPage();
128+
}
129+
}, [
130+
currentIndex,
131+
jobList.length,
132+
hasNextPage,
133+
isFetchingNextPage,
134+
fetchNextPage,
135+
]);
136+
137+
// Left/right arrow keys page between jobs, matching the on-screen buttons.
138+
React.useEffect(() => {
139+
const handleKeyDown = (e: KeyboardEvent) => {
140+
if (e.metaKey || e.ctrlKey || e.altKey) return;
141+
const target = e.target as HTMLElement | null;
142+
if (
143+
target &&
144+
(target.tagName === "INPUT" ||
145+
target.tagName === "TEXTAREA" ||
146+
target.isContentEditable)
147+
) {
148+
return;
149+
}
150+
if (e.key === "ArrowLeft" && prevJob) {
151+
e.preventDefault();
152+
goToJob(prevJob.id);
153+
} else if (e.key === "ArrowRight" && nextJob) {
154+
e.preventDefault();
155+
goToJob(nextJob.id);
156+
}
157+
};
158+
document.addEventListener("keydown", handleKeyDown);
159+
return () => document.removeEventListener("keydown", handleKeyDown);
160+
}, [prevJob, nextJob, goToJob]);
161+
75162
const actionLoading =
76163
retryMutation.isPending ||
77164
removeMutation.isPending ||
@@ -193,6 +280,38 @@ export function JobPage({
193280
{/* Title Row */}
194281
<div className="flex items-center justify-between border-b px-4 py-3">
195282
<div className="flex items-center gap-3">
283+
<div className="flex items-center">
284+
<Tooltip>
285+
<TooltipTrigger asChild>
286+
<Button
287+
variant="outline"
288+
size="icon"
289+
className="h-8 w-8"
290+
onClick={() => goToJob(prevJob?.id)}
291+
disabled={!prevJob}
292+
>
293+
<ChevronLeft className="h-4 w-4" />
294+
<span className="sr-only">Previous job</span>
295+
</Button>
296+
</TooltipTrigger>
297+
<TooltipContent>Previous job (←)</TooltipContent>
298+
</Tooltip>
299+
<Tooltip>
300+
<TooltipTrigger asChild>
301+
<Button
302+
variant="outline"
303+
size="icon"
304+
className="h-8 w-8 border-l-0"
305+
onClick={() => goToJob(nextJob?.id)}
306+
disabled={!nextJob}
307+
>
308+
<ChevronRight className="h-4 w-4" />
309+
<span className="sr-only">Next job</span>
310+
</Button>
311+
</TooltipTrigger>
312+
<TooltipContent>Next job (→)</TooltipContent>
313+
</Tooltip>
314+
</div>
196315
<div className="flex items-center gap-2 bg-muted px-2.5 py-1 text-sm font-medium">
197316
{job.name}
198317
</div>

packages/core/src/ui/router.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ export const jobSearchSchema = z.object({
104104
tab: z
105105
.enum(["payload", "output", "error", "retries", "timeline", "logs"])
106106
.optional(),
107+
// List context for prev/next navigation between jobs — mirrors the queue
108+
// list's status filter and sort so paging matches the view we came from.
109+
status: z
110+
.enum([
111+
"all",
112+
"active",
113+
"waiting",
114+
"waiting-children",
115+
"prioritized",
116+
"completed",
117+
"failed",
118+
"delayed",
119+
])
120+
.optional(),
121+
sort: z.string().optional(),
107122
});
108123

109124
export type JobSearch = z.infer<typeof jobSearchSchema>;
@@ -512,6 +527,7 @@ function QueueRoute() {
512527
navigate({
513528
to: "/queues/$queueName/jobs/$jobId",
514529
params: { queueName, jobId },
530+
search: { status: search.status, sort: search.sort },
515531
})
516532
}
517533
/>

0 commit comments

Comments
 (0)