import figures from 'figures'; import * as React from 'react'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { Box, Text, stringWidth } from '@anthropic/ink'; import { useAppState } from '../state/AppState.js'; import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; import { AGENT_COLOR_TO_THEME_COLOR, type AgentColorName, } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; import { count } from '../utils/array.js'; import { summarizeRecentActivities } from '../utils/collapseReadSearch.js'; import { truncateToWidth } from '../utils/format.js'; import { isTodoV2Enabled, type Task } from '../utils/tasks.js'; import type { Theme } from '../utils/theme.js'; import ThemedText from './design-system/ThemedText.js'; type Props = { tasks: Task[]; isStandalone?: boolean; }; const RECENT_COMPLETED_TTL_MS = 30_000; function byIdAsc(a: Task, b: Task): number { const aNum = parseInt(a.id, 10); const bNum = parseInt(b.id, 10); if (!isNaN(aNum) && !isNaN(bNum)) { return aNum - bNum; } return a.id.localeCompare(b.id); } export function TaskListV2({ tasks, isStandalone = false }: Props): React.ReactNode { const teamContext = useAppState(s => s.teamContext); const appStateTasks = useAppState(s => s.tasks); const [, forceUpdate] = React.useState(0); const { rows, columns } = useTerminalSize(); // Track when each task was last observed transitioning to completed const completionTimestampsRef = React.useRef(new Map()); const previousCompletedIdsRef = React.useRef | null>(null); if (previousCompletedIdsRef.current === null) { previousCompletedIdsRef.current = new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)); } const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)); // Update completion timestamps: reset when a task transitions to completed const currentCompletedIds = new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)); const now = Date.now(); for (const id of currentCompletedIds) { if (!previousCompletedIdsRef.current.has(id)) { completionTimestampsRef.current.set(id, now); } } for (const id of completionTimestampsRef.current.keys()) { if (!currentCompletedIds.has(id)) { completionTimestampsRef.current.delete(id); } } previousCompletedIdsRef.current = currentCompletedIds; // Schedule re-render when the next recent completion expires. // Depend on `tasks` so the timer is only reset when the task list changes, // not on every render (which was causing unnecessary work). React.useEffect(() => { if (completionTimestampsRef.current.size === 0) { return; } const currentNow = Date.now(); let earliestExpiry = Infinity; for (const ts of completionTimestampsRef.current.values()) { const expiry = ts + RECENT_COMPLETED_TTL_MS; if (expiry > currentNow && expiry < earliestExpiry) { earliestExpiry = expiry; } } if (earliestExpiry === Infinity) { return; } const timer = setTimeout( forceUpdate => forceUpdate((n: number) => n + 1), earliestExpiry - currentNow, forceUpdate, ); return () => clearTimeout(timer); }, [tasks]); if (!isTodoV2Enabled()) { return null; } if (tasks.length === 0) { return null; } // Build a map of teammate name -> theme color const teammateColors: Record = {}; if (isAgentSwarmsEnabled() && teamContext?.teammates) { for (const teammate of Object.values(teamContext.teammates)) { if (teammate.color) { const themeColor = AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName]; if (themeColor) { teammateColors[teammate.name] = themeColor; } } } } // Build a map of teammate name -> current activity description // Map both agentName ("researcher") and agentId ("researcher@team") so // task owners match regardless of which format the model used. // Rolls up consecutive search/read tool uses into a compact summary. // Also track which teammates are still running (not shut down). const teammateActivity: Record = {}; const activeTeammates = new Set(); if (isAgentSwarmsEnabled()) { for (const bgTask of Object.values(appStateTasks)) { if (isInProcessTeammateTask(bgTask) && bgTask.status === 'running') { activeTeammates.add(bgTask.identity.agentName); activeTeammates.add(bgTask.identity.agentId); const activities = bgTask.progress?.recentActivities; const desc = (activities && summarizeRecentActivities(activities)) ?? bgTask.progress?.lastActivity?.activityDescription; if (desc) { teammateActivity[bgTask.identity.agentName] = desc; teammateActivity[bgTask.identity.agentId] = desc; } } } } // Get task counts for display const completedCount = count(tasks, t => t.status === 'completed'); const pendingCount = count(tasks, t => t.status === 'pending'); const inProgressCount = tasks.length - completedCount - pendingCount; // Unresolved tasks (open or in_progress) block dependent tasks const unresolvedTaskIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id)); // Check if we need to truncate const needsTruncation = tasks.length > maxDisplay; let visibleTasks: Task[]; let hiddenTasks: Task[]; if (needsTruncation) { // Prioritize: recently completed (within 30s), in-progress, pending, older completed const recentCompleted: Task[] = []; const olderCompleted: Task[] = []; for (const task of tasks.filter(t => t.status === 'completed')) { const ts = completionTimestampsRef.current.get(task.id); if (ts && now - ts < RECENT_COMPLETED_TTL_MS) { recentCompleted.push(task); } else { olderCompleted.push(task); } } recentCompleted.sort(byIdAsc); olderCompleted.sort(byIdAsc); const inProgress = tasks.filter(t => t.status === 'in_progress').sort(byIdAsc); const pending = tasks .filter(t => t.status === 'pending') .sort((a, b) => { const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)); const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)); if (aBlocked !== bBlocked) { return aBlocked ? 1 : -1; } return byIdAsc(a, b); }); const prioritized = [...recentCompleted, ...inProgress, ...pending, ...olderCompleted]; visibleTasks = prioritized.slice(0, maxDisplay); hiddenTasks = prioritized.slice(maxDisplay); } else { // No truncation needed — sort by ID for stable ordering visibleTasks = [...tasks].sort(byIdAsc); hiddenTasks = []; } let hiddenSummary = ''; if (hiddenTasks.length > 0) { const parts: string[] = []; const hiddenPending = count(hiddenTasks, t => t.status === 'pending'); const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress'); const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed'); if (hiddenInProgress > 0) { parts.push(`${hiddenInProgress} in progress`); } if (hiddenPending > 0) { parts.push(`${hiddenPending} pending`); } if (hiddenCompleted > 0) { parts.push(`${hiddenCompleted} completed`); } hiddenSummary = ` … +${parts.join(', ')}`; } const content = ( <> {visibleTasks.map(task => ( unresolvedTaskIds.has(id))} activity={task.owner ? teammateActivity[task.owner] : undefined} ownerActive={task.owner ? activeTeammates.has(task.owner) : false} columns={columns} /> ))} {maxDisplay > 0 && hiddenSummary && {hiddenSummary}} ); if (isStandalone) { return ( {tasks.length} {' tasks ('} {completedCount} {' done, '} {inProgressCount > 0 && ( <> {inProgressCount} {' in progress, '} )} {pendingCount} {' open)'} {content} ); } return {content}; } type TaskItemProps = { task: Task; ownerColor?: keyof Theme; openBlockers: string[]; activity?: string; ownerActive: boolean; columns: number; }; function getTaskIcon(status: Task['status']): { icon: string; color: keyof Theme | undefined; } { switch (status) { case 'completed': return { icon: figures.tick, color: 'success' }; case 'in_progress': return { icon: figures.squareSmallFilled, color: 'claude' }; case 'pending': return { icon: figures.squareSmall, color: undefined }; } } function TaskItem({ task, ownerColor, openBlockers, activity, ownerActive, columns }: TaskItemProps): React.ReactNode { const isCompleted = task.status === 'completed'; const isInProgress = task.status === 'in_progress'; const isBlocked = openBlockers.length > 0; const { icon, color } = getTaskIcon(task.status); const showActivity = isInProgress && !isBlocked && activity; // Responsive layout: hide owner on narrow screens (<60 cols) // Truncate subject based on available space const showOwner = columns >= 60 && task.owner && ownerActive; const ownerWidth = showOwner ? stringWidth(` (@${task.owner})`) : 0; // Account for: icon(2) + indentation(~8 when nested under spinner) + owner + safety // Use columns - 15 as a conservative estimate for nested layouts const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth); const displaySubject = truncateToWidth(task.subject, maxSubjectWidth); // Truncate activity for narrow screens const maxActivityWidth = Math.max(15, columns - 15); const displayActivity = activity ? truncateToWidth(activity, maxActivityWidth) : undefined; return ( {icon} {displaySubject} {showOwner && ( {' ('} {ownerColor ? @{task.owner} : `@${task.owner}`} {')'} )} {isBlocked && ( {' '} {figures.pointerSmall} blocked by{' '} {[...openBlockers] .sort((a, b) => parseInt(a, 10) - parseInt(b, 10)) .map(id => `#${id}`) .join(', ')} )} {showActivity && displayActivity && ( {' '} {displayActivity} {figures.ellipsis} )} ); }