"use client"; import { useRef, useState, type ReactNode } from "react"; import { IconAdjustments, IconArrowRight, IconLogicBuffer } from "@tabler/icons-react"; import { ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubTrigger, } from "@kandev/ui/context-menu"; import { TaskContextMenuSubContent as ContextMenuSubContent } from "./task-context-menu-sub-content"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { useWorkflowMove } from "@/hooks/domains/kanban/use-workflow-move"; import { useToast } from "@/components/toast-provider"; import { WorkflowMoveDialog, WorkflowMoveOptions, WorkflowMoveOptionsForm, type WorkflowMoveOptionsSubmit, } from "./workflow-move-options"; import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api"; import type { WorkflowStepProgress } from "@/hooks/domains/kanban/use-workflow-step-progress"; import { StepProgressDetails } from "./workflow-step-progress-details"; export type TaskMoveStep = { id: string; title: string; task_id?: string | null; color?: string | null; workflow_id?: string | null; agent_profile_id?: string | null; events?: { on_enter?: Array<{ type: string; config?: Record }> }; }; export type TaskMoveWorkflow = { id: string; name: string; hidden?: boolean; }; export function useTaskMoveOptions({ taskId, workflowId, steps, closeMenu, }: { taskId: string; workflowId?: string | null; steps?: TaskMoveStep[]; closeMenu?: () => void; }) { const [moveOptionsStep, setMoveOptionsStep] = useState(null); const { move, isMoving } = useWorkflowMove(); const { toast } = useToast(); const { t } = useTranslation(); const openMoveOptionsStep = (targetStep: TaskMoveStep) => { setMoveOptionsStep({ ...targetStep, task_id: targetStep.task_id ?? taskId }); }; const openMoveOptions = (targetStepId: string) => { const targetStep = steps?.find((step) => step.id === targetStepId); if (!targetStep) return; openMoveOptionsStep({ ...targetStep, workflow_id: workflowId ?? undefined }); }; const runMove = async ( targetStepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => { if (!workflowId) return false; const result = await move(taskId, { workflow_id: workflowId, workflow_step_id: targetStepId, position: 0, entry_options: entryOptions, }); if (result.disposition === "failed") { const error = result.error; toast({ title: t("task:failedToMoveTask"), description: error instanceof Error ? error.message : t("task:failedToMoveTask"), variant: "error", }); return false; } closeMenu?.(); return true; }; const submitMoveOptions = async (entryOptions: WorkflowMoveEntryOptions | undefined) => { if (!moveOptionsStep) return false; const ok = await runMove(moveOptionsStep.id, entryOptions); if (ok) setMoveOptionsStep(null); return ok; }; // Move a specific step directly, used by the fine-pointer inline options form // rendered in the "Move to" submenu (no intermediate dialog/drawer surface). const submitMoveOptionsForStep = ( targetStepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => runMove(targetStepId, entryOptions); return { moveOptionsStep, isMoving, openMoveOptions, openMoveOptionsStep, submitMoveOptions, submitMoveOptionsForStep, closeMoveOptions: () => { setMoveOptionsStep(null); }, }; } export function TaskMoveOptionsSurface({ step, isMoving, onClose, onSubmit, }: { step: TaskMoveStep | null; isMoving: boolean; onClose: () => void; onSubmit: WorkflowMoveOptionsSubmit; }) { const usesTouchDrawer = useTouchDrawer(); if (!step) return null; const optionsProps = { open: true, onOpenChange: (open: boolean) => { if (!open) onClose(); }, targetStepName: step.title, isMoving, onSubmit, }; const Options = usesTouchDrawer ? WorkflowMoveOptions : WorkflowMoveDialog; return ; } type TaskMoveContextMenuItemsProps = { currentWorkflowId?: string | null; currentStepId?: string | null; workflows: TaskMoveWorkflow[]; stepsByWorkflowId: Record; disabled?: boolean; showSeparator?: boolean; onMoveToStep?: (stepId: string) => void; onMoveToStepWithOptions?: (stepId: string) => void; onSubmitWithOptions?: ( stepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => Promise; isMoving?: boolean; progressByStepId?: Readonly>; agentLabelsByProfileId?: Readonly>; onSendToWorkflow?: (workflowId: string, stepId: string) => void; }; export function stepHasAutoStart(step: TaskMoveStep) { return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false; } export function taskMoveOptions( currentWorkflowId: string | null | undefined, workflows: TaskMoveWorkflow[], stepsByWorkflowId: Record, ) { const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; const targets = currentWorkflowId ? workflows.filter((workflow) => !workflow.hidden && workflow.id !== currentWorkflowId) : []; return { currentSteps, targets, canMove: currentSteps.length > 1, canSend: targets.length > 0 }; } function StepMenuLabel({ step, isCurrent, testIdPrefix, }: { step: TaskMoveStep; isCurrent: boolean; testIdPrefix: string; }) { const { t } = useTranslation(); const hasAutoStart = stepHasAutoStart(step); return ( <> {step.title} {(isCurrent || hasAutoStart) && ( {isCurrent && ( {t("task:current2")} )} {hasAutoStart && ( {t("task:autoStart")} )} )} ); } function StepMenuItem({ step, currentStepId, onSelect, onSelectWithOptions, onSubmitWithOptions, isMoving, progress, agentLabelsByProfileId, testIdPrefix = "task-context-step", }: { step: TaskMoveStep; currentStepId?: string | null; onSelect: (stepId: string) => void; onSelectWithOptions?: (stepId: string) => void; onSubmitWithOptions?: ( stepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => Promise; isMoving?: boolean; progress?: WorkflowStepProgress; agentLabelsByProfileId?: Readonly>; testIdPrefix?: string; }) { const isCurrent = step.id === currentStepId; const content = ( ); if (!onSelectWithOptions && !onSubmitWithOptions) { return ( { event.preventDefault(); if (!isCurrent) onSelect(step.id); }} > {content} ); } return ( ); } function StepMenuSubItem({ step, isCurrent, onSelect, onSelectWithOptions, onSubmitWithOptions, isMoving, content, testIdPrefix, }: { step: TaskMoveStep; isCurrent: boolean; onSelect: (stepId: string) => void; onSelectWithOptions?: (stepId: string) => void; onSubmitWithOptions?: ( stepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => Promise; isMoving?: boolean; content: ReactNode; testIdPrefix: string; }) { const { t } = useTranslation(); const pointerTypeRef = useRef(null); return ( { pointerTypeRef.current = event.pointerType; }} onPointerCancel={() => { pointerTypeRef.current = null; }} onClick={(event) => { const pointerType = pointerTypeRef.current; pointerTypeRef.current = null; const rect = event.currentTarget.getBoundingClientRect(); const tappedChevron = (pointerType === "touch" || pointerType === "pen") && rect.width > 0 && event.clientX >= rect.right - 32; if (!tappedChevron) { event.preventDefault(); if (!isCurrent) onSelect(step.id); } }} onKeyDown={(event) => { if (!isCurrent && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); onSelect(step.id); } }} > {content} {onSubmitWithOptions ? ( // Interactive fields live inside a Radix menu popup. Stop key events at // this wrapper (bubble phase, after the field handled them) so the // menu's typeahead never yanks focus off the instructions field or the // agent-profile combobox search while the user is typing.
event.stopPropagation()}> onSubmitWithOptions(step.id, entryOptions)} />
) : ( { event.preventDefault(); onSelectWithOptions?.(step.id); }} > {t("task:moveWithOptions")} )}
); } function StepMenuContent({ step, isCurrent, progress, agentLabelsByProfileId, testIdPrefix, }: { step: TaskMoveStep; isCurrent: boolean; progress?: WorkflowStepProgress; agentLabelsByProfileId?: Readonly>; testIdPrefix: string; }) { return ( {progress && ( )} ); } function MoveToCurrentWorkflowSubmenu({ steps, currentStepId, disabled, onMoveToStep, onMoveToStepWithOptions, onSubmitWithOptions, isMoving, progressByStepId, agentLabelsByProfileId, }: { steps: TaskMoveStep[]; currentStepId?: string | null; disabled?: boolean; onMoveToStep?: (stepId: string) => void; onMoveToStepWithOptions?: (stepId: string) => void; onSubmitWithOptions?: ( stepId: string, entryOptions: WorkflowMoveEntryOptions | undefined, ) => Promise; isMoving?: boolean; progressByStepId?: Readonly>; agentLabelsByProfileId?: Readonly>; }) { const { t } = useTranslation(); if (!onMoveToStep || steps.length <= 1) return null; return ( {t("task:moveTo")} {steps.map((step) => ( ))} ); } function WorkflowTargetItem({ workflow, steps, disabled, onSendToWorkflow, }: { workflow: TaskMoveWorkflow; steps: TaskMoveStep[]; disabled?: boolean; onSendToWorkflow?: (workflowId: string, stepId: string) => void; }) { const { t } = useTranslation(); if (steps.length === 0 || !onSendToWorkflow) { return ( {workflow.name} {t("task:noSteps")} ); } return ( {workflow.name} {steps.map((step) => ( onSendToWorkflow(workflow.id, stepId)} /> ))} ); } function SendToWorkflowSubmenu({ workflows, stepsByWorkflowId, disabled, onSendToWorkflow, }: { workflows: TaskMoveWorkflow[]; stepsByWorkflowId: Record; disabled?: boolean; onSendToWorkflow?: (workflowId: string, stepId: string) => void; }) { const { t } = useTranslation(); if (!onSendToWorkflow || workflows.length === 0) return null; return ( {t("task:sendToWorkflow")} {workflows.map((workflow) => ( ))} ); } export function TaskMoveContextMenuItems({ currentWorkflowId, currentStepId, workflows, stepsByWorkflowId, disabled, showSeparator = true, onMoveToStep, onMoveToStepWithOptions, onSubmitWithOptions, isMoving, progressByStepId, agentLabelsByProfileId, onSendToWorkflow, }: TaskMoveContextMenuItemsProps) { const { currentSteps, targets, canMove, canSend } = taskMoveOptions( currentWorkflowId, workflows, stepsByWorkflowId, ); const hasSameWorkflowMove = Boolean( (onMoveToStep || onMoveToStepWithOptions || onSubmitWithOptions) && canMove, ); const hasCrossWorkflowMove = Boolean(onSendToWorkflow && canSend); if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null; return ( <> {showSeparator && } ); }