"use client"; import { memo, type ReactNode } from "react"; import { IconChevronDown } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent"; import { TaskItemStatsRow } from "./task-item-stats-row"; import { useTaskColor } from "@/hooks/use-task-color"; import { manualTaskColorPresentation, resolveTaskItemColor, type TaskMarkerPresentation, } from "@/lib/task-color-presentation"; import type { TaskColor } from "@/lib/task-colors"; import type { ForegroundActivity, TaskPriority, TaskState, TaskSessionState, } from "@/lib/types/http"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; import { TaskRowMetadata } from "./task-row-plugin-slots"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { useTranslation } from "react-i18next"; import { TaskItemComparisonUnavailable } from "./task-item-comparison-unavailable"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; import { TaskItemLeadingBadges } from "./task-item-leading-badges"; import { resolveTaskRowPresentation, type ResolvedTaskRowPresentation, } from "./task-row-presentation"; import { TaskItemTrailing, type DiffStats } from "./task-item-trailing"; import { TaskStateIcon } from "./task-state-icon"; type TaskItemProps = { title: string; autopilot?: boolean; priority?: TaskPriority; state?: TaskState; sessionState?: TaskSessionState; /** * Task-level most-active-wins busy aggregate (ADR-0049) carried on the task * record. Drives the sidebar row's generating/background tiers so it agrees * with the board card and open-task header instead of re-deriving from a * single session's substate. */ foregroundActivity?: ForegroundActivity | null; /** * 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 (permission/clarification) and by an active * foregroundActivity. */ parkedOnBackgroundWork?: boolean; isArchived?: boolean; isSelected?: boolean; /** Whether this row is part of an active multi-selection (distinct from the active-task highlight). */ isMultiSelected?: boolean; onClick?: () => void; /** * Modifier-aware activation handler. When provided, both mouse clicks and * keyboard Enter/Space delegate here (cmd/shift/plain dispatch lives in the * parent); `onClick` is the fallback when no selection handler is wired. */ onSelect?: (e: React.MouseEvent | React.KeyboardEvent) => void; diffStats?: DiffStats; comparisonUnavailable?: boolean; isRemoteExecutor?: boolean; remoteExecutorId?: string; remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; lastActivityAt?: string; showActivityTime?: boolean; menuOpen?: boolean; archiveConfirmation?: ReactNode; isDeleting?: boolean; taskId?: string; /** Drives the `task-row-metadata` plugin slot's `workflowStepId`. */ workflowStepId?: string | null; primarySessionId?: string | null; hasPendingClarification?: boolean; hasPendingPermission?: boolean; /** True when the task's session was mid-turn when the backend died. */ interrupted?: boolean; parentTaskTitle?: string; isSubTask?: boolean; /** Whether the task is currently on the final ordered step of its workflow. */ isOnLastWorkflowStep?: boolean; /** * Nesting depth in the sidebar tree (0 = root). Drives left indentation so * arbitrarily deep subtask trees read as a hierarchy. Falls back to * `isSubTask` (depth 1) when omitted. */ depth?: number; /** Number of subtasks under this parent task. Only set for parent rows. */ subtaskCount?: number; /** Whether the subtasks of this parent are currently hidden. */ subtasksCollapsed?: boolean; /** Toggles subtask visibility when the chevron is clicked. */ onToggleSubtasks?: () => void; /** * The task's repository as a stable slug (or name). A multi-repository task * carries its primary repository here for compatibility. The complete * ordered repository combination is projected through * `TaskSwitcherItem.repositories` for sidebar grouping. */ repositoryPath?: string; /** * Whether this row should name its repository. False when the list is grouped * by repository, where the section header already says it. */ showRepository?: boolean; prInfo?: { number: number; state: string; aggregateState?: string }; /** Number of prompts currently en-queued for this task (mail badge). */ queuedCount?: number; /** Destination-resident WIP queue status, separate from queued prompts. */ wipQueue?: WipQueueStatus; issueInfo?: { url: string; number: number }; isPinned?: boolean; agentErrorMessage?: string | null; automaticColor?: TaskMarkerPresentation; taskRowPresentation?: import("@/lib/state/slices/ui/sidebar-task-row-presentation").SidebarTaskRowPresentation; }; function handleTaskItemKeyDown( e: React.KeyboardEvent, onSelect: ((e: React.KeyboardEvent) => void) | undefined, onClick: (() => void) | undefined, ): void { if (e.key !== "Enter" && e.key !== " ") return; e.preventDefault(); // Keyboard activation mirrors mouse: when a selection-aware handler is wired, // Enter/Space toggles/extends the selection just like a click would. if (onSelect) onSelect(e); else onClick?.(); } /** State attributes for the row: active-task (`aria-current`) + multi-selected. */ function taskItemStateAttrs(isSelected: boolean, isMultiSelected: boolean) { return { "data-active": isSelected ? "true" : "false", "data-multiselected": isMultiSelected ? "true" : undefined, "aria-current": isSelected ? ("true" as const) : undefined, // Surface the multi-selected state to assistive tech. "aria-selected": isMultiSelected ? true : undefined, }; } function taskItemRowClassName( isSelected: boolean, isMultiSelected: boolean, isRoot: boolean, hasDetails: boolean, ): string { const rowSurfaceClass = isSelected ? "border-y border-primary/50 bg-primary/15 hover:bg-primary/20" : "hover:bg-foreground/[0.05]"; return cn( "group relative flex w-full gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", hasDetails ? "items-start" : "items-center", "transition-colors duration-75", rowSurfaceClass, // When a row is multi-selected, keep the active background when applicable // and add only the existing selection ring for the multi-selection state. isMultiSelected && !isSelected && "bg-primary/5", isMultiSelected && "ring-1 ring-inset ring-primary/40", isRoot && "pl-3", ); } /** Mouse uses the modifier-aware `onSelect`; falls back to the plain `onClick`. */ function taskItemRowClick( onSelect: ((e: React.MouseEvent | React.KeyboardEvent) => void) | undefined, onClick: (() => void) | undefined, ): (e: React.MouseEvent) => void { return (e) => (onSelect ? onSelect(e) : onClick?.()); } function TaskItemTitle({ title }: { title: string }) { return {title}; } type TaskItemContentProps = { title: string; autopilot?: boolean; priority?: TaskPriority; taskId?: string; workflowStepId?: string | null; isRemoteExecutor?: boolean; remoteExecutorId?: string; remoteExecutorType?: string; remoteExecutorName?: string; primarySessionId?: string | null; isArchived?: boolean; isPinned?: boolean; repositoryPath?: string; prInfo?: { number: number; state: string; aggregateState?: string }; queuedCount?: number; wipQueue?: WipQueueStatus; issueInfo?: { url: string; number: number }; agentErrorMessage?: string | null; comparisonUnavailable?: boolean; resolvedTaskRow: ResolvedTaskRowPresentation; relativeTime?: string; }; function TaskItemContent({ title, autopilot, priority, taskId, workflowStepId, isRemoteExecutor, remoteExecutorId, remoteExecutorType, remoteExecutorName, primarySessionId, isArchived, isPinned, repositoryPath, prInfo, queuedCount, wipQueue, issueInfo, agentErrorMessage, comparisonUnavailable, resolvedTaskRow, relativeTime, }: TaskItemContentProps) { const { t } = useTranslation(); return (
{isRemoteExecutor && ( )} {isArchived && ( {t("task:filterDimensionArchived")} )} {taskId && resolvedTaskRow.detailsEnabled && ( )} {resolvedTaskRow.detailsEnabled && ( )}
); } function TaskItemActions({ archiveConfirmation, resolvedTaskRow, diffStats, menuOpen, effectiveMenuOpen, relativeTime, taskId, prInfo, }: { archiveConfirmation?: ReactNode; resolvedTaskRow: ResolvedTaskRowPresentation; diffStats?: DiffStats; menuOpen: boolean; effectiveMenuOpen: boolean; relativeTime?: string; taskId?: string; prInfo?: { number: number; state: string; aggregateState?: string }; }) { if (archiveConfirmation) { return (
{archiveConfirmation}
); } return ( ); } // eslint-disable-next-line max-lines-per-function export const TaskItem = memo(function TaskItem({ title, autopilot, priority, state, sessionState, foregroundActivity, parkedOnBackgroundWork, isArchived, isSelected = false, isMultiSelected = false, onClick, onSelect, diffStats, archiveConfirmation, comparisonUnavailable, isRemoteExecutor, remoteExecutorId, remoteExecutorType, remoteExecutorName, updatedAt, lastActivityAt, showActivityTime = false, menuOpen = false, isDeleting, taskId, workflowStepId, primarySessionId, hasPendingClarification, hasPendingPermission, interrupted, isSubTask, depth, subtaskCount, subtasksCollapsed, onToggleSubtasks, repositoryPath, showRepository = true, prInfo, queuedCount, wipQueue, issueInfo, isPinned, agentErrorMessage, automaticColor, isOnLastWorkflowStep = false, taskRowPresentation, }: TaskItemProps) { const effectiveMenuOpen = menuOpen || isDeleting === true; const resolvedTaskRow = resolveTaskRowPresentation(taskRowPresentation, { showRepository }); const relativeTime = showActivityTime ? (lastActivityAt ?? updatedAt) : updatedAt; const taskColor = useTaskColor(taskId); const manualColor = manualTaskColorPresentation(taskColor); const indent = computeRowIndent(resolveRowDepth(depth, isSubTask)); return (
handleTaskItemKeyDown(e, onSelect, onClick)} style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined} className={cn( taskItemRowClassName( isSelected, isMultiSelected, indent.depth === 0, resolvedTaskRow.detailsEnabled, ), archiveConfirmation && "flex-wrap", )} > {!!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks && ( )}
); }); // Nested-subtask connector glyph. Renders nothing at the top level (depth 0). function RowConnector({ depth, leftPx }: { depth: number; leftPx: number }) { if (depth === 0) return null; return ( ↳ ); } function SelectionBar({ isSelected, color, }: { isSelected: boolean; color: (TaskMarkerPresentation | { token: TaskColor; className: string }) | null; }) { if (!color) return null; return (
); } function SubtaskToggle({ taskId, count, collapsed, onToggle, }: { taskId?: string; count: number; collapsed: boolean; onToggle: () => void; }) { const { t } = useTranslation(); return ( ); }