fix(conversation): align TodoWrite task projection and disclosure lifecycle
This commit is contained in:
1 parent
8d10743fb2
commit
d423d59b08
9 files changed
+335
-66
No files matched your search
@@ -117,6 +117,9 @@ export interface SourceToolExpandableProps extends Omit<SourceToolHeaderProps, '
|
||||
canExpand?: boolean
|
||||
defaultExpanded?: boolean
|
||||
expand?: boolean
|
||||
/** Todo-only opt-in: retain actual position edges through presentation remounts. */
|
||||
retainExpandEdges?: boolean
|
||||
disclosureKey?: string
|
||||
autoScroll?: boolean
|
||||
disableMotion?: boolean
|
||||
children?: ReactNode
|
||||
@@ -127,6 +130,8 @@ export function SourceToolExpandable({
|
||||
canExpand = true,
|
||||
defaultExpanded = false,
|
||||
expand,
|
||||
retainExpandEdges = false,
|
||||
disclosureKey,
|
||||
autoScroll = false,
|
||||
disableMotion = false,
|
||||
children,
|
||||
@@ -134,7 +139,9 @@ export function SourceToolExpandable({
|
||||
className,
|
||||
...headerProps
|
||||
}: SourceToolExpandableProps) {
|
||||
const [expanded, setExpanded] = useDisclosureState('tool-expandable', () => defaultExpanded || expand === true)
|
||||
const slot = disclosureKey ? `tool-expandable:${disclosureKey}` : 'tool-expandable'
|
||||
const [expanded, setExpanded] = useDisclosureState(slot, () => defaultExpanded || expand === true)
|
||||
const [retainedExpand, setRetainedExpand] = useDisclosureState(`${slot}:expand-signal`, expand === true)
|
||||
const priorExpand = useRef<boolean | undefined>(undefined)
|
||||
const expandable = canExpand && children != null
|
||||
const open = expandable && expanded
|
||||
@@ -143,11 +150,12 @@ export function SourceToolExpandable({
|
||||
// Same transition semantics as source ToolExpandable [988614,989631).
|
||||
useEffect(() => {
|
||||
if (expand === undefined) return
|
||||
const previous = priorExpand.current
|
||||
const previous = retainExpandEdges ? retainedExpand : priorExpand.current
|
||||
priorExpand.current = expand
|
||||
if (retainExpandEdges) setRetainedExpand(expand)
|
||||
if (previous === true && !expand) setExpanded(false)
|
||||
else if (previous === false && expand) setExpanded(true)
|
||||
}, [expand])
|
||||
}, [expand, retainExpandEdges, retainedExpand, setRetainedExpand, setExpanded])
|
||||
// Electron 37 supports source interpolate-size CSS; disabled motion uses source setStaticState.
|
||||
// The older-renderer JS animation-library fallback is not recovered here.
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { ConversationToolCall } from './types'
|
||||
import { SourceToolExpandable, SourceToolHeader } from './SourceToolHeader'
|
||||
import { CheckIcon, FailedIcon, LoaderIcon, PlanIcon } from './RecoveredBlockIcons'
|
||||
import { getToolCallPhase } from './ToolCall'
|
||||
import { sourceConversationLabel as t } from './source-labels'
|
||||
import { getPlanTodoStatus, selectPlanTasks, type PlanTodoItem } from './SpecializedBlocks'
|
||||
import './todo-write.css'
|
||||
|
||||
/** Payload for the tool.todo.task.click integration. No project task is implied. */
|
||||
export interface TodoTaskClickRequest {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
taskId: string | number
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === 'string') {
|
||||
try { return record(JSON.parse(value)) } catch { return {} }
|
||||
}
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||
}
|
||||
function text(...values: unknown[]): string {
|
||||
return values.find(value => typeof value === 'string' && value.trim()) as string || ''
|
||||
}
|
||||
|
||||
/** Read-only presentation projection; never edits tool args, results or history. */
|
||||
export function normalizeTodoWrite(tool: ConversationToolCall) {
|
||||
const args = tool.args ?? {}
|
||||
const raw = record(args._rawInput)
|
||||
const data = Object.keys(raw).length ? raw : args
|
||||
const candidates = [data.todos, data.steps, args.todos, args.steps]
|
||||
const rawTasks = candidates.find(Array.isArray) as unknown[] | undefined ?? []
|
||||
const tasks: PlanTodoItem[] = rawTasks.flatMap((value, index) => {
|
||||
const item = record(value)
|
||||
const content = text(item.content, item.title, item.text, item.step)
|
||||
if (!content) return []
|
||||
return [{
|
||||
id: typeof item.id === 'string' || typeof item.id === 'number' ? item.id : String(index + 1),
|
||||
content,
|
||||
activeForm: text(item.activeForm) || undefined,
|
||||
status: text(item.status)
|
||||
}]
|
||||
})
|
||||
return {
|
||||
tasks,
|
||||
completedCount: tasks.filter(task => getPlanTodoStatus(task.status) === 'completed').length,
|
||||
totalCount: tasks.length,
|
||||
merge: (data.merge ?? args.merge) === true
|
||||
}
|
||||
}
|
||||
|
||||
export function windowTodoTasks(tasks: PlanTodoItem[], maxDisplay = 5): PlanTodoItem[] {
|
||||
const limit = Number.isFinite(maxDisplay) ? Math.max(1, Math.floor(maxDisplay)) : 5
|
||||
return selectPlanTasks(tasks, limit).visible
|
||||
}
|
||||
|
||||
function StatusGlyph({ status }: { status: ReturnType<typeof getPlanTodoStatus> }) {
|
||||
if (status === 'completed') return <CheckIcon />
|
||||
if (status === 'in_progress') return <LoaderIcon className="cr-tool-plan-task__loader" />
|
||||
if (status === 'failed') return <FailedIcon />
|
||||
return <span className="cr-tool-plan-task__pending" />
|
||||
}
|
||||
|
||||
export function TodoWriteView({ tool, sessionId, defaultExpanded = false, onTaskClick }: {
|
||||
tool: ConversationToolCall
|
||||
sessionId?: string
|
||||
defaultExpanded?: boolean
|
||||
onTaskClick?: (request: TodoTaskClickRequest) => void
|
||||
}) {
|
||||
const { tasks, completedCount, totalCount, merge } = normalizeTodoWrite(tool)
|
||||
const phase = getToolCallPhase(tool.status)
|
||||
const title = merge ? t('tool.todo.progress', { completed: completedCount, total: totalCount }) : t('tool.todo.list')
|
||||
if (phase === 'error') return <SourceToolHeader className="cr-tool-head--todo-write" error statusText={t('tool.todo.generateError')} />
|
||||
if (!tasks.length) return <SourceToolHeader className="cr-tool-head--todo-write" icon={<PlanIcon />} statusText={title}
|
||||
secondaryInfo={phase === 'running' ? t('tool.todo.receiving') : undefined} />
|
||||
return <SourceToolExpandable className="cr-tool-head--todo-write" icon={<PlanIcon />} statusText={title}
|
||||
defaultExpanded={defaultExpanded} expand={tool.isLastContent === true} retainExpandEdges
|
||||
disclosureKey={`todo:${JSON.stringify([sessionId ?? '', tool.id])}`}>
|
||||
<div className="cr-tool-plan-task">
|
||||
{windowTodoTasks(tasks).map((task, index) => {
|
||||
const status = getPlanTodoStatus(task.status)
|
||||
const label = status === 'in_progress' ? task.activeForm || task.content : task.content
|
||||
const activate = onTaskClick ? () => onTaskClick({ toolCallId: tool.id, toolName: tool.name, taskId: task.id }) : undefined
|
||||
return <div key={`${typeof task.id}:${task.id}:${index}`} className={`cr-tool-plan-task__row${activate ? ' cr-tool-plan-task__row--clickable' : ''}`}
|
||||
data-task-id={task.id} data-task-status={status} role={activate ? 'button' : undefined} tabIndex={activate ? 0 : undefined}
|
||||
onClick={activate} onKeyDown={activate ? event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); activate() }
|
||||
} : undefined}>
|
||||
<span className={`cr-tool-plan-task__glyph cr-tool-plan-task__glyph--${status}`} aria-hidden="true"><StatusGlyph status={status} /></span>
|
||||
<span className={`cr-tool-plan-task__text${status === 'completed' ? ' cr-tool-plan-task__text--done' : ''}${status === 'in_progress' ? ' cr-tool-plan-task__text--running' : ''}`} title={label}>{label}</span>
|
||||
{activate && <span className="cr-tool-plan-task__action" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="m6 4 4 4-4 4" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" /></svg></span>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</SourceToolExpandable>
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { AskUserQuestion } from './AskUserQuestion'
|
||||
import { ToolCallCard, getToolCallPhase, getToolResultText } from './ToolCall'
|
||||
import { SourceToolExpandable, SourceToolHeader } from './SourceToolHeader'
|
||||
import { TodoWriteView, type TodoTaskClickRequest } from './TodoWriteView'
|
||||
import { resolveSourceToolIcon } from './SourceToolIcons'
|
||||
import { SourceWriteFileView } from './SourceWriteFileView'
|
||||
import { SourcePresentFilesView } from './SourcePresentFilesView'
|
||||
@@ -12,7 +13,7 @@ import { SourceToolApprovalActions } from './SourceToolActions'
|
||||
import { hasSourcePermissionRequest, type ToolApprovalControl } from './tool-approval'
|
||||
import { sourceConversationLabel as t } from './source-labels'
|
||||
import { CheckIcon, FailedIcon } from './RecoveredBlockIcons'
|
||||
import { getPlanTodoStatus, PlanCreateBlock, PlanUpdateBlock, TaskBlock, type PlanTodoItem, type TaskToolData } from './SpecializedBlocks'
|
||||
import { PlanCreateBlock, PlanUpdateBlock, TaskBlock, type PlanTodoItem, type TaskToolData } from './SpecializedBlocks'
|
||||
import type { ConversationToolCall } from './types'
|
||||
import type { PreviewArtifact } from '@shared/artifacts'
|
||||
|
||||
@@ -38,6 +39,7 @@ export interface ToolCallRendererProps {
|
||||
onOpenLink?: (url: string, toolCallId: string, toolName: string) => void
|
||||
onTaskApprove?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
||||
onTaskReject?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
||||
onTodoTaskClick?: (request: TodoTaskClickRequest) => void
|
||||
onPlanTaskClick?: (request: { toolCallId: string; toolName: string; planId: string; taskId: string | number }) => void
|
||||
onPlanView?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
||||
onPlanDownload?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
||||
@@ -156,6 +158,7 @@ export function ToolCallRenderer({
|
||||
onOpenLink,
|
||||
onTaskApprove,
|
||||
onTaskReject,
|
||||
onTodoTaskClick,
|
||||
onPlanTaskClick,
|
||||
onPlanView,
|
||||
onPlanDownload,
|
||||
@@ -188,7 +191,7 @@ export function ToolCallRenderer({
|
||||
return withToolOutput(tool, view)
|
||||
}
|
||||
if (kind === 'todo-write') {
|
||||
return <TodoWriteView tool={tool} defaultExpanded={defaultExpanded} />
|
||||
return <TodoWriteView key={`${sessionId ?? ''}:${tool.id}`} tool={tool} sessionId={sessionId} defaultExpanded={defaultExpanded} onTaskClick={onTodoTaskClick} />
|
||||
}
|
||||
if (kind === 'exit-plan') {
|
||||
const plan = normalizeExitPlan(tool)
|
||||
@@ -251,57 +254,6 @@ function ToolOutput({ tool }: { tool: ConversationToolCall }) {
|
||||
return <pre className="cr-captured-tool__output">{tool.output}</pre>
|
||||
}
|
||||
|
||||
function TodoWriteView({ tool, defaultExpanded }: { tool: ConversationToolCall; defaultExpanded: boolean }) {
|
||||
const rawInput = parseRecord(tool.args?._rawInput)
|
||||
const data = Object.keys(rawInput).length > 0 ? rawInput : tool.args ?? {}
|
||||
const rawTasks = Array.isArray(data.todos) ? data.todos
|
||||
: Array.isArray(data.steps) ? data.steps
|
||||
: Array.isArray(tool.args?.todos) ? tool.args.todos
|
||||
: Array.isArray(tool.args?.steps) ? tool.args.steps : []
|
||||
const tasks = rawTasks.flatMap((value, index): PlanTodoItem[] => {
|
||||
const task = asRecord(value)
|
||||
const content = firstString(task.content, task.title, task.text, task.step)
|
||||
if (!content) return []
|
||||
return [{
|
||||
id: typeof task.id === 'string' || typeof task.id === 'number' ? task.id : String(index + 1),
|
||||
content,
|
||||
activeForm: firstString(task.activeForm) || undefined,
|
||||
status: firstString(task.status)
|
||||
}]
|
||||
})
|
||||
const completedCount = tasks.filter(task => getPlanTodoStatus(task.status) === 'completed').length
|
||||
const totalCount = typeof data.totalCount === 'number' ? data.totalCount : tasks.length
|
||||
const isRunning = getToolCallPhase(tool.status) === 'running'
|
||||
const isError = getToolCallPhase(tool.status) === 'error'
|
||||
|
||||
return <CapturedToolCard tool={tool} title={t('tool.todo.list')} defaultExpanded={defaultExpanded || tool.isLastContent === true}>
|
||||
{isError
|
||||
? <ToolSummary primary={t('tool.todo.generateError')} />
|
||||
: tasks.length === 0
|
||||
? <ToolSummary primary={t('tool.todo.list')} secondary={isRunning ? t('tool.todo.receiving') : undefined} />
|
||||
: <div className="cr-captured-tool__details">
|
||||
<ToolSummary primary={data.merge === true ? t('tool.todo.progress', { completed: completedCount, total: totalCount }) : t('tool.todo.list')} />
|
||||
<div className="cb-plan-todolist"><div className="cb-plan-todolist-wrapper">
|
||||
{tasks.map(task => {
|
||||
const status = getPlanTodoStatus(task.status)
|
||||
return <div className={`cb-plan-task cb-plan-task--${status}`} key={task.id}>
|
||||
<span className="cb-plan-task-status" aria-hidden="true">{todoStatusMark(status)}</span>
|
||||
<span className="cb-plan-task-text">{status === 'in_progress' ? task.activeForm || task.content : task.content}</span>
|
||||
</div>
|
||||
})}
|
||||
</div></div>
|
||||
</div>}
|
||||
<ToolOutput tool={tool} />
|
||||
</CapturedToolCard>
|
||||
}
|
||||
|
||||
function todoStatusMark(status: ReturnType<typeof getPlanTodoStatus>): string {
|
||||
if (status === 'completed') return 'OK'
|
||||
if (status === 'in_progress') return '>'
|
||||
if (status === 'failed') return 'x'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function ToolSummary({ primary, secondary }: { primary: string; secondary?: string }) {
|
||||
return <div className="cr-captured-tool__summary"><strong>{primary}</strong>{secondary && <span>{secondary}</span>}</div>
|
||||
}
|
||||
|
||||
@@ -1344,7 +1344,7 @@ const zhCN = {
|
||||
"tool.todo.generateError": "生成失败",
|
||||
"tool.todo.list": "任务列表",
|
||||
"tool.todo.receiving": "接收中...",
|
||||
"tool.todo.progress": "进度更新 {completed}/{total}",
|
||||
"tool.todo.progress": "进度 {completed}/{total}",
|
||||
"tool.question.waitingAnswer": "等待你的回答...",
|
||||
"tool.question.generating": "问题生成中...",
|
||||
"tool.question.cancelled": "已取消提问",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/* Modern TodoWrite PlanTask contract from REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94.
|
||||
See docs/协作/TodoWrite样式-REA核对-2026-10-21.md. Not the older cb-plan-task branch. */
|
||||
.cr-tool-plan-task { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.cr-tool-plan-task__row { display: flex; align-items: flex-start; gap: 8px; min-width: 0; border-radius: 8px; padding: 1px 4px 2px; }
|
||||
.cr-tool-plan-task__glyph { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; min-height: 25px; color: var(--cr-text-tertiary); }
|
||||
.cr-tool-plan-task__glyph svg { width: 16px; height: 16px; }
|
||||
.cr-tool-plan-task__pending { display: block; width: 12px; height: 12px; box-sizing: border-box; border: 1px solid currentColor; border-radius: 50%; }
|
||||
.cr-tool-plan-task__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; line-height: 1.75; color: var(--cr-text-primary); }
|
||||
.cr-tool-plan-task__text--done { color: var(--cr-text-tertiary); text-decoration: line-through; }
|
||||
.cr-tool-plan-task__text--running { font-weight: 600; }
|
||||
.cr-tool-plan-task__glyph--failed { color: var(--cr-status-error); }
|
||||
.cr-tool-plan-task__loader { animation: cr-tool-plan-task-spin 2s linear infinite; }
|
||||
.cr-tool-plan-task__row--clickable { cursor: pointer; }
|
||||
.cr-tool-plan-task__row--clickable:hover { background: var(--cr-bg-hover); }
|
||||
.cr-tool-plan-task__row--clickable:focus-visible { outline: 2px solid var(--cr-text-primary); outline-offset: 1px; }
|
||||
.cr-tool-plan-task__action { display: inline-flex; align-items: center; flex: 0 0 16px; min-height: 25px; color: var(--cr-text-tertiary); }
|
||||
@keyframes cr-tool-plan-task-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .cr-tool-plan-task__loader { animation: none; } }
|
||||
Reference in new issue
Block a user