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 (
: undefined}
onClick={() => triggerRunMutation.mutate()}
>
Trigger Run
}
aria-label="Refresh"
onClick={() => {
void handleRefresh();
}}
/>
{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}
);
};