mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
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<string, number>());
|
|
const previousCompletedIdsRef = React.useRef<Set<string> | 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<string, keyof Theme> = {};
|
|
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<string, string> = {};
|
|
const activeTeammates = new Set<string>();
|
|
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 => (
|
|
<TaskItem
|
|
key={task.id}
|
|
task={task}
|
|
ownerColor={task.owner ? teammateColors[task.owner] : undefined}
|
|
openBlockers={task.blockedBy.filter(id => unresolvedTaskIds.has(id))}
|
|
activity={task.owner ? teammateActivity[task.owner] : undefined}
|
|
ownerActive={task.owner ? activeTeammates.has(task.owner) : false}
|
|
columns={columns}
|
|
/>
|
|
))}
|
|
{maxDisplay > 0 && hiddenSummary && <Text dimColor>{hiddenSummary}</Text>}
|
|
</>
|
|
);
|
|
|
|
if (isStandalone) {
|
|
return (
|
|
<Box flexDirection="column" marginTop={1} marginLeft={2}>
|
|
<Box>
|
|
<Text dimColor>
|
|
<Text bold>{tasks.length}</Text>
|
|
{' tasks ('}
|
|
<Text bold>{completedCount}</Text>
|
|
{' done, '}
|
|
{inProgressCount > 0 && (
|
|
<>
|
|
<Text bold>{inProgressCount}</Text>
|
|
{' in progress, '}
|
|
</>
|
|
)}
|
|
<Text bold>{pendingCount}</Text>
|
|
{' open)'}
|
|
</Text>
|
|
</Box>
|
|
{content}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return <Box flexDirection="column">{content}</Box>;
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column">
|
|
<Box>
|
|
<Text color={color}>{icon} </Text>
|
|
<Text bold={isInProgress} strikethrough={isCompleted} dimColor={isCompleted || isBlocked}>
|
|
{displaySubject}
|
|
</Text>
|
|
{showOwner && (
|
|
<Text dimColor>
|
|
{' ('}
|
|
{ownerColor ? <ThemedText color={ownerColor}>@{task.owner}</ThemedText> : `@${task.owner}`}
|
|
{')'}
|
|
</Text>
|
|
)}
|
|
{isBlocked && (
|
|
<Text dimColor>
|
|
{' '}
|
|
{figures.pointerSmall} blocked by{' '}
|
|
{[...openBlockers]
|
|
.sort((a, b) => parseInt(a, 10) - parseInt(b, 10))
|
|
.map(id => `#${id}`)
|
|
.join(', ')}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
{showActivity && displayActivity && (
|
|
<Box>
|
|
<Text dimColor>
|
|
{' '}
|
|
{displayActivity}
|
|
{figures.ellipsis}
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
}
|