Files
kandev/apps/web/components/kanban-card.tsx
T
2026-09-12 14:58:49 +02:00

582 lines
19 KiB
TypeScript

"use client";
import { useRef } from "react";
import { useDraggable } from "@dnd-kit/core";
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
import { KanbanCardShell } from "@/components/kanban-card-content";
import { KanbanCardDialogs } from "@/components/kanban-card-dialogs";
import { useActiveWorkspaceRepositories } from "@/components/kanban-card-repositories";
export { resolveTaskRepositoryChips } from "@/components/kanban-card-repositories";
import {
buildKanbanCardMenuEntries,
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
import { useAppStore } from "@/components/state-provider";
import { TaskArchiveConfirmation } from "@/components/task/task-archive-confirmation";
import { TaskDetachConfirmationSurface } from "@/components/task/task-detach-confirm-dialog";
import { type ExternalLinkProvider } from "@/components/task/task-external-link-dialog";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import type { TaskDependencyRef } from "@/lib/state/slices/kanban/types";
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select";
import type { TaskActionOptions } from "@/hooks/use-task-actions";
import { useDetachTask } from "@/hooks/use-detach-task";
import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority";
import { useTaskMenuDialogState } from "@/hooks/use-task-menu-dialog-state";
import {
type ForegroundActivity,
type TaskPendingAction,
type TaskPriority,
type TaskState,
} from "@/lib/types/http";
import type { PluginTaskMenuContext } from "@/lib/plugins/types";
import { usePluginRegistry } from "@/lib/plugins/registry";
export interface Task {
id: string;
title: string;
workflowStepId: string;
state?: TaskState;
priority?: TaskPriority;
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;
base_branch?: string;
checkout_branch?: string;
branch_policy_id?: string;
branch_policy_name?: string;
branch_policy_base_branch?: string;
branch_policy_branch_template?: string;
branch_policy_pull_request_target?: 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;
primarySessionPendingAction?: TaskPendingAction | null;
taskPendingAction?: TaskPendingAction | null;
/**
* Task-level MOST-ACTIVE-WINS activity aggregate;
* undefined/null when no session is running. Drives the background-running
* affordance on the card status icon.
*/
foregroundActivity?: ForegroundActivity | null;
/** True when the task's session was mid-turn when the backend died. */
interrupted?: boolean;
/** True when a workflow step's auto_start_agent on_enter action failed to
* launch a run for this task. */
autoStartFailed?: boolean;
/**
* True when the task is waiting on the operator to notice, not on the
* operator to act — a settled session with a positively-sampled
* background process still live (spec:
* docs/specs/disambiguate-waiting/spec.md). Outranked by pending-input
* and any live foregroundActivity. The revision/epoch fields backing the
* stale-update discard rule live on the store's KanbanState Task shape
* (kanban/types.ts) and the wire payload, not here — this board-rendering
* type only needs the resolved boolean.
*/
parkedOnBackgroundWork?: boolean;
/** True when this task inherits an archived parent's workspace and can no
* longer materialize or start. */
workspaceOrphaned?: boolean;
/** Live subagents summed across this task's sessions; drives the count chip. */
activeSubagentCount?: number;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
primaryExecutorId?: string | null;
primaryExecutorType?: string | null;
primaryExecutorName?: string | null;
isRemoteExecutor?: boolean;
/** Human assignee (user id); the card renders their name read-only. */
assigneeUserId?: string;
parentTaskId?: string | null;
workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group";
updatedAt?: string;
createdAt?: string;
wipAdmitted?: boolean;
queuedForStepId?: string;
queuedForStepTitle?: string;
/** Derived dependency state — see TaskDependencyRef in the kanban slice. */
blocked?: boolean;
blockedReason?: string;
dependsOn?: TaskDependencyRef[];
blocks?: TaskDependencyRef[];
startWhenUnblocked?: boolean;
queuedAt?: string;
issueUrl?: string;
issueNumber?: number;
statusSummary?: TaskStatusSummary | null;
}
export type RepositoryChip = {
label: string;
path?: 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> }>;
};
}
export type KanbanPresentation = PluginTaskMenuContext["presentation"];
export interface KanbanCardProps {
task: Task;
workspaceId: string | null;
presentation?: KanbanPresentation;
externalLinkAvailability: KanbanExternalLinkAvailability;
/** Display labels and hover paths of every repository linked to the task, primary first. */
repositoryChips?: RepositoryChip[];
onClick?: (task: Task) => void;
onEdit?: (task: Task) => void;
onDelete?: (task: Task, opts?: TaskActionOptions) => void;
onArchive?: (task: Task, opts?: TaskActionOptions) => 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;
/** Shift-click range select within this card's column. */
onRangeSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
}
function useKanbanCardMoveMenuActions({
task,
steps,
isSelected,
selectedIds,
onMove,
}: Pick<KanbanCardProps, "task" | "steps" | "isSelected" | "selectedIds" | "onMove">) {
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore();
const runMoveTasks = (
taskIds: string[],
workflowId: string,
stepId: string,
destination: "step" | "workflow",
) => {
void moveTasks(taskIds, workflowId, stepId, destination).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
};
const moveToStepFromDropdown = (stepId: string) => {
if (onMove) return onMove(task, stepId);
if (moveTargets.currentWorkflowId) {
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId, "step");
}
};
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
const orderedSelectedIds = () => sortByDisplayOrder(selectedTaskIds);
const isMixedWorkflowSelection =
selectedTaskIds.length > 1 &&
new Set(selectedTaskIds.map((id) => getWorkflowIdForTask(id))).size > 1;
const moveSelectedToStep = (stepId: string) => {
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
onMove(task, stepId);
return;
}
if (!moveTargets.currentWorkflowId) return;
runMoveTasks(orderedSelectedIds(), moveTargets.currentWorkflowId, stepId, "step");
};
return {
moveTargets,
moveToStepFromDropdown,
moveSelectedToStep: isMixedWorkflowSelection ? undefined : moveSelectedToStep,
sendTaskToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks([task.id], workflowId, stepId, "workflow");
},
sendSelectionToWorkflow: (workflowId: string, stepId: string) => {
runMoveTasks(orderedSelectedIds(), workflowId, stepId, "workflow");
},
};
}
function externalLinkHandlers(
availability: KanbanCardProps["externalLinkAvailability"],
setExternalLinkProvider: (provider: ExternalLinkProvider) => void,
) {
return {
onLinkJiraTicket: availability.jira ? () => setExternalLinkProvider("jira") : undefined,
onLinkLinearIssue: availability.linear ? () => setExternalLinkProvider("linear") : undefined,
onLinkSentryIssue: availability.sentry ? () => setExternalLinkProvider("sentry") : undefined,
};
}
/** Link-dialog openers shared by both the dropdown and context menu builds. */
function buildLinkDialogHandlers(
externalLinkAvailability: KanbanExternalLinkAvailability,
dialogs: ReturnType<typeof useTaskMenuDialogState>,
) {
return {
onLinkPullRequest: () => dialogs.setShowPRDialog(true),
onLinkIssue: () => dialogs.setShowIssueDialog(true),
onLinkMergeRequest: externalLinkAvailability.gitlab
? () => dialogs.setShowMRDialog(true)
: undefined,
...externalLinkHandlers(externalLinkAvailability, dialogs.setExternalLinkProvider),
};
}
export function buildPluginMenuContext(
task: Task,
workspaceId: string | null,
presentation: KanbanPresentation,
): PluginTaskMenuContext {
return {
workspaceId: workspaceId ?? "",
taskId: task.id,
taskTitle: task.title,
workflowStepId: task.workflowStepId ?? null,
presentation,
};
}
function useKanbanCardMenus({
task,
workspaceId,
presentation = "desktop",
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
externalLinkAvailability,
}: Pick<
KanbanCardProps,
| "task"
| "workspaceId"
| "presentation"
| "externalLinkAvailability"
| "steps"
| "isDeleting"
| "isArchiving"
| "isSelected"
| "selectedIds"
| "onEdit"
| "onDelete"
| "onArchive"
| "onMove"
>) {
const pluginLinkActions = useTaskPluginLinkActions(task.id, task.repositories ?? []);
// Plugins load asynchronously and can be disabled/uninstalled at runtime;
// re-render on any registry change so a menu action a plugin registers
// after this card already mounted still appears, and one whose plugin was
// just disabled doesn't linger as a stale entry.
usePluginRegistry();
const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove });
const dialogs = useTaskMenuDialogState();
const { detachTask, detachingTaskId } = useDetachTask();
const updateTaskPriority = useUpdateTaskPriority();
const detachAnchorRef = useRef<HTMLDivElement>(null);
const detachFocusReturnRef = useRef<HTMLButtonElement>(null);
const isDetaching = detachingTaskId === task.id;
const disabled = Boolean(isDeleting || isArchiving || isDetaching);
const actingOnMultiSelection = Boolean(isSelected && selectedIds && selectedIds.size > 1);
const handleDetachConfirm = async () => {
try {
await detachTask(task.id);
dialogs.setShowDetachConfirm(false);
} catch (error) {
console.error("Failed to detach task:", error);
}
};
const requestDetachConfirmation = () => {
// Let Radix finish the menu's pointer sequence before the non-modal
// popover opens; otherwise the initiating menu event is an outside click.
window.setTimeout(() => dialogs.setShowDetachConfirm(true), 300);
};
const requestArchiveConfirmation = () => {
// Let Radix finish the menu's pointer sequence before the local surface
// opens; otherwise the initiating menu event is treated as outside input.
window.setTimeout(() => dialogs.setShowArchiveConfirm(true), 300);
};
const menuBase = {
currentWorkflowId: moveMenu.moveTargets.currentWorkflowId,
currentStepId: task.workflowStepId,
workflows: moveMenu.moveTargets.workflowItems,
stepsByWorkflowId: moveMenu.moveTargets.stepsByWorkflowId,
disabled,
isDeleting,
isArchiving,
isDetaching,
parentTaskId: task.parentTaskId,
currentPriority: task.priority,
onSelectPriority: (priority: TaskPriority) => void updateTaskPriority(task.id, priority),
onEdit: onEdit ? () => onEdit(task) : undefined,
onArchive: onArchive ? requestArchiveConfirmation : undefined,
onDelete: onDelete ? () => dialogs.setShowDeleteConfirm(true) : undefined,
onDetach: task.parentTaskId && !actingOnMultiSelection ? requestDetachConfirmation : undefined,
...buildLinkDialogHandlers(externalLinkAvailability, dialogs),
pluginLinkActions,
};
const pluginMenuContext = buildPluginMenuContext(task, workspaceId, presentation);
return {
...dialogs,
dropdownMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveMenu.moveToStepFromDropdown,
onSendToWorkflow: moveMenu.sendTaskToWorkflow,
pluginMenuContext,
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveMenu.moveSelectedToStep,
onSendToWorkflow: moveMenu.sendSelectionToWorkflow,
pluginMenuContext,
}),
isDetaching,
detachAnchorRef,
detachFocusReturnRef,
archiveAnchorRef: detachFocusReturnRef,
archiveFocusReturnRef: detachFocusReturnRef,
handleDetachConfirm,
};
}
export type KanbanCardMenuState = ReturnType<typeof useKanbanCardMenus>;
/**
* Cmd/Ctrl-click toggles a single card; Shift-click range-selects within the
* column; either modifier enters multi-select mode without the toggle button.
* A plain click toggles while in multi-select mode, otherwise previews/opens.
*/
/** @internal Exported for unit testing the four-branch click dispatch. */
export function dispatchKanbanCardClick(
e: React.MouseEvent,
taskId: string,
task: Task,
handlers: {
onToggleSelect?: (taskId: string) => void;
onRangeSelect?: (taskId: string) => void;
onClick?: (task: Task) => void;
isMultiSelectMode?: boolean;
},
): void {
// Only intercept a modifier click when the matching handler is wired, so a
// card rendered without selection handlers still opens on Cmd/Shift click.
if ((e.metaKey || e.ctrlKey) && handlers.onToggleSelect) {
e.preventDefault();
handlers.onToggleSelect(taskId);
return;
}
if (e.shiftKey && handlers.onRangeSelect) {
e.preventDefault();
handlers.onRangeSelect(taskId);
return;
}
if (handlers.isMultiSelectMode && handlers.onToggleSelect) {
handlers.onToggleSelect(taskId);
return;
}
handlers.onClick?.(task);
}
function KanbanCardFrame({
task,
presentation,
repositoryChips,
draggable,
menu,
isPreviewed,
isSelected,
isMultiSelectMode,
showMaximizeButton,
isDeleting,
isArchiving,
onArchive,
onClick,
onToggleSelect,
onOpenFullPage,
}: Pick<
KanbanCardProps,
| "task"
| "presentation"
| "repositoryChips"
| "isSelected"
| "isMultiSelectMode"
| "showMaximizeButton"
| "isDeleting"
| "isArchiving"
| "onArchive"
| "onToggleSelect"
| "onOpenFullPage"
> & {
draggable: ReturnType<typeof useDraggable>;
menu: KanbanCardMenuState;
isPreviewed: boolean;
onClick: (e: React.MouseEvent) => void;
}) {
return (
<>
<div ref={menu.detachAnchorRef} className="w-full">
<KanbanCardContextMenu entries={menu.contextMenuEntries}>
<KanbanCardShell
task={task}
repositoryChips={repositoryChips}
attributes={draggable.attributes}
listeners={draggable.listeners}
setNodeRef={draggable.setNodeRef}
transform={draggable.transform}
isDragging={draggable.isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={menu.dropdownMenuEntries}
menuTriggerRef={menu.detachFocusReturnRef}
onClick={onClick}
onCheckboxClick={(e) => {
e.stopPropagation();
onToggleSelect?.(task.id);
}}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
</div>
<TaskDetachConfirmationSurface
taskId={task.id}
open={menu.showDetachConfirm}
anchorRef={menu.detachAnchorRef}
focusReturnRef={menu.detachFocusReturnRef}
taskTitle={task.title}
sharesParentWorkspace={task.workspaceMode === "inherit_parent"}
onOpenChange={menu.setShowDetachConfirm}
onConfirm={menu.handleDetachConfirm}
/>
<TaskArchiveConfirmation
open={menu.showArchiveConfirm}
anchorRef={menu.archiveAnchorRef}
focusReturnRef={menu.archiveFocusReturnRef}
taskTitle={task.title}
taskId={task.id}
executorType={task.primaryExecutorType}
isArchiving={isArchiving}
forceDialog={presentation === "mobile"}
onOpenChange={menu.setShowArchiveConfirm}
onConfirm={({ cascade }) => onArchive?.(task, { cascade })}
/>
</>
);
}
export function KanbanCard({
task,
workspaceId,
presentation = "desktop",
externalLinkAvailability,
repositoryChips,
onClick,
onEdit,
onDelete,
onArchive,
onOpenFullPage,
onMove,
steps,
showMaximizeButton = false,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onToggleSelect,
onRangeSelect,
isMultiSelectMode,
}: KanbanCardProps) {
const draggable = useDraggable({
id: task.id,
disabled: isMultiSelectMode,
});
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
const repositories = useActiveWorkspaceRepositories();
const menu = useKanbanCardMenus({
task,
workspaceId,
presentation,
externalLinkAvailability,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
});
const handleClick = (e: React.MouseEvent) =>
dispatchKanbanCardClick(e, task.id, task, {
onToggleSelect,
onRangeSelect,
onClick,
isMultiSelectMode,
});
return (
<>
<KanbanCardFrame
task={task}
presentation={presentation}
repositoryChips={repositoryChips}
draggable={draggable}
menu={menu}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
onArchive={onArchive}
onClick={handleClick}
onToggleSelect={onToggleSelect}
onOpenFullPage={onOpenFullPage}
/>
<KanbanCardDialogs
task={task}
workspaceId={workspaceId}
repositories={repositories}
menu={menu}
isDeleting={isDeleting}
onDelete={onDelete}
/>
</>
);
}