"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 (
{[...Array(8)].map((_, i) => ( ))}
); } // Error state if (error || !task) { return (

Task Not Found

{error?.message ?? "The task you're looking for doesn't exist or has been deleted."}

); } return (
{/* Breadcrumb (parent task) — renders nothing for a root task */} {/* Header — prev/next list navigation rides top-right, next to Actions, so it never occupies its own row above the title. */} } /> {/* 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 && (
{task.team === Team.BOARD && ( )}
)} {/* Metadata Cards */} {/* Tabbed Content */} {/* Action Dialogs */}
); }