"use client"; import { memo } from "react"; import { IconAlertCircle, IconChevronDown, IconCircleCheck, IconCircleDashed, IconDots, IconGitPullRequest, IconMessageQuestion, IconPinFilled, IconShieldQuestion, } from "@tabler/icons-react"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { IssueTaskIcon } from "@/components/github/issue-task-icon"; import { useAppStore } from "@/components/state-provider"; import { cn } from "@/lib/utils"; import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent"; import { isDebugUI } from "@/lib/config"; import { useTaskColor } from "@/hooks/use-task-color"; import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } from "@/lib/ui/state-icons"; import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { RemoteCloudTooltip } from "./remote-cloud-tooltip"; import { classifyTask } from "./task-classify"; import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow"; type DiffStats = { additions: number; deletions: number; }; type TaskItemProps = { title: string; state?: TaskState; sessionState?: TaskSessionState; 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; isRemoteExecutor?: boolean; remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; menuOpen?: boolean; isDeleting?: boolean; taskId?: string; primarySessionId?: string | null; hasPendingClarification?: boolean; hasPendingPermission?: boolean; parentTaskTitle?: string; isSubTask?: 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; repositories?: string[]; prInfo?: { number: number; state: string }; issueInfo?: { url: string; number: number }; isPinned?: boolean; agentErrorMessage?: string | null; }; function formatRelativeTime(dateString: string): string { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffSecs = Math.floor(diffMs / 1000); const diffMins = Math.floor(diffSecs / 60); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffSecs < 60) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } // 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 TaskStateIcon({ sessionState, state, isInProgress, hasPendingClarification, hasPendingPermission, }: { sessionState?: TaskSessionState; state?: TaskState; isInProgress: boolean; hasPendingClarification?: boolean; hasPendingPermission?: boolean; }) { if (shouldUseQuestionTaskIcon(state, hasPendingClarification)) { return ( ); } if (shouldUsePermissionTaskIcon(hasPendingPermission)) { return ( ); } if (computeIsPreparing(state, sessionState)) { return ( ); } if (isInProgress) { return ( ); } if (classifyTask(sessionState, state) === "review") { return ( ); } return ( ); } const POLL_MODE_CONFIG: Record = { fast: { letter: "F", color: "text-emerald-500", label: "focused, 2s polling" }, slow: { letter: "S", color: "text-yellow-500", label: "subscribed, 30s polling" }, paused: { letter: "P", color: "text-muted-foreground/40", label: "no subscribers" }, }; function TaskItemStatsRow({ updatedAt, prInfo, primarySessionId, }: { updatedAt?: string; prInfo?: { number: number; state: string }; primarySessionId?: string | null; }) { const pollMode = useAppStore((s) => isDebugUI() && primarySessionId ? (s.sessionPollMode.bySessionId[primarySessionId] ?? null) : null, ); if (!updatedAt && !prInfo && !pollMode) return null; const modeConfig = pollMode ? POLL_MODE_CONFIG[pollMode] : null; return ( {updatedAt && ( {formatRelativeTime(updatedAt)} )} {prInfo && #{prInfo.number}} {modeConfig && ( {modeConfig.letter} Git poll: {pollMode} ({modeConfig.label}) )} ); } function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpen: boolean }) { return (
+{diffStats.additions}{" "} -{diffStats.deletions}
); } /** Shows PR icon from store (real data) or from prInfo prop (prototype/mock). */ function TaskPRIcon({ taskId, prInfo, }: { taskId?: string; prInfo?: { number: number; state: string }; }) { const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0); if (hasStorePR) return ; if (!prInfo) return null; const state = prInfo.state.toLowerCase(); let color = "text-muted-foreground"; if (state === "merged") color = "text-purple-500"; else if (state === "closed") color = "text-red-500"; return ( ); } function TaskItemContent({ title, taskId, isRemoteExecutor, remoteExecutorType, remoteExecutorName, primarySessionId, isArchived, isPinned, repositories, updatedAt, prInfo, issueInfo, agentErrorMessage, }: { title: string; taskId?: string; isRemoteExecutor?: boolean; remoteExecutorType?: string; remoteExecutorName?: string; primarySessionId?: string | null; isArchived?: boolean; isPinned?: boolean; repositories?: string[]; updatedAt?: string; prInfo?: { number: number; state: string }; issueInfo?: { url: string; number: number }; agentErrorMessage?: string | null; }) { return (
{title} {isPinned && ( )} {issueInfo && } {agentErrorMessage && } {isRemoteExecutor && ( )} {isArchived && ( Archived )} {repositories && repositories.length > 1 && ( {repositories.join(" · ")} )}
); } function TaskAgentErrorIcon({ message }: { message: string }) { return ( {message} ); } export const TaskItem = memo(function TaskItem({ title, state, sessionState, isArchived, isSelected = false, isMultiSelected = false, onClick, onSelect, diffStats, isRemoteExecutor, remoteExecutorType, remoteExecutorName, updatedAt, menuOpen = false, isDeleting, taskId, primarySessionId, hasPendingClarification, hasPendingPermission, isSubTask, depth, subtaskCount, subtasksCollapsed, onToggleSubtasks, repositories, prInfo, issueInfo, isPinned, agentErrorMessage, }: TaskItemProps) { const effectiveMenuOpen = menuOpen || isDeleting === true; const isInProgress = computeIsInProgress(state, sessionState); const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0); const showSubtaskToggle = !!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks; const taskColor = useTaskColor(taskId); const indent = computeRowIndent(resolveRowDepth(depth, isSubTask)); return (
handleTaskItemKeyDown(e, onSelect, onClick)} style={indent.depth > 0 ? { paddingLeft: indent.paddingLeftPx } : undefined} className={taskItemRowClassName(isSelected, isMultiSelected, indent.depth === 0)} > {hasDiffStats ? (
) : ( )} {showSubtaskToggle && ( )}
); }); // 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: TaskColor | null }) { if (color) { return (
); } return (
); } function SubtaskToggle({ taskId, count, collapsed, onToggle, }: { taskId?: string; count: number; collapsed: boolean; onToggle: () => void; }) { return ( ); } function TaskMenuButton({ visible }: { visible: boolean }) { return (
); }