8892920514
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import {
|
|
IconCheck,
|
|
IconCircleDashed,
|
|
IconChevronLeft,
|
|
IconChevronRight,
|
|
IconHelpCircle,
|
|
} from "@tabler/icons-react";
|
|
import { cn } from "@kandev/ui/lib/utils";
|
|
import { getTaskStateIcon } from "@/lib/ui/state-icons";
|
|
import type { Task } from "@/components/kanban-card";
|
|
import type { WorkflowStep } from "@/components/kanban-column";
|
|
import { useTaskPendingInput } from "@/hooks/use-task-pending-input";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
type StepPhase = "past" | "current" | "future";
|
|
|
|
function isRunningState(state?: string): boolean {
|
|
return state === "IN_PROGRESS" || state === "SCHEDULING";
|
|
}
|
|
|
|
export type Graph2StepNodeProps = {
|
|
step: WorkflowStep;
|
|
phase: StepPhase;
|
|
task: Task;
|
|
hasPrev: boolean;
|
|
hasNext: boolean;
|
|
onMoveTask: (task: Task, targetStepId: string) => void;
|
|
onOpenTask?: (task: Task) => void;
|
|
prevStepId?: string;
|
|
nextStepId?: string;
|
|
prevStepTitle?: string;
|
|
nextStepTitle?: string;
|
|
prevStepHidden?: boolean;
|
|
nextStepHidden?: boolean;
|
|
isMoving?: boolean;
|
|
};
|
|
|
|
const NODE_CLASS =
|
|
"w-[130px] h-[36px] rounded-lg shrink-0 px-2.5 flex flex-col items-start justify-center";
|
|
|
|
/**
|
|
* A completed step: every step keeps its own labelled pill, matching the
|
|
* Kanban card's step run. Muted rather than green — the run's only color
|
|
* signal is the current step's accent border and the row's amber
|
|
* needs-attention edge; a third hue on every past step competed with both.
|
|
*/
|
|
function PastNode({ step }: { step: WorkflowStep }) {
|
|
return (
|
|
<div
|
|
data-testid="graph2-step-node-past"
|
|
title={step.title}
|
|
className={cn(NODE_CLASS, "border border-muted-foreground/20 bg-muted/30")}
|
|
>
|
|
<div className="flex items-center gap-1.5 w-full">
|
|
<IconCheck className="h-3 w-3 text-muted-foreground/50 shrink-0" />
|
|
<span className="text-[11px] text-muted-foreground truncate">{step.title}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A not-yet-reached step: dashed to distinguish it from a completed one. */
|
|
function FutureNode({ step }: { step: WorkflowStep }) {
|
|
return (
|
|
<div
|
|
data-testid="graph2-step-node-future"
|
|
title={step.title}
|
|
className={cn(NODE_CLASS, "border border-dashed border-muted-foreground/20 bg-muted/10")}
|
|
>
|
|
<div className="flex items-center gap-1.5 w-full">
|
|
<IconCircleDashed className="h-3 w-3 text-muted-foreground/40 shrink-0" />
|
|
<span className="text-[11px] text-muted-foreground/40 truncate">{step.title}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The synthetic labelled marker rendered when a task has no resolvable
|
|
* current step: `workflowStepId` is empty, so it matches no displayed step.
|
|
* It carries fixed unassigned-step copy rather than a step title, and no move
|
|
* controls, there being no current step for either direction.
|
|
*/
|
|
export function Graph2UnassignedStepMarker() {
|
|
const { t } = useTranslation();
|
|
const label = t("kanban:pipelineUnassignedStep");
|
|
return (
|
|
<div
|
|
data-testid="graph2-step-node-unassigned"
|
|
title={label}
|
|
className={cn(NODE_CLASS, "border border-dashed border-muted-foreground/40 bg-muted/20")}
|
|
>
|
|
<div className="flex items-center gap-1.5 w-full">
|
|
<IconHelpCircle className="h-3 w-3 text-muted-foreground/60 shrink-0" />
|
|
<span className="text-[11px] font-medium text-muted-foreground truncate">{label}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MoveButton({
|
|
direction,
|
|
isMoving,
|
|
label,
|
|
showTooltip = true,
|
|
onClick,
|
|
}: {
|
|
direction: "left" | "right";
|
|
isMoving?: boolean;
|
|
label: string;
|
|
showTooltip?: boolean;
|
|
onClick: (e: React.MouseEvent) => void;
|
|
}) {
|
|
const posClass = direction === "left" ? "-left-3" : "-right-3";
|
|
const Icon = direction === "left" ? IconChevronLeft : IconChevronRight;
|
|
const button = (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
disabled={isMoving}
|
|
onClick={onClick}
|
|
onContextMenu={(e) => e.stopPropagation()}
|
|
className={cn(
|
|
`absolute ${posClass} top-1/2 -translate-y-1/2 z-10`,
|
|
"h-5 w-5 rounded-full bg-background border border-border shadow-sm",
|
|
"flex items-center justify-center",
|
|
"hover:bg-accent transition-colors cursor-pointer",
|
|
isMoving && "opacity-50 cursor-not-allowed",
|
|
)}
|
|
>
|
|
<Icon className="h-3 w-3" />
|
|
</button>
|
|
);
|
|
// The tooltip names the destination step. Keep the trigger wrapper for the
|
|
// default card/pipeline behavior, while allowing visible destinations to
|
|
// opt out when the caller already renders that name in the run.
|
|
if (!showTooltip) return button;
|
|
return (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span tabIndex={isMoving ? 0 : -1} className="inline-flex">
|
|
{button}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{label}</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function Graph2StepNode({
|
|
step,
|
|
phase,
|
|
task,
|
|
hasPrev,
|
|
hasNext,
|
|
onMoveTask,
|
|
onOpenTask,
|
|
prevStepId,
|
|
nextStepId,
|
|
prevStepTitle,
|
|
nextStepTitle,
|
|
prevStepHidden,
|
|
nextStepHidden,
|
|
isMoving,
|
|
}: Graph2StepNodeProps) {
|
|
const { t } = useTranslation();
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const pendingInput = useTaskPendingInput(task.primarySessionId, {
|
|
taskId: task.id,
|
|
taskPendingAction: task.taskPendingAction,
|
|
statusSummary: task.statusSummary,
|
|
primarySessionState: task.primarySessionState,
|
|
primarySessionPendingAction: task.primarySessionPendingAction,
|
|
});
|
|
|
|
if (phase === "past") return <PastNode step={step} />;
|
|
if (phase === "future") return <FutureNode step={step} />;
|
|
|
|
// Current phase
|
|
const running = isRunningState(task.state);
|
|
|
|
const showMoveControls = isHovered || isFocused;
|
|
|
|
return (
|
|
<div
|
|
className="relative shrink-0"
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onFocusCapture={() => setIsFocused(true)}
|
|
onBlurCapture={(event) => {
|
|
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
setIsFocused(false);
|
|
}
|
|
}}
|
|
>
|
|
{showMoveControls && hasPrev && prevStepId && (
|
|
<MoveButton
|
|
direction="left"
|
|
isMoving={isMoving}
|
|
label={t("kanban:moveToStep", { step: prevStepTitle ?? prevStepId })}
|
|
showTooltip={prevStepHidden}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMoveTask(task, prevStepId);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* onOpenTask is not yet threaded through PipelineStepNodes, so this
|
|
onClick is inert today; an unhandled right-click still bubbles to
|
|
the row's own context menu, same as the title. */}
|
|
<button
|
|
type="button"
|
|
title={step.title}
|
|
onClick={() => onOpenTask?.(task)}
|
|
className={cn(
|
|
NODE_CLASS,
|
|
"cursor-pointer transition-colors bg-background hover:bg-accent/30",
|
|
running ? "border-1 border-accent/50 node-border-running" : "border-1 border-accent/50",
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-1.5 w-full">
|
|
<div className="shrink-0">
|
|
{getTaskStateIcon(task.state, "h-3 w-3", {
|
|
hasPendingClarification: pendingInput.clarification,
|
|
foregroundActivity: task.foregroundActivity,
|
|
hasPendingPermission: pendingInput.permission,
|
|
interrupted: task.interrupted,
|
|
autoStartFailed: task.autoStartFailed,
|
|
workspaceOrphaned: task.workspaceOrphaned,
|
|
})}
|
|
</div>
|
|
<span className="text-[11px] font-medium text-foreground truncate">{step.title}</span>
|
|
</div>
|
|
</button>
|
|
|
|
{showMoveControls && hasNext && nextStepId && (
|
|
<MoveButton
|
|
direction="right"
|
|
isMoving={isMoving}
|
|
label={t("kanban:moveToStep", { step: nextStepTitle ?? nextStepId })}
|
|
showTooltip={nextStepHidden}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onMoveTask(task, nextStepId);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|