Files
claude-code/src/components/TaskListV2.tsx
claude-code-best 5b1a52b8e0 更新大量 tsx 原始文件; 已经迁移 login panel; 部分 (#121)
* style(B1-1): 格式化 ink/buddy/cli/context/screens/tasks/services/keybindings/state (43 files)

纯格式化:移除分号、React Compiler import、import 多行展开。
修复了 Box.tsx 和 ScrollBox.tsx 中无效的 global.d.ts import。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-2): 格式化 commands (79 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-3): 格式化 components/messages,permissions,mcp,sandbox,shell (104 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-4): 格式化 components/PromptInput,FeedbackSurvey,tasks,agents,skills,design-system,wizard (73 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-5): 格式化 components其余 + hooks + tools (232 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 更新 README,新增 Run.ps1/TODO.md,删除 V6.md

- README.md: 大幅重写,更详细版本历史和配置示例
- Run.ps1: 新增 Windows 启动脚本
- TODO.md: 新增包完成清单
- V6.md: 删除(架构重构规划已不适用)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: 修复以前的问题

* fix: 修复 login 面板的问题

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 23:24:27 +08:00

362 lines
11 KiB
TypeScript

import figures from 'figures'
import * as React from 'react'
import { useTerminalSize } from '../hooks/useTerminalSize.js'
import { stringWidth } from '../ink/stringWidth.js'
import { Box, Text } from '../ink.js'
import { useAppState } from '../state/AppState.js'
import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'
import {
AGENT_COLOR_TO_THEME_COLOR,
type AgentColorName,
} from '../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>
)
}