Files
kandev/apps/web/components/kanban-card.tsx
T

294 lines
8.7 KiB
TypeScript

"use client";
import { useState } from "react";
import { useDraggable } from "@dnd-kit/core";
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
import { KanbanCardShell } from "@/components/kanban-card-content";
import {
buildKanbanCardMenuEntries,
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
import { useAppStore } from "@/components/state-provider";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { getRepositoryDisplayName } from "@/lib/utils";
import { repositoryId as toRepositoryId, type Repository, type TaskState } from "@/lib/types/http";
export interface Task {
id: string;
title: string;
workflowStepId: string;
state?: TaskState;
description?: string;
position?: number;
repositoryId?: string;
/** All repositories linked to the task; used to render a "+N" chip for multi-repo. */
repositories?: Array<{ id: string; repository_id: string; position: number }>;
sessionCount?: number | null;
primarySessionId?: string | null;
/**
* Primary session's runtime state. Decoupled from `state` (the workflow
* column). Used to suppress the running-spinner when the agent has already
* finished — the workflow may leave the task in IN_PROGRESS for review.
*/
primarySessionState?: string | null;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primaryExecutorId?: string | null;
primaryExecutorType?: string | null;
primaryExecutorName?: string | null;
isRemoteExecutor?: boolean;
parentTaskId?: string | null;
updatedAt?: string;
createdAt?: string;
}
export interface WorkflowStep {
id: string;
title: string;
color: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}
interface KanbanCardProps {
task: Task;
/** Display names of every repository linked to the task, primary first. */
repositoryNames?: string[];
onClick?: (task: Task) => void;
onEdit?: (task: Task) => void;
onDelete?: (task: Task, opts?: { cascade?: boolean }) => void;
onArchive?: (task: Task, opts?: { cascade?: boolean }) => void;
onOpenFullPage?: (task: Task) => void;
onMove?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
showMaximizeButton?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
isSelected?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
}
function useKanbanCardMenus({
task,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
}: Pick<
KanbanCardProps,
| "task"
| "steps"
| "isDeleting"
| "isArchiving"
| "isSelected"
| "selectedIds"
| "onEdit"
| "onDelete"
| "onArchive"
| "onMove"
>) {
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const disabled = Boolean(isDeleting || isArchiving);
const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => {
void moveTasks(taskIds, workflowId, stepId).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
};
const moveToStepFromDropdown = (stepId: string) => {
if (onMove) {
onMove(task, stepId);
return;
}
if (moveTargets.currentWorkflowId) {
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId);
}
};
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
const moveSelectedToStep = (stepId: string) => {
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
onMove(task, stepId);
return;
}
if (!moveTargets.currentWorkflowId) return;
runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId);
};
const menuBase = {
currentWorkflowId: moveTargets.currentWorkflowId,
currentStepId: task.workflowStepId,
workflows: moveTargets.workflowItems,
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
disabled,
isDeleting,
isArchiving,
onEdit: onEdit ? () => onEdit(task) : undefined,
onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined,
onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined,
};
return {
dropdownMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveToStepFromDropdown,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks([task.id], workflowId, stepId);
},
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveSelectedToStep,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks(selectedTaskIds, workflowId, stepId);
},
}),
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
};
}
export function KanbanCard({
task,
repositoryNames,
onClick,
onEdit,
onDelete,
onArchive,
onOpenFullPage,
onMove,
steps,
showMaximizeButton = false,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onToggleSelect,
isMultiSelectMode,
}: KanbanCardProps) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id,
disabled: isMultiSelectMode,
});
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
const {
dropdownMenuEntries,
contextMenuEntries,
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
} = useKanbanCardMenus({
task,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
});
const handleClick = () => {
if (isMultiSelectMode) {
onToggleSelect?.(task.id);
return;
}
onClick?.(task);
};
const handleCheckboxClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggleSelect?.(task.id);
};
return (
<>
<KanbanCardContextMenu entries={contextMenuEntries}>
<KanbanCardShell
task={task}
repositoryNames={repositoryNames}
attributes={attributes}
listeners={listeners}
setNodeRef={setNodeRef}
transform={transform}
isDragging={isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={dropdownMenuEntries}
onClick={handleClick}
onCheckboxClick={handleCheckboxClick}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isDeleting={isDeleting}
onConfirm={({ cascade }) => onDelete?.(task, { cascade })}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isArchiving={isArchiving}
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
/>
</>
);
}
/**
* Resolves a task's linked repositories to display names. Primary first
* (`task.repositoryId`), then any others ordered by `task.repositories[].position`.
* Skips unresolved IDs (repo deleted / not yet hydrated).
*/
export function resolveTaskRepositoryNames(task: Task, repositories: Repository[]): string[] {
const byId = new Map(repositories.map((repo) => [repo.id, repo]));
const seen = new Set<string>();
const names: string[] = [];
const push = (id: string | undefined) => {
if (!id || seen.has(id)) return;
const repo = byId.get(toRepositoryId(id));
if (!repo) return;
seen.add(id);
const display = getRepositoryDisplayName(repo.local_path);
if (display) names.push(display);
};
push(task.repositoryId);
const ordered = [...(task.repositories ?? [])].sort((a, b) => a.position - b.position);
for (const link of ordered) push(link.repository_id);
return names;
}