"use client"; import { memo } from "react"; import { IconChevronDown, IconCircleCheck, IconCircleDashed, IconMessageQuestion, IconProgressCheck, IconShieldQuestion, } 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 { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { ForegroundActivity, TaskState, TaskSessionState } from "@/lib/types/http"; import { InterruptedTaskIcon, isTerminalInterruptedState, shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon, } from "@/lib/ui/state-icons"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; import { TaskRowMetadata } from "./task-row-plugin-slots"; import { classifyTask } from "./task-classify"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; import { useTranslation } from "react-i18next"; import { TaskTitleHoverCard } from "@/components/task/task-title-hover-card"; import type { WipQueueStatus } from "@/lib/kanban/wip-queue"; import { TaskItemComparisonUnavailable } from "./task-item-comparison-unavailable"; import { TaskMenuButton } from "./task-item-menu-button"; import { TaskItemLeadingBadges } from "./task-item-leading-badges"; type DiffStats = { additions: number; deletions: number; }; type TaskItemProps = { title: string; autopilot?: boolean; 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; 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; remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; lastActivityAt?: string; showActivityTime?: boolean; menuOpen?: boolean; 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; enumerating the rest is deferred, * since `TaskSwitcherItem.repositories` is not populated from live data. */ 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; }; // Delegates to the shared classifier in task-switcher so the sidebar bucket // and the per-task running spinner always agree. A task whose workflow state // is REVIEW or COMPLETED must not render as "running" when its session // transiently cycles through STARTING/RUNNING (e.g. during an agent auto- // resume after a backend restart). function computeIsInProgress(state?: TaskState, sessionState?: TaskSessionState): boolean { return classifyTask(sessionState, state) === "in_progress"; } function computeIsPreparing(state?: TaskState, sessionState?: TaskSessionState): boolean { if (state === "SCHEDULING") return true; return sessionState === "STARTING" && classifyTask(sessionState, state) !== "review"; } 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, ): string { return cn( "group relative flex w-full items-start gap-2 py-2 pr-3 text-left text-sm outline-none cursor-pointer", "transition-colors duration-75 hover:bg-foreground/[0.05]", isSelected && "bg-primary/10", // When a row is both the active task and multi-selected, keep the stronger // active background and just add the selection ring on top. 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 BackgroundWorkTaskIcon() { const { t } = useTranslation(); return ( {t("task:backgroundWorkIsRunning")} ); } function TaskStateIcon({ sessionState, state, foregroundActivity, isInProgress, hasPendingClarification, hasPendingPermission, isOnLastWorkflowStep, interrupted, }: { sessionState?: TaskSessionState; state?: TaskState; foregroundActivity?: ForegroundActivity | null; isInProgress: boolean; hasPendingClarification?: boolean; hasPendingPermission?: boolean; isOnLastWorkflowStep?: boolean; /** True when the task's session was mid-turn when the backend died. */ interrupted?: boolean; }) { if (shouldUsePermissionTaskIcon(hasPendingPermission)) { return ( ); } if (hasPendingClarification) { return ( ); } if (foregroundActivity === "generating") { return ( ); } if (foregroundActivity === "background") { return ; } if (shouldUseQuestionTaskIcon(state)) { return ( ); } if (computeIsPreparing(state, sessionState)) { return ( ); } // When the aggregate is unknown, a live turn safely falls back to the // established generating spinner rather than a done affordance. if (isInProgress) { return ( ); } // The task's session was mid-turn when the backend died (startup // reconciliation marker). Show the red interruption icon instead of the // idle/done affordances; every active/pending state above already won, and // terminal states keep their own icons. if (interrupted && !isTerminalInterruptedState(state, sessionState)) { return ; } if (classifyTask(sessionState, state) === "review") { if (isOnLastWorkflowStep) { return ( ); } return ( ); } return ( ); } function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpen: boolean }) { return (
+{diffStats.additions}{" "} -{diffStats.deletions}
); } function TaskItemTitle({ taskId, title }: { taskId?: string; title: string }) { // w-full: ScrollOnOverflow's root is inline-block, so once it sits inside // the title-preview trigger's ); }