Files
kandev/apps/web/components/task/task-item.tsx
T
José Almeida 8e50fe90e9 feat(web): right-click context menu to move sidebar tasks between steps (#492)
* feat(web): add right-click context menu to move sidebar tasks between workflow steps

* fix: scope Move to submenu to task's own workflow steps

* fix: surgical rollback and disable actions during delete

* fix: wrap TaskItem in div for ContextMenuTrigger asChild ref forwarding

* refactor: unify right-click and 3-dot menus into single context menu

- Delete task-item-menu.tsx (DropdownMenu) — no longer needed
- TaskItem's 3-dot button now dispatches contextmenu event to trigger
  the wrapping ContextMenu
- TaskItemWithContextMenu tracks open state and passes menuOpen to
  TaskItem for hover visibility
- Add Duplicate item to the context menu
- Fix step color dots using block display

* fix: use Tailwind class for step color dots instead of inline style

* fix: disable Duplicate menu item until handler is implemented

* fix: defer move-to-step to let context menu close before optimistic update

* fix: defer move-to-step to let context menu close before optimistic update
2026-04-06 00:57:01 +01:00

298 lines
8.1 KiB
TypeScript

"use client";
import { memo } from "react";
import { IconDots, IconLoader2, IconSubtask } from "@tabler/icons-react";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { cn } from "@/lib/utils";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
import { RemoteCloudTooltip } from "./remote-cloud-tooltip";
type DiffStats = {
additions: number;
deletions: number;
};
type TaskItemProps = {
title: string;
description?: string;
stepName?: string;
state?: TaskState;
sessionState?: TaskSessionState;
isArchived?: boolean;
isSelected?: boolean;
onClick?: () => void;
diffStats?: DiffStats;
isRemoteExecutor?: boolean;
remoteExecutorType?: string;
remoteExecutorName?: string;
updatedAt?: string;
menuOpen?: boolean;
isDeleting?: boolean;
taskId?: string;
primarySessionId?: string | null;
parentTaskTitle?: string;
};
// Helper to format relative time
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSecs < 60) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function handleTaskItemKeyDown(e: React.KeyboardEvent<HTMLDivElement>, onClick?: () => void): void {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
onClick?.();
}
function TaskItemTitle({
title,
description,
taskId,
parentTaskTitle,
}: {
title: string;
description?: string;
taskId?: string;
parentTaskTitle?: string;
}) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1 line-clamp-1 min-w-0 text-[13px] font-medium text-foreground">
{title}
{taskId && <PRTaskIcon taskId={taskId} />}
</span>
{parentTaskTitle && (
<span className="text-[10px] text-muted-foreground/60 truncate flex items-center gap-0.5">
<IconSubtask className="h-2.5 w-2.5 shrink-0" />
{parentTaskTitle}
</span>
)}
{description && (
<span className="text-[11px] text-muted-foreground/60 truncate">{description}</span>
)}
</div>
);
}
function TaskItemMeta({
effectiveMenuOpen,
isRemoteExecutor,
taskId,
primarySessionId,
remoteExecutorName,
remoteExecutorType,
isArchived,
stepName,
isInProgress,
diffStats,
updatedAt,
}: {
effectiveMenuOpen: boolean;
isRemoteExecutor?: boolean;
taskId?: string;
primarySessionId?: string | null;
remoteExecutorName?: string;
remoteExecutorType?: string;
isArchived?: boolean;
stepName?: string;
isInProgress: boolean;
diffStats?: DiffStats;
updatedAt?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-end gap-0.5 transition-opacity duration-100",
effectiveMenuOpen ? "opacity-0" : "group-hover:opacity-0",
)}
>
<div className="flex items-center gap-1">
{isRemoteExecutor && (
<RemoteCloudTooltip
taskId={taskId ?? ""}
sessionId={primarySessionId ?? null}
fallbackName={remoteExecutorName ?? remoteExecutorType}
iconClassName="h-3.5 w-3.5 text-muted-foreground/80"
/>
)}
<TaskItemStepBadge isArchived={isArchived} stepName={stepName} />
</div>
<TaskItemRightMeta isInProgress={isInProgress} diffStats={diffStats} updatedAt={updatedAt} />
</div>
);
}
export const TaskItem = memo(function TaskItem({
title,
description,
stepName,
state,
sessionState,
isArchived,
isSelected = false,
onClick,
diffStats,
isRemoteExecutor,
remoteExecutorType,
remoteExecutorName,
updatedAt,
menuOpen = false,
isDeleting,
taskId,
primarySessionId,
parentTaskTitle,
}: TaskItemProps) {
const effectiveMenuOpen = menuOpen || isDeleting === true;
const isInProgress =
state === "IN_PROGRESS" ||
state === "SCHEDULING" ||
sessionState === "STARTING" ||
sessionState === "RUNNING";
const hasDiffStats = diffStats && (diffStats.additions > 0 || diffStats.deletions > 0);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => handleTaskItemKeyDown(e, onClick)}
className={cn(
"group relative flex w-full items-center gap-2 px-3 py-2 text-left text-sm outline-none cursor-pointer",
"transition-colors duration-75",
"hover:bg-foreground/[0.05]",
isSelected && "bg-primary/10",
)}
>
{/* Selection indicator */}
<div
className={cn(
"absolute left-0 top-0 bottom-0 w-[2px] transition-opacity",
isSelected ? "bg-primary opacity-100" : "opacity-0",
)}
/>
{/* Content */}
<TaskItemTitle
title={title}
description={description}
taskId={taskId}
parentTaskTitle={parentTaskTitle}
/>
{/* Right side: step name + meta, or 3-dot button on hover */}
<div className="relative flex items-center shrink-0">
<TaskItemMeta
effectiveMenuOpen={effectiveMenuOpen}
isRemoteExecutor={isRemoteExecutor}
taskId={taskId}
primarySessionId={primarySessionId}
remoteExecutorName={remoteExecutorName}
remoteExecutorType={remoteExecutorType}
isArchived={isArchived}
stepName={stepName}
isInProgress={isInProgress}
diffStats={hasDiffStats ? diffStats : undefined}
updatedAt={updatedAt}
/>
<TaskMenuButton visible={effectiveMenuOpen} />
</div>
</div>
);
});
/** 3-dot button that triggers the wrapping ContextMenu */
function TaskMenuButton({ visible }: { visible: boolean }) {
return (
<div
className={cn(
"absolute right-0 flex items-center gap-0.5 transition-opacity duration-100",
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
<button
type="button"
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md cursor-pointer",
"text-muted-foreground hover:text-foreground hover:bg-foreground/10",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring transition-colors",
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
e.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
clientX: e.clientX,
clientY: e.clientY,
}),
);
}}
aria-label="Task actions"
>
<IconDots className="h-4 w-4" />
</button>
</div>
);
}
/** Step badge or "Archived" badge */
function TaskItemStepBadge({ isArchived, stepName }: { isArchived?: boolean; stepName?: string }) {
if (isArchived) {
return (
<span className="text-[10px] text-muted-foreground/70 bg-amber-500/15 text-amber-500 px-1.5 py-px rounded-[6px]">
Archived
</span>
);
}
if (stepName) {
return (
<span className="text-[10px] text-muted-foreground/70 bg-foreground/[0.06] px-1.5 py-px rounded-[6px]">
{stepName}
</span>
);
}
return null;
}
/** Right-side meta: spinner, diff stats, or relative time */
function TaskItemRightMeta({
isInProgress,
diffStats,
updatedAt,
}: {
isInProgress: boolean;
diffStats?: DiffStats;
updatedAt?: string;
}) {
if (isInProgress) {
return <IconLoader2 className="h-3.5 w-3.5 text-blue-500 animate-spin" />;
}
if (diffStats) {
return (
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-500">+{diffStats.additions}</span>{" "}
<span className="text-rose-500">-{diffStats.deletions}</span>
</span>
);
}
if (updatedAt) {
return (
<span className="text-[11px] text-muted-foreground/60">{formatRelativeTime(updatedAt)}</span>
);
}
return null;
}