feat: Add preview controls to initiative-level review
Extract PreviewControls into shared component and wire up preview start/stop to InitiativeReview header alongside Push Branch and Merge & Push to Default buttons.
This commit is contained in:
@@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc";
|
||||
import { parseUnifiedDiff } from "./parse-diff";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { ReviewSidebar } from "./ReviewSidebar";
|
||||
import { PreviewControls } from "./PreviewControls";
|
||||
|
||||
interface InitiativeReviewProps {
|
||||
initiativeId: string;
|
||||
@@ -48,6 +49,44 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
{ enabled: !!selectedCommit },
|
||||
);
|
||||
|
||||
// Preview state
|
||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||
|
||||
const previewsQuery = trpc.listPreviews.useQuery(
|
||||
{ initiativeId },
|
||||
{ refetchInterval: 3000 },
|
||||
);
|
||||
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,
|
||||
},
|
||||
);
|
||||
const preview = previewStatusQuery.data ?? existingPreview;
|
||||
|
||||
const startPreview = trpc.startPreview.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setActivePreviewId(data.id);
|
||||
toast.success(`Preview running at ${data.url}`);
|
||||
},
|
||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||
});
|
||||
|
||||
const stopPreview = trpc.stopPreview.useMutation({
|
||||
onSuccess: () => {
|
||||
setActivePreviewId(null);
|
||||
toast.success("Preview stopped");
|
||||
previewsQuery.refetch();
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
||||
});
|
||||
|
||||
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
||||
onSuccess: (_data, variables) => {
|
||||
const msg = variables.strategy === "merge_and_push"
|
||||
@@ -87,6 +126,33 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
||||
|
||||
const previewState = firstProjectId && sourceBranch
|
||||
? {
|
||||
status: startPreview.isPending
|
||||
? ("building" as const)
|
||||
: preview?.status === "running"
|
||||
? ("running" as const)
|
||||
: preview?.status === "building"
|
||||
? ("building" as const)
|
||||
: preview?.status === "failed"
|
||||
? ("failed" as const)
|
||||
: ("idle" as const),
|
||||
url: preview?.url ?? undefined,
|
||||
onStart: () =>
|
||||
startPreview.mutate({
|
||||
initiativeId,
|
||||
projectId: firstProjectId,
|
||||
branch: sourceBranch,
|
||||
}),
|
||||
onStop: () => {
|
||||
const id = activePreviewId ?? existingPreview?.id;
|
||||
if (id) stopPreview.mutate({ previewId: id });
|
||||
},
|
||||
isStarting: startPreview.isPending,
|
||||
isStopping: stopPreview.isPending,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
||||
{/* Header */}
|
||||
@@ -127,8 +193,9 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: action buttons */}
|
||||
{/* Right: preview + action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{previewState && <PreviewControls preview={previewState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Square,
|
||||
CircleDot,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export interface PreviewState {
|
||||
status: "idle" | "building" | "running" | "failed";
|
||||
url?: string;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
}
|
||||
|
||||
export function PreviewControls({ preview }: { preview: PreviewState }) {
|
||||
if (preview.status === "building" || preview.isStarting) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Building...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "running") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Preview
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStop}
|
||||
disabled={preview.isStopping}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
className="h-7 text-xs text-status-error-fg"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
disabled={preview.isStarting}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
FileCode,
|
||||
Plus,
|
||||
Minus,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Square,
|
||||
CircleDot,
|
||||
RotateCcw,
|
||||
ArrowRight,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
@@ -18,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PreviewControls } from "./PreviewControls";
|
||||
import type { PreviewState } from "./PreviewControls";
|
||||
import type { FileDiff, ReviewStatus } from "./types";
|
||||
|
||||
interface PhaseOption {
|
||||
@@ -25,15 +23,6 @@ interface PhaseOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PreviewState {
|
||||
status: "idle" | "building" | "running" | "failed";
|
||||
url?: string;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
}
|
||||
|
||||
interface ReviewHeaderProps {
|
||||
phases: PhaseOption[];
|
||||
activePhaseId: string | null;
|
||||
@@ -285,66 +274,3 @@ export function ReviewHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewControls({ preview }: { preview: PreviewState }) {
|
||||
if (preview.status === "building" || preview.isStarting) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>Building...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "running") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<a
|
||||
href={preview.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
Preview
|
||||
<ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStop}
|
||||
disabled={preview.isStopping}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Square className="h-2.5 w-2.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.status === "failed") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
className="h-7 text-xs text-status-error-fg"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Retry Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={preview.onStart}
|
||||
disabled={preview.isStarting}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Preview
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user