import type { ProjectDetail } from "@/contracts"; import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Play, RefreshCw } from "lucide-react"; import { useParams } from "react-router-dom"; import { ProjectMetadataCard, RunRow, WebhookCard } from "@/client/components"; import { Breadcrumbs, Button, EmptyState, ErrorBanner, Skeleton } from "@/client/components/ui"; import { formatApiError, getApiClient, queryKeys } from "@/client/lib"; import { useToast } from "@/client/toast"; const RUN_PAGE_LIMIT = 20; const hasRunActivity = (detail: ProjectDetail | undefined): boolean => detail !== undefined && (detail.activeRun !== null || detail.pendingRuns.length > 0); const ProjectDetailSkeleton = () => (
{Array.from({ length: 4 }, (_, i) => ( ))}
{Array.from({ length: 3 }, (_, i) => (
))}
); export const ProjectDetailPage = () => { const { projectId } = useParams<{ projectId: string }>(); const { pushToast } = useToast(); const queryClient = useQueryClient(); const resolvedProjectId = projectId ?? ""; const detailQuery = useQuery({ queryKey: queryKeys.projectDetail(resolvedProjectId), queryFn: () => getApiClient().getProjectDetail(resolvedProjectId), enabled: resolvedProjectId.length > 0, refetchInterval: (query) => (hasRunActivity(query.state.data) ? 7_000 : false), }); const runsQuery = useInfiniteQuery({ queryKey: queryKeys.projectRuns(resolvedProjectId), queryFn: ({ pageParam }) => getApiClient().getProjectRuns(resolvedProjectId, { limit: RUN_PAGE_LIMIT, cursor: pageParam ?? undefined, }), enabled: resolvedProjectId.length > 0, getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, initialPageParam: null as string | null, refetchInterval: hasRunActivity(detailQuery.data) ? 7_000 : false, }); const triggerRunMutation = useMutation({ mutationFn: () => getApiClient().triggerRun(resolvedProjectId), onSuccess: async (response) => { pushToast({ tone: "success", title: "Run triggered", message: `Run ${response.runId} queued.` }); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.projectDetail(resolvedProjectId) }), queryClient.invalidateQueries({ queryKey: queryKeys.projectRuns(resolvedProjectId) }), queryClient.invalidateQueries({ queryKey: queryKeys.projectsRoot() }), ]); }, onError: (reason) => { pushToast({ tone: "error", title: "Trigger failed", message: formatApiError(reason) }); }, }); const handleRefresh = async () => { await Promise.all([detailQuery.refetch(), runsQuery.refetch()]); }; const pageTitle = detailQuery.data?.project.name ?? projectId?.slice(0, 8) ?? ""; if (!projectId) { return (
); } if (detailQuery.isPending || runsQuery.isPending) { return ( <>

{pageTitle}

); } if (detailQuery.isError && !detailQuery.data) { return (
); } if (!detailQuery.data) return null; const { project } = detailQuery.data; const runs = runsQuery.data?.pages.flatMap((page) => page.runs) ?? []; const runsError = runsQuery.isError ? formatApiError(runsQuery.error) : null; const loadingMore = runsQuery.isFetchingNextPage; const triggering = triggerRunMutation.isPending; return (

Run history

Recent runs

{detailQuery.isError ? : null} {runsError && runs.length === 0 ? : null} {runs.length === 0 && !runsError ? ( } title="No runs yet" description="Trigger a run to get started." /> ) : (
{runs.map((run) => ( ))}
)} {runsQuery.hasNextPage ? ( ) : null} {runsError && runs.length > 0 ? (
) : null}
); };