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 { parseUnifiedDiff } from "./parse-diff";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { ReviewSidebar } from "./ReviewSidebar";
|
import { ReviewSidebar } from "./ReviewSidebar";
|
||||||
|
import { PreviewControls } from "./PreviewControls";
|
||||||
|
|
||||||
interface InitiativeReviewProps {
|
interface InitiativeReviewProps {
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
@@ -48,6 +49,44 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
{ enabled: !!selectedCommit },
|
{ 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({
|
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
const msg = variables.strategy === "merge_and_push"
|
const msg = variables.strategy === "merge_and_push"
|
||||||
@@ -87,6 +126,33 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||||
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
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 (
|
return (
|
||||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -127,8 +193,9 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: action buttons */}
|
{/* Right: preview + action buttons */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{previewState && <PreviewControls preview={previewState} />}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
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,
|
FileCode,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Square,
|
|
||||||
CircleDot,
|
|
||||||
RotateCcw,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Eye,
|
Eye,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -18,6 +14,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { PreviewControls } from "./PreviewControls";
|
||||||
|
import type { PreviewState } from "./PreviewControls";
|
||||||
import type { FileDiff, ReviewStatus } from "./types";
|
import type { FileDiff, ReviewStatus } from "./types";
|
||||||
|
|
||||||
interface PhaseOption {
|
interface PhaseOption {
|
||||||
@@ -25,15 +23,6 @@ interface PhaseOption {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PreviewState {
|
|
||||||
status: "idle" | "building" | "running" | "failed";
|
|
||||||
url?: string;
|
|
||||||
onStart: () => void;
|
|
||||||
onStop: () => void;
|
|
||||||
isStarting: boolean;
|
|
||||||
isStopping: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ReviewHeaderProps {
|
interface ReviewHeaderProps {
|
||||||
phases: PhaseOption[];
|
phases: PhaseOption[];
|
||||||
activePhaseId: string | null;
|
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