Files
roboco/panel/src/components/tasks/task-detail/task-header.tsx
T
Renn F c3ee5f09ba fix(panel): copyable task-id chip + stable, non-shifting task header
The task header rendered 'Task #<uuid>: <title>' as one click-to-edit <h1>,
so the UUID could not be selected/copied (clicking it entered title-edit) and
the editable field silently dropped the id. Worse, the title + status + team +
type all shared one flex-wrap row with auto-width dropdowns, so a long title or
a wider selected label shoved the controls — and the Actions button — to new
positions on every render.

Restructure for stability:
- Title is its own row, editable (no UUID), and truncates on overflow — it can
  never push the controls or Actions.
- A read-only #<short-id> chip with a copy button (reuses CopyButton, which has
  the LAN/http clipboard fallback) copies the FULL uuid.
- Status and team dropdowns are fixed-width (w-40 / w-36), so changing the
  selected value's label width can't shift a neighbor.
- Actions is pinned top-right (shrink-0) and never moves regardless of title
  length or dropdown contents.

panel typecheck + eslint clean.
2026-06-21 06:38:13 +02:00

659 lines
22 KiB
TypeScript

"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Task, TaskStatus, Team } from "@/types";
import {
useDeleteTask,
useUpdateTask,
useTaskValidTransitions,
} from "@/hooks/use-tasks";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
ArrowLeft,
MoreVertical,
Play,
Pause,
CheckCircle,
XCircle,
AlertTriangle,
Trash2,
GitBranch,
GitMerge,
GitPullRequest,
FileCheck,
Send,
ThumbsUp,
ThumbsDown,
} from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { TaskTypeBadge } from "../task-type-badge";
import { CopyButton } from "@/components/ui/copy-button";
// Status badge colors
const statusColors: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]:
"bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400",
[TaskStatus.PENDING]:
"bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
[TaskStatus.CLAIMED]:
"bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300",
[TaskStatus.IN_PROGRESS]:
"bg-blue-200 text-blue-800 dark:bg-blue-800 dark:text-blue-200",
[TaskStatus.BLOCKED]:
"bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",
[TaskStatus.PAUSED]:
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300",
[TaskStatus.VERIFYING]:
"bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300",
[TaskStatus.NEEDS_REVISION]:
"bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300",
[TaskStatus.AWAITING_QA]:
"bg-yellow-200 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-200",
[TaskStatus.AWAITING_DOCUMENTATION]:
"bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300",
[TaskStatus.AWAITING_PR_REVIEW]:
"bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300",
[TaskStatus.AWAITING_PM_REVIEW]:
"bg-orange-200 text-orange-800 dark:bg-orange-800 dark:text-orange-200",
[TaskStatus.AWAITING_CEO_APPROVAL]:
"bg-amber-200 text-amber-800 dark:bg-amber-800 dark:text-amber-200",
[TaskStatus.COMPLETED]:
"bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300",
[TaskStatus.CANCELLED]:
"bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
};
const statusLabels: Record<TaskStatus, string> = {
[TaskStatus.BACKLOG]: "Backlog",
[TaskStatus.PENDING]: "Pending",
[TaskStatus.CLAIMED]: "Claimed",
[TaskStatus.IN_PROGRESS]: "In Progress",
[TaskStatus.BLOCKED]: "Blocked",
[TaskStatus.PAUSED]: "Paused",
[TaskStatus.VERIFYING]: "Verifying",
[TaskStatus.NEEDS_REVISION]: "Needs Revision",
[TaskStatus.AWAITING_QA]: "Awaiting QA",
[TaskStatus.AWAITING_DOCUMENTATION]: "Awaiting Docs",
[TaskStatus.AWAITING_PR_REVIEW]: "PR Review",
[TaskStatus.AWAITING_PM_REVIEW]: "PM Review",
[TaskStatus.AWAITING_CEO_APPROVAL]: "CEO Approval",
[TaskStatus.COMPLETED]: "Completed",
[TaskStatus.CANCELLED]: "Cancelled",
};
interface TaskHeaderProps {
task: Task;
onAction?: (action: string) => void;
}
export function TaskHeader({ task, onAction }: TaskHeaderProps) {
const router = useRouter();
const deleteTask = useDeleteTask();
const updateTask = useUpdateTask();
// Fetch valid next statuses from GET /tasks/{id}/valid-transitions.
// Falls back to [] while loading or on error — the Select is disabled during loading.
const { data: validTransitionsData, isLoading: isTransitionsLoading } =
useTaskValidTransitions(task.id, task.status);
// Exclude the current status: it is always rendered first (below), so a stale
// cache or a backend list that re-includes it would duplicate the item. Radix
// Select requires unique item values, so a duplicate also garbles the trigger
// label (it renders as e.g. "Completed Completed").
const nextStatuses: TaskStatus[] = (validTransitionsData ?? []).filter(
(s) => s !== task.status,
);
// God-mode: the panel always acts as the CEO/operator, so the dropdown also
// offers every OTHER status as a forced admin override — letting the CEO
// recover a task wedged in a state with no valid in-band move (e.g. a task
// stuck in `blocked` whose PR can never merge, or reopening a `cancelled`
// task). These route through the audited admin-override path, not lifecycle
// verbs. See handleStatusChange.
const overrideStatuses: TaskStatus[] = Object.values(TaskStatus).filter(
(s) => s !== task.status && !nextStatuses.includes(s),
);
const [deleteOpen, setDeleteOpen] = useState(false);
// Inline editing states
const [editingTitle, setEditingTitle] = useState(false);
const [localTitleValue, setLocalTitleValue] = useState("");
const titleInputRef = useRef<HTMLInputElement>(null);
// Display prop value when not editing, local value when editing
const titleValue = editingTitle ? localTitleValue : task.title;
const setTitleValue = (value: string) => setLocalTitleValue(value);
// Start editing - copy current prop value to local state
const startEditingTitle = () => {
setLocalTitleValue(task.title);
setEditingTitle(true);
};
// Focus input when editing starts
useEffect(() => {
if (editingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [editingTitle]);
const handleAction = (action: string) => {
onAction?.(action);
};
const handleDelete = async () => {
try {
await deleteTask.mutateAsync(task.id);
toast.success("Task deleted");
setDeleteOpen(false);
router.push("/tasks");
} catch {
toast.error("Failed to delete task");
}
};
const handleTitleSave = async () => {
if (!titleValue.trim() || titleValue === task.title) {
setTitleValue(task.title);
setEditingTitle(false);
return;
}
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { title: titleValue.trim() },
});
setEditingTitle(false);
} catch {
toast.error("Failed to update title");
setTitleValue(task.title);
}
};
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleTitleSave();
} else if (e.key === "Escape") {
setTitleValue(task.title);
setEditingTitle(false);
}
};
const handleStatusChange = async (newStatus: TaskStatus) => {
// Skip if same status
if (newStatus === task.status) return;
// Map a target status to the lifecycle action that achieves it in-band.
const statusToAction: Partial<Record<TaskStatus, string>> = {
[TaskStatus.PENDING]: "reopen", // From cancelled
[TaskStatus.CLAIMED]: "claim",
[TaskStatus.IN_PROGRESS]: "start",
[TaskStatus.BLOCKED]: "block",
[TaskStatus.PAUSED]: "pause",
[TaskStatus.VERIFYING]: "verify",
[TaskStatus.AWAITING_QA]: "submit-qa",
[TaskStatus.AWAITING_DOCUMENTATION]: "pass-qa",
[TaskStatus.COMPLETED]: "complete",
[TaskStatus.CANCELLED]: "cancel",
};
const action = statusToAction[newStatus];
// Prefer the real lifecycle action when this is a valid in-band transition —
// it runs the proper side effects (e.g. `complete` merges the PR).
if (action && nextStatuses.includes(newStatus) && onAction) {
onAction(action);
return;
}
// God-mode override: force ANY status, even from a state with no valid
// in-band move (a task wedged in `blocked` whose PR can never merge, or
// reopening a `cancelled` task). Audited via PATCH /tasks/{id} {status} ->
// admin_set_status. No PR merge / lifecycle side effects fire — this is a
// pure, operator-driven state correction the CEO is entitled to make.
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { status: newStatus },
});
toast.success(`Status forced to ${statusLabels[newStatus]}`);
} catch {
toast.error(`Failed to set status to ${statusLabels[newStatus]}`);
}
};
const handleTeamChange = async (newTeam: Team) => {
if (newTeam === task.team) return;
try {
await updateTask.mutateAsync({
taskId: task.id,
updates: { team: newTeam },
});
toast.success("Team updated");
} catch {
toast.error("Failed to update team");
}
};
// Determine available lifecycle actions based on current status
const getAvailableActions = () => {
const actions: Array<{
label: string;
action: string;
icon?: React.ReactNode;
}> = [];
switch (task.status) {
case TaskStatus.PENDING:
actions.push({
label: "Claim Task",
action: "claim",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.CLAIMED:
actions.push({
label: "Start Work",
action: "start",
icon: <Play className="h-4 w-4 mr-2" />,
});
// PM can create branch for tasks without branch (all tasks follow git workflow)
if (!task.branch_name) {
actions.push({
label: "Create Branch",
action: "create-branch",
icon: <GitBranch className="h-4 w-4 mr-2" />,
});
}
break;
case TaskStatus.IN_PROGRESS:
actions.push({
label: "Pause",
action: "pause",
icon: <Pause className="h-4 w-4 mr-2" />,
});
actions.push({
label: "Mark Blocked",
action: "block",
icon: <AlertTriangle className="h-4 w-4 mr-2" />,
});
// Create PR action for tasks with branch but no PR
if (task.branch_name && !task.pr_number) {
actions.push({
label: "Create PR",
action: "create-pr",
icon: <GitPullRequest className="h-4 w-4 mr-2" />,
});
}
actions.push({
label: "Self Verify",
action: "verify",
icon: <CheckCircle className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.BLOCKED:
actions.push({
label: "Unblock",
action: "unblock",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.PAUSED:
actions.push({
label: "Resume",
action: "resume",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.VERIFYING:
actions.push({
label: "Submit for QA",
action: "submit-qa",
icon: <CheckCircle className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.AWAITING_QA:
actions.push({
label: "Pass QA",
action: "pass-qa",
icon: <CheckCircle className="h-4 w-4 mr-2" />,
});
actions.push({
label: "Fail QA",
action: "fail-qa",
icon: <XCircle className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.AWAITING_DOCUMENTATION:
// Parallel phase - show status and available actions
if (!task.docs_complete) {
actions.push({
label: "Mark Docs Complete",
action: "docs-complete",
icon: <FileCheck className="h-4 w-4 mr-2" />,
});
}
// Only show submit for PM review when both docs and PR are ready
if (task.docs_complete && task.pr_created) {
actions.push({
label: "Submit for PM Review",
action: "submit-pm-review",
icon: <Send className="h-4 w-4 mr-2" />,
});
}
break;
case TaskStatus.AWAITING_PM_REVIEW:
actions.push({
label: "Approve & Complete",
action: "complete",
icon: <ThumbsUp className="h-4 w-4 mr-2" />,
});
actions.push({
label: "Escalate to CEO",
action: "escalate-to-ceo",
icon: <Send className="h-4 w-4 mr-2" />,
});
actions.push({
label: "Request Changes",
action: "request-changes",
icon: <ThumbsDown className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.AWAITING_CEO_APPROVAL:
actions.push({
label: "Approve & Merge",
action: "approve-and-merge",
icon: <ThumbsUp className="h-4 w-4 mr-2" />,
});
actions.push({
label: "Request Changes",
action: "ceo-reject",
icon: <ThumbsDown className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.CANCELLED:
actions.push({
label: "Reopen Task",
action: "reopen",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.BACKLOG:
actions.push({
label: "Activate Task",
action: "activate",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
case TaskStatus.NEEDS_REVISION:
actions.push({
label: "Start Revision",
action: "start-revision",
icon: <Play className="h-4 w-4 mr-2" />,
});
break;
}
// Cancel is always available for non-terminal states
if (
task.status !== TaskStatus.COMPLETED &&
task.status !== TaskStatus.CANCELLED
) {
actions.push({
label: "Cancel Task",
action: "cancel",
icon: <XCircle className="h-4 w-4 mr-2" />,
});
}
// Merge PR is available whenever task.pr_number is set and the task is not in a terminal state
if (
task.pr_number &&
task.status !== TaskStatus.COMPLETED &&
task.status !== TaskStatus.CANCELLED
) {
actions.push({
label: "Merge PR",
action: "merge-pr",
icon: <GitMerge className="h-4 w-4 mr-2" />,
});
}
return actions;
};
const actions = getAvailableActions();
return (
<div className="border-b pb-4">
<div className="flex items-start justify-between gap-4">
{/* Left: back arrow + title + metadata. This column SHRINKS and the
title truncates, so a long title never pushes the controls or the
Actions menu out of place. */}
<div className="flex items-start gap-3 min-w-0 flex-1">
<Link href="/tasks">
<Button variant="ghost" size="icon" className="shrink-0">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div className="min-w-0 flex-1">
{/* Row 1: title only — editable, no UUID. Truncates on overflow. */}
{editingTitle ? (
<Input
ref={titleInputRef}
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onKeyDown={handleTitleKeyDown}
onBlur={handleTitleSave}
className="text-2xl font-bold h-auto py-1 px-2 w-full"
disabled={updateTask.isPending}
/>
) : (
<h1
className="text-2xl font-bold cursor-pointer hover:bg-muted/50 px-2 py-1 -mx-2 rounded transition-colors truncate"
onClick={startEditingTitle}
title={task.title}
>
{task.title}
</h1>
)}
{/* Row 2: copyable task id + status + team + type. The dropdowns are
FIXED width so changing a selected value's label width can never
shift a neighbor; the id is read-only and copies the FULL uuid. */}
<div className="flex items-center gap-2 mt-1.5">
<span
className="inline-flex shrink-0 items-center gap-1 rounded-md border bg-muted/40 px-2 py-0.5 font-mono text-xs text-muted-foreground"
title={task.id}
>
#{task.id.slice(0, 8)}
<CopyButton value={task.id} className="-mr-1 px-1 py-0" />
</span>
<span className="text-muted-foreground shrink-0">|</span>
{/* Status Dropdown — only current status + valid next statuses from backend */}
<Select
value={task.status}
onValueChange={(v) => handleStatusChange(v as TaskStatus)}
>
<SelectTrigger
className={`w-40 shrink-0 h-7 text-xs font-medium border-0 ${statusColors[task.status]}`}
disabled={isTransitionsLoading}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{/* Always render the current status first so the trigger value is always present */}
<SelectItem key={task.status} value={task.status}>
<span
className={`px-2 py-0.5 rounded ${statusColors[task.status]}`}
>
{statusLabels[task.status]}
</span>
</SelectItem>
{/* nextStatuses sourced exclusively from useTaskValidTransitions
(GET /tasks/{id}/valid-transitions) — no local fallback array */}
{nextStatuses.map((status) => (
<SelectItem key={status} value={status}>
<span
className={`px-2 py-0.5 rounded ${statusColors[status]}`}
>
{statusLabels[status]}
</span>
</SelectItem>
))}
{/* God-mode: every remaining status as an audited admin
override (no valid in-band transition). Marked "force" so
the operator knows it bypasses the normal lifecycle. */}
{overrideStatuses.map((status) => (
<SelectItem key={status} value={status}>
<span
className={`px-2 py-0.5 rounded ${statusColors[status]}`}
>
{statusLabels[status]}
</span>
<span className="ml-1 text-[10px] uppercase tracking-wide text-muted-foreground">
force
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Team Dropdown */}
<span className="text-muted-foreground shrink-0">|</span>
<Select
value={task.team}
onValueChange={(v) => handleTeamChange(v as Team)}
>
<SelectTrigger className="w-36 shrink-0 h-7 text-sm text-muted-foreground border-0 bg-transparent hover:bg-muted/50 px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Team).map((team) => (
<SelectItem key={team} value={team}>
{team.replace(/_/g, " ")} Team
</SelectItem>
))}
</SelectContent>
</Select>
{/* Task Type Badge */}
{task.task_type && (
<>
<span className="text-muted-foreground shrink-0">|</span>
<span className="shrink-0">
<TaskTypeBadge type={task.task_type} />
</span>
</>
)}
</div>
</div>
</div>
{/* Actions — pinned top-right; never moves regardless of title length
or a dropdown's selected-label width. */}
<div className="shrink-0">
{actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Actions
<MoreVertical className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Lifecycle actions (non-cancel) */}
{actions
.filter((a) => a.action !== "cancel")
.map((action) => (
<DropdownMenuItem
key={action.action}
onClick={() => handleAction(action.action)}
>
{action.icon}
{action.label}
</DropdownMenuItem>
))}
{/* Cancel action */}
{actions.some((a) => a.action === "cancel") && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleAction("cancel")}
className="text-orange-600"
>
<XCircle className="h-4 w-4 mr-2" />
Cancel Task
</DropdownMenuItem>
</>
)}
{/* Delete option */}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
className="text-red-600"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete Task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Delete Confirmation */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Task?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{task.title}&quot;. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
{deleteTask.isPending ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}