mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Task lifecycle actions get a 25-action tip map, every dialog field and git panel control annotated; fixes two round-1 disabled-button tips that could never fire and a double data-state stamp on tab-progress.
628 lines
21 KiB
TypeScript
628 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import { use, useState } from "react";
|
|
import axios from "axios";
|
|
import { useTaskDetail, useTaskLifecycle, useUpdateTask } from "@/hooks";
|
|
import { useCreateBranch, useCreatePR, useMergePR } from "@/hooks/use-git";
|
|
import { Team, TaskStatus } from "@/types";
|
|
import {
|
|
TaskHeader,
|
|
TaskBreadcrumb,
|
|
TaskListNav,
|
|
TaskMetadata,
|
|
TaskTabs,
|
|
} from "@/components/tasks/task-detail";
|
|
import { ApproveAndStartButton } from "@/components/tasks/approve-and-start-button";
|
|
import {
|
|
EscalateToCeoDialog,
|
|
ApproveAndMergeDialog,
|
|
CeoApproveDialog,
|
|
CeoRejectDialog,
|
|
CreateBranchDialog,
|
|
CreatePRDialog,
|
|
RequiredNotesDialog,
|
|
} from "@/components/tasks/task-detail/task-action-dialogs";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { AlertTriangle, ArrowLeft } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { HelpTip } from "@/components/ui/help-tip";
|
|
|
|
interface TaskDetailPageProps {
|
|
params: Promise<{ taskId: string }>;
|
|
}
|
|
|
|
export default function TaskDetailPage({ params }: TaskDetailPageProps) {
|
|
const { taskId } = use(params);
|
|
const router = useRouter();
|
|
const { task, project, isLoading, error, refetch } = useTaskDetail(taskId);
|
|
|
|
const lifecycle = useTaskLifecycle();
|
|
const updateTask = useUpdateTask();
|
|
const createBranch = useCreateBranch();
|
|
const createPR = useCreatePR();
|
|
const mergePR = useMergePR();
|
|
|
|
// Dialog states
|
|
const [escalateDialogOpen, setEscalateDialogOpen] = useState(false);
|
|
const [approveAndMergeDialogOpen, setApproveAndMergeDialogOpen] =
|
|
useState(false);
|
|
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
|
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
|
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
|
|
const [prDialogOpen, setPrDialogOpen] = useState(false);
|
|
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
|
const [passQaDialogOpen, setPassQaDialogOpen] = useState(false);
|
|
const [failQaDialogOpen, setFailQaDialogOpen] = useState(false);
|
|
const [docsCompleteDialogOpen, setDocsCompleteDialogOpen] = useState(false);
|
|
const [submitPmReviewDialogOpen, setSubmitPmReviewDialogOpen] =
|
|
useState(false);
|
|
const [completeDialogOpen, setCompleteDialogOpen] = useState(false);
|
|
|
|
const handleAction = async (action: string) => {
|
|
if (!task) return;
|
|
|
|
try {
|
|
switch (action) {
|
|
case "claim":
|
|
await lifecycle.claim.mutateAsync(task.id);
|
|
toast.success("Task claimed successfully");
|
|
break;
|
|
case "start":
|
|
await lifecycle.start.mutateAsync(task.id);
|
|
toast.success("Task started");
|
|
break;
|
|
case "pause":
|
|
await lifecycle.pause.mutateAsync(task.id);
|
|
toast.success("Task paused");
|
|
break;
|
|
case "resume":
|
|
await lifecycle.resume.mutateAsync(task.id);
|
|
toast.success("Task resumed");
|
|
break;
|
|
case "block":
|
|
await lifecycle.block.mutateAsync({ taskId: task.id });
|
|
toast.success("Task marked as blocked");
|
|
break;
|
|
case "unblock":
|
|
await lifecycle.unblock.mutateAsync(task.id);
|
|
toast.success("Task unblocked");
|
|
break;
|
|
case "verify":
|
|
await lifecycle.verify.mutateAsync(task.id);
|
|
toast.success("Task self-verified");
|
|
break;
|
|
case "submit-qa":
|
|
await lifecycle.submitQa.mutateAsync({ taskId: task.id });
|
|
toast.success("Task submitted for QA");
|
|
break;
|
|
case "pass-qa":
|
|
setPassQaDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required note
|
|
case "fail-qa":
|
|
setFailQaDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required note
|
|
case "complete":
|
|
setCompleteDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required justification
|
|
case "cancel":
|
|
setCancelDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required reason
|
|
case "reopen":
|
|
await lifecycle.reopen.mutateAsync(task.id);
|
|
toast.success("Task reopened");
|
|
break;
|
|
case "activate":
|
|
await lifecycle.activate.mutateAsync(task.id);
|
|
toast.success("Task activated");
|
|
break;
|
|
case "start-revision":
|
|
// Operator override: /start is assignee-only, so route the
|
|
// needs_revision -> in_progress nudge through the audited admin
|
|
// status path (PATCH /tasks/{id}), which privileged roles may use.
|
|
await updateTask.mutateAsync({
|
|
taskId: task.id,
|
|
updates: { status: TaskStatus.IN_PROGRESS },
|
|
});
|
|
toast.success("Revision started");
|
|
break;
|
|
// Git workflow actions
|
|
case "docs-complete":
|
|
setDocsCompleteDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required note
|
|
case "submit-pm-review":
|
|
setSubmitPmReviewDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required note
|
|
case "approve-and-merge":
|
|
setApproveAndMergeDialogOpen(true);
|
|
return; // Don't refetch yet — dialog handles confirmation
|
|
case "ceo-approve":
|
|
setApproveDialogOpen(true);
|
|
return; // Don't refetch yet — dialog collects the required note
|
|
case "ceo-reject":
|
|
setRejectDialogOpen(true);
|
|
return; // Don't refetch yet, dialog will handle it
|
|
case "escalate-to-ceo":
|
|
setEscalateDialogOpen(true);
|
|
return; // Don't refetch yet, dialog will handle it
|
|
case "request-changes":
|
|
await lifecycle.failQa.mutateAsync({
|
|
taskId: task.id,
|
|
qaNotes: "Changes requested by PM",
|
|
});
|
|
toast.success("Changes requested");
|
|
break;
|
|
case "create-branch":
|
|
if (!project) {
|
|
toast.error("Project not found - cannot create branch");
|
|
return;
|
|
}
|
|
setBranchDialogOpen(true);
|
|
return; // Don't refetch yet, dialog will handle it
|
|
case "create-pr":
|
|
if (!project) {
|
|
toast.error("Project not found - cannot create PR");
|
|
return;
|
|
}
|
|
setPrDialogOpen(true);
|
|
return; // Don't refetch yet, dialog will handle it
|
|
case "merge-pr":
|
|
if (!project) {
|
|
toast.error("Project not found - cannot merge PR");
|
|
return;
|
|
}
|
|
if (!task.pr_number) {
|
|
toast.error("No PR number found on this task");
|
|
return;
|
|
}
|
|
await mergePR.mutateAsync({
|
|
project_slug: project.slug,
|
|
pr_number: task.pr_number,
|
|
task_id: task.id,
|
|
agent_id: "ceo", // CEO is merging the PR from the panel
|
|
});
|
|
toast.success(`PR #${task.pr_number} merged successfully`);
|
|
break;
|
|
default:
|
|
console.warn("Unknown action:", action);
|
|
}
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to " + action.replace("-", " ") + " task");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
// Dialog handlers
|
|
const handleEscalateToCeo = async (reason: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.escalateToCeo.mutateAsync({ taskId: task.id, reason });
|
|
toast.success("Escalated to CEO");
|
|
setEscalateDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to escalate to CEO");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleCeoReject = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.ceoReject.mutateAsync({ taskId: task.id, notes });
|
|
toast.success("Changes requested");
|
|
setRejectDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to request changes");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleApproveAndMerge = async () => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.approveAndMerge.mutateAsync(task.id);
|
|
toast.success("Task approved and PR merged");
|
|
setApproveAndMergeDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
if (axios.isAxiosError(err)) {
|
|
const detail =
|
|
(err.response?.data as { detail?: string } | undefined)?.detail ?? "";
|
|
if (typeof detail === "string" && detail.startsWith("NO_PR")) {
|
|
toast.error(
|
|
"No PR found for this task. Create a pull request before merging.",
|
|
);
|
|
} else if (
|
|
typeof detail === "string" &&
|
|
detail.startsWith("Merge failed")
|
|
) {
|
|
toast.error(
|
|
"Merge failed: " +
|
|
(detail.slice("Merge failed".length).replace(/^[: ]+/, "") ||
|
|
"the merge could not be completed"),
|
|
);
|
|
} else {
|
|
toast.error("Failed to approve and merge task");
|
|
}
|
|
} else {
|
|
toast.error("Failed to approve and merge task");
|
|
}
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleCeoApprove = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.ceoApprove.mutateAsync({ taskId: task.id, notes });
|
|
toast.success("Task approved and completed");
|
|
setApproveDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to approve task");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleCancel = async (reason: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.cancel.mutateAsync({ taskId: task.id, reason });
|
|
toast.success("Task cancelled");
|
|
setCancelDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to cancel task");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handlePassQa = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.passQa.mutateAsync({ taskId: task.id, qaNotes: notes });
|
|
toast.success("Task passed QA");
|
|
setPassQaDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to pass QA");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleFailQa = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.failQa.mutateAsync({ taskId: task.id, qaNotes: notes });
|
|
toast.success("Task failed QA");
|
|
setFailQaDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to fail QA");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleDocsComplete = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.docsComplete.mutateAsync({ taskId: task.id, notes });
|
|
toast.success("Documentation marked complete");
|
|
setDocsCompleteDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to mark documentation complete");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleSubmitPmReview = async (notes: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.submitPmReview.mutateAsync({ taskId: task.id, notes });
|
|
toast.success("Submitted for PM review");
|
|
setSubmitPmReviewDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to submit for PM review");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleComplete = async (justification: string) => {
|
|
if (!task) return;
|
|
try {
|
|
await lifecycle.complete.mutateAsync({ taskId: task.id, justification });
|
|
toast.success("Task completed");
|
|
setCompleteDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to complete task");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleCreateBranch = async (branchType: string) => {
|
|
if (!task || !project) return;
|
|
try {
|
|
await createBranch.mutateAsync({
|
|
project_slug: project.slug,
|
|
task_id: task.id,
|
|
branch_type: branchType as
|
|
| "feature"
|
|
| "bug"
|
|
| "chore"
|
|
| "docs"
|
|
| "hotfix",
|
|
agent_id: "ceo", // CEO is creating the branch from the panel
|
|
});
|
|
toast.success("Branch created successfully");
|
|
setBranchDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to create branch");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
const handleCreatePR = async (title: string, body: string) => {
|
|
if (!task || !project) return;
|
|
try {
|
|
const result = await createPR.mutateAsync({
|
|
project_slug: project.slug,
|
|
task_id: task.id,
|
|
title,
|
|
body,
|
|
agent_id: "ceo", // CEO is creating the PR from the panel
|
|
});
|
|
toast.success(`PR #${result.pr_number} created successfully`);
|
|
setPrDialogOpen(false);
|
|
refetch();
|
|
} catch (err) {
|
|
toast.error("Failed to create PR");
|
|
console.error(err);
|
|
}
|
|
};
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-10 w-10" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-4 gap-4">
|
|
{[...Array(8)].map((_, i) => (
|
|
<Skeleton key={i} className="h-24" />
|
|
))}
|
|
</div>
|
|
<Skeleton className="h-48" />
|
|
<Skeleton className="h-96" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Error state
|
|
if (error || !task) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<HelpTip label="Returns to the task list, preserving whatever filters/sort/page you had set there.">
|
|
<Link href="/tasks" prefetch={false}>
|
|
<Button variant="ghost" size="sm">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Tasks
|
|
</Button>
|
|
</Link>
|
|
</HelpTip>
|
|
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-center py-12">
|
|
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-destructive" />
|
|
<h2 className="text-xl font-semibold mb-2">Task Not Found</h2>
|
|
<p className="text-muted-foreground mb-6">
|
|
{error?.message ??
|
|
"The task you're looking for doesn't exist or has been deleted."}
|
|
</p>
|
|
<div className="flex justify-center gap-4">
|
|
<HelpTip label="Leaves this not-found error and returns to the full task list.">
|
|
<Link href="/tasks" prefetch={false}>
|
|
<Button>View All Tasks</Button>
|
|
</Link>
|
|
</HelpTip>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Breadcrumb (parent task) — renders nothing for a root task */}
|
|
<TaskBreadcrumb task={task} />
|
|
|
|
{/* Header — prev/next list navigation rides top-right, next to Actions,
|
|
so it never occupies its own row above the title. */}
|
|
<TaskHeader
|
|
task={task}
|
|
onAction={handleAction}
|
|
nav={<TaskListNav task={task} />}
|
|
/>
|
|
|
|
{/* CEO gate #1: Approve & Start a board-reviewed task. After the Board
|
|
finishes, the orchestrator sets board_review_complete and sends the
|
|
CEO an approval notification, but the task STAYS pending (its pending
|
|
state is what drives Main PM dispatch on approval) — so we gate on
|
|
PENDING here, NOT on awaiting_ceo_approval (the unrelated end-of-work
|
|
ceo-approve flow). Gate on exactly the orchestrator's own criterion:
|
|
pending + board_review_complete. The earlier team===BOARD / product-
|
|
scoped predicate was too narrow — it hid this button for project-
|
|
scoped intake tasks (which carry a project_id, team=the lead cell,
|
|
and no product_id), leaving the CEO with no way to approve them.
|
|
approve_and_start re-targets the task to the Main PM (team → main_pm)
|
|
without changing status, so exclude team===MAIN_PM to hide the button
|
|
once it's been approved. */}
|
|
{task.status === TaskStatus.PENDING &&
|
|
task.board_review_complete === true &&
|
|
task.team !== Team.MAIN_PM && (
|
|
<div className="flex justify-end gap-2">
|
|
{task.team === Team.BOARD && (
|
|
<HelpTip label="Re-opens the intake chat, threading in the board's review notes so the draft can be revised.">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => router.push(`/prompter?redraft=${task.id}`)}
|
|
>
|
|
Re-draft with board feedback
|
|
</Button>
|
|
</HelpTip>
|
|
)}
|
|
<ApproveAndStartButton task={task} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Metadata Cards */}
|
|
<TaskMetadata task={task} />
|
|
|
|
{/* Tabbed Content */}
|
|
<TaskTabs task={task} />
|
|
|
|
{/* Action Dialogs */}
|
|
<EscalateToCeoDialog
|
|
open={escalateDialogOpen}
|
|
onOpenChange={setEscalateDialogOpen}
|
|
onConfirm={handleEscalateToCeo}
|
|
isPending={lifecycle.escalateToCeo.isPending}
|
|
/>
|
|
|
|
<ApproveAndMergeDialog
|
|
open={approveAndMergeDialogOpen}
|
|
onOpenChange={setApproveAndMergeDialogOpen}
|
|
onConfirm={handleApproveAndMerge}
|
|
isPending={lifecycle.approveAndMerge.isPending}
|
|
/>
|
|
|
|
<CeoApproveDialog
|
|
open={approveDialogOpen}
|
|
onOpenChange={setApproveDialogOpen}
|
|
onConfirm={handleCeoApprove}
|
|
isPending={lifecycle.ceoApprove.isPending}
|
|
/>
|
|
|
|
<CeoRejectDialog
|
|
open={rejectDialogOpen}
|
|
onOpenChange={setRejectDialogOpen}
|
|
onConfirm={handleCeoReject}
|
|
isPending={lifecycle.ceoReject.isPending}
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={cancelDialogOpen}
|
|
onOpenChange={setCancelDialogOpen}
|
|
onConfirm={handleCancel}
|
|
isPending={lifecycle.cancel.isPending}
|
|
title="Cancel Task"
|
|
description="Record why this task is being cancelled. This note is the permanent audit record and is required."
|
|
label="Cancellation reason"
|
|
placeholder="Cancelling because..."
|
|
minChars={10}
|
|
confirmLabel="Cancel Task"
|
|
destructive
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={passQaDialogOpen}
|
|
onOpenChange={setPassQaDialogOpen}
|
|
onConfirm={handlePassQa}
|
|
isPending={lifecycle.passQa.isPending}
|
|
title="Pass QA"
|
|
description="Record the QA review outcome. This note is the permanent audit record and is required."
|
|
label="QA notes"
|
|
placeholder="Verified against acceptance criteria; passing because..."
|
|
minChars={20}
|
|
confirmLabel="Pass QA"
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={failQaDialogOpen}
|
|
onOpenChange={setFailQaDialogOpen}
|
|
onConfirm={handleFailQa}
|
|
isPending={lifecycle.failQa.isPending}
|
|
title="Fail QA"
|
|
description="Record what failed QA and what needs to change. This note is the permanent audit record and is required."
|
|
label="QA notes"
|
|
placeholder="Failing QA because..."
|
|
minChars={20}
|
|
confirmLabel="Fail QA"
|
|
destructive
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={docsCompleteDialogOpen}
|
|
onOpenChange={setDocsCompleteDialogOpen}
|
|
onConfirm={handleDocsComplete}
|
|
isPending={lifecycle.docsComplete.isPending}
|
|
title="Mark Docs Complete"
|
|
description="Record what documentation was written. This note is the permanent audit record and is required."
|
|
label="Documentation notes"
|
|
placeholder="Documented the following..."
|
|
minChars={20}
|
|
confirmLabel="Mark Docs Complete"
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={submitPmReviewDialogOpen}
|
|
onOpenChange={setSubmitPmReviewDialogOpen}
|
|
onConfirm={handleSubmitPmReview}
|
|
isPending={lifecycle.submitPmReview.isPending}
|
|
title="Submit for PM Review"
|
|
description="Record the summary for PM review. This note is the permanent audit record and is required."
|
|
label="Review notes"
|
|
placeholder="Submitting for PM review; summary..."
|
|
minChars={20}
|
|
confirmLabel="Submit for PM Review"
|
|
/>
|
|
|
|
<RequiredNotesDialog
|
|
open={completeDialogOpen}
|
|
onOpenChange={setCompleteDialogOpen}
|
|
onConfirm={handleComplete}
|
|
isPending={lifecycle.complete.isPending}
|
|
title="Approve & Complete"
|
|
description="Record why this work is approved and complete. This note is the permanent audit record and is required."
|
|
label="Completion justification"
|
|
placeholder="Approving and completing because..."
|
|
minChars={20}
|
|
confirmLabel="Approve & Complete"
|
|
/>
|
|
|
|
<CreateBranchDialog
|
|
open={branchDialogOpen}
|
|
onOpenChange={setBranchDialogOpen}
|
|
onConfirm={handleCreateBranch}
|
|
isPending={createBranch.isPending}
|
|
taskId={task.id}
|
|
/>
|
|
|
|
<CreatePRDialog
|
|
open={prDialogOpen}
|
|
onOpenChange={setPrDialogOpen}
|
|
onConfirm={handleCreatePR}
|
|
isPending={createPR.isPending}
|
|
defaultTitle={task.title}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|