fix: Use SSE events instead of polling for preview status updates

httpBatchLink batches polling queries behind the long-running
startPreview mutation, so refetchInterval never fires independently.
Replace polling with preview: event invalidation via the existing
useLiveUpdates SSE subscription — preview:building/ready/stopped/failed
events now trigger listPreviews and getPreviewStatus invalidation.
This commit is contained in:
Lukas May
2026-03-05 22:01:16 +01:00
parent df84a877f2
commit cdb3de353d
3 changed files with 20 additions and 27 deletions

View File

@@ -53,20 +53,14 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
const previewsQuery = trpc.listPreviews.useQuery(
{ initiativeId },
{ refetchInterval: 3000 },
);
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{
enabled: !!(activePreviewId ?? existingPreview?.id),
refetchInterval: 3000,
},
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;

View File

@@ -45,14 +45,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
const reviewablePhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
[phasesQuery.data],
);
// Select first pending review phase
// Select first pending review phase, falling back to completed phases
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
const isActivePhaseCompleted = activePhase?.status === "completed";
// Fetch projects for this initiative (needed for preview)
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
@@ -78,20 +81,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
);
// Preview state
const previewsQuery = trpc.listPreviews.useQuery(
{ initiativeId },
{ refetchInterval: 3000 },
);
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
);
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{
enabled: !!(activePreviewId ?? existingPreview?.id),
refetchInterval: 3000,
},
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
);
const preview = previewStatusQuery.data ?? existingPreview;
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
@@ -263,7 +260,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
const activePhaseName =
diffQuery.data?.phaseName ??
pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ??
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
"Phase";
// All files from the full branch diff (for sidebar file list)
@@ -285,7 +282,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
);
}
if (pendingReviewPhases.length === 0) {
if (reviewablePhases.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p>
@@ -297,8 +294,9 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
<div className="rounded-lg border border-border bg-card">
{/* Header: phase selector + toolbar */}
<ReviewHeader
phases={pendingReviewPhases.map((p) => ({ id: p.id, name: p.name }))}
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
activePhaseId={activePhaseId}
isReadOnly={isActivePhaseCompleted}
onPhaseSelect={handlePhaseSelect}
phaseName={activePhaseName}
sourceBranch={sourceBranch}

View File

@@ -29,10 +29,11 @@ function InitiativeDetailPage() {
// Single SSE stream for all live updates
useLiveUpdates([
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
]);
// tRPC queries