From d15a6df22497ce7d444e669ea3cc015f77e73388 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:50:32 -0700 Subject: [PATCH] Render task checklists with update diffs and a composer progress panel (#19230) * Render native chat task lists with incremental checklist updates * Keep native chat task-list review plan out of repository root * Render live Codex plan notifications through task checklists * fix(native-chat): keep checklist test fixture within shared boundary * Keep agent task progress in one composer panel * Restore inline task checklists and historical update diffs --------- Co-authored-by: Merge Sim --- .../codex-structured-item-translation.test.ts | 10 + ...eChatMessageList.task-list-frames.test.tsx | 225 ++++++++++++++++ .../native-chat/NativeChatMessageList.tsx | 251 ++++++++++-------- .../native-chat/NativeChatMessageRow.tsx | 12 +- .../native-chat/NativeChatTaskList.test.tsx | 75 ++++++ .../native-chat/NativeChatTaskList.tsx | 183 +++++++++++++ .../native-chat/NativeChatToolRun.test.tsx | 55 ++++ .../native-chat/NativeChatToolRun.tsx | 28 +- .../native-chat-task-list-frames.ts | 36 +++ .../native-chat-task-list-history.test.ts | 114 ++++++++ .../native-chat-task-list-history.ts | 83 ++++++ .../native-chat-task-list-state.test.ts | 73 +++++ .../native-chat-task-list-state.ts | 43 +++ src/renderer/src/i18n/locales/en.json | 16 ++ src/shared/native-chat-task-list.test.ts | 138 ++++++++++ src/shared/native-chat-task-list.ts | 122 +++++++++ src/shared/native-chat-tool-icon.test.ts | 3 + src/shared/native-chat-tool-icon.ts | 1 + 18 files changed, 1352 insertions(+), 116 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatTaskList.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-frames.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-history.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-task-list-state.ts create mode 100644 src/shared/native-chat-task-list.test.ts create mode 100644 src/shared/native-chat-task-list.ts diff --git a/src/main/codex/codex-structured-item-translation.test.ts b/src/main/codex/codex-structured-item-translation.test.ts index 45afb0c9fa5..201df58bc90 100644 --- a/src/main/codex/codex-structured-item-translation.test.ts +++ b/src/main/codex/codex-structured-item-translation.test.ts @@ -584,6 +584,16 @@ describe('codex item bodies', () => { }) }) + it('preserves plan prose documents byte-for-byte as status text', () => { + const text = + ' # Implementation plan\r\n\r\n- [ ] Preserve prose\r\n- [x] Keep café → 日本語\r\n\r\n```ts\r\nconst task = "pending"\r\n```\r\n ' + + expect(codexJournalItem({ type: 'plan', id: 'plan-document', text })).toEqual({ + body: { kind: 'status', text, presentation: 'plan-document' }, + handled: true + }) + }) + it('renders reasoning as status and exposes an unknown item as a provider frame', () => { expect(codexItemBody({ type: 'reasoning', id: 'r', text: 'thinking' })).toEqual({ kind: 'status', diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx new file mode 100644 index 00000000000..e34c1afd131 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.task-list-frames.test.tsx @@ -0,0 +1,225 @@ +// @vitest-environment happy-dom + +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalStatusItem } from '../../../../shared/agent-session-journal-types' +import { projectStructuredItemToNativeChat } from '../../../../shared/structured-agent-session-projection' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { NativeChatMessageList } from './NativeChatMessageList' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' + +afterEach(cleanup) + +function frame(id: number, status: string, overrides: { kind?: string; truncated?: boolean } = {}) { + const kind = overrides.kind ?? 'notification:turn/plan/updated' + const head = JSON.stringify({ + threadId: 'thread', + turnId: 'turn', + explanation: 'Keep verification visible', + plan: [{ step: 'Verify', status }] + }) + const body: AgentJournalStatusItem = { + kind: 'status', + text: `codex · ${kind}`, + providerFrame: { + provider: 'codex', + kind, + payload: { + head, + byteLength: new TextEncoder().encode(head).byteLength, + digest: 'fixture-digest', + truncated: overrides.truncated ?? false + } + } + } + const message = projectStructuredItemToNativeChat({ + itemId: `frame-${id}`, + revision: 1, + sequence: id, + observedAt: id, + body + }) + if (!message) { + throw new Error('Expected a projected message') + } + return message +} + +function transcript(messages: NativeChatMessage[], sessionId = 'live-codex') { + return ( + + ) +} + +describe('live Codex checklist frames', () => { + it('updates one pinned checklist from journal notifications without rewinding on pagination', () => { + const first = frame(1, 'pending') + const active = frame(2, 'inProgress') + const last = frame(3, 'completed') + const { rerender, container } = render(transcript([first])) + const toggle = screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' }) + const viewport = container.querySelector('.overflow-y-auto')! + expect(viewport.contains(toggle)).toBe(false) + fireEvent.click(toggle) + rerender(transcript([first, active])) + expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass( + 'text-foreground' + ) + expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument() + rerender(transcript([last])) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + expect(screen.getAllByText('Keep verification visible')).toHaveLength(2) + rerender(transcript([first, active, last])) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })).toBe(toggle) + expect(screen.getByText('Started Verify')).toBeInTheDocument() + expect(screen.queryByText('notification:turn/plan/updated')).toBeNull() + expect(projectNativeChatTaskListFrames([last])[0]).toBe( + projectNativeChatTaskListFrames([last])[0] + ) + }) + + it('uses the latest complete snapshot across Codex tool calls and notifications', () => { + const tool: NativeChatMessage = { + id: 'tool', + role: 'assistant', + timestamp: 1, + source: 'transcript', + blocks: [ + { + type: 'tool-call', + name: 'update_plan', + input: { plan: [{ step: 'Verify', status: 'pending' }] } + } + ] + } + render(transcript([tool, frame(3, 'completed')])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' })) + expect(screen.getAllByText('Verify')).toHaveLength(2) + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 1 tasks completed' }).parentElement! + ).getByText('Verify') + ).toHaveClass('line-through') + }) + + it('keeps malformed, truncated, other-provider, and plan-document frames unchanged', () => { + const truncated = frame(1, 'pending', { truncated: true }) + const document = frame(2, 'pending', { kind: 'item:plan' }) + const malformed = frame(3, 'pending') + const otherProvider = frame(4, 'pending') + const malformedBlock = malformed.blocks[0] + const otherBlock = otherProvider.blocks[0] + if (malformedBlock.type === 'text' && malformedBlock.providerFrame) { + malformedBlock.providerFrame.payload.head = '{"plan":null}' + } + if (otherBlock.type === 'text' && otherBlock.providerFrame) { + otherBlock.providerFrame.provider = 'claude' + } + const messages = [truncated, document, malformed, otherProvider] + const projected = projectNativeChatTaskListFrames(messages) + projected.forEach((message, index) => expect(message).toBe(messages[index])) + render(transcript([truncated])) + expect(screen.getByText('notification:turn/plan/updated')).toBeInTheDocument() + expect(screen.queryByText('Tasks')).toBeNull() + }) + + it('does not consume a neighboring tool failure as a notification result', () => { + const command: NativeChatMessage = { + id: 'command', + role: 'assistant', + timestamp: 2, + source: 'transcript', + blocks: [ + { type: 'tool-call', name: 'shell', input: { command: 'verify' }, state: 'failed' }, + { type: 'tool-result', output: 'Verification failed', isError: true } + ] + } + render(transcript([frame(1, 'pending'), command])) + expect(screen.getByRole('button', { name: 'Tasks 0 of 1 tasks completed' })).toBeInTheDocument() + expect(screen.getByText('Verification failed', { selector: 'pre' })).toHaveClass( + 'text-destructive' + ) + }) +}) + +describe('NativeChatMessageList task list history', () => { + it('keeps the latest Claude state after pagination and resets disclosure between sessions', () => { + const first = { + id: 'first-list', + role: 'assistant' as const, + timestamp: 1, + source: 'transcript' as const, + blocks: [ + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'pending' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const last = { + ...first, + id: 'last-list', + timestamp: 3, + blocks: [ + { type: 'text' as const, text: 'Ready for verification' }, + { + type: 'tool-call' as const, + name: 'TodoWrite', + input: { + todos: [ + { content: 'Read', status: 'completed' }, + { content: 'Test', status: 'pending' } + ] + } + } + ] + } + const { rerender } = render(transcript([last])) + fireEvent.click(screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' })) + rerender(transcript([first, last])) + expect(screen.getAllByText('Read')).toHaveLength(2) + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect( + within( + screen.getByRole('button', { name: 'Tasks 1 of 2 tasks completed' }).parentElement! + ).getByText('Read') + ).toHaveClass('line-through') + expect(screen.getByText('Ready for verification')).toBeInTheDocument() + rerender(transcript([first], 'two')) + expect(screen.getByRole('button', { name: 'Tasks 0 of 2 tasks completed' })).toHaveAttribute( + 'aria-expanded', + 'false' + ) + expect(screen.getAllByText('Read')).toHaveLength(1) + rerender(transcript([], 'three')) + expect(screen.queryByText('Tasks')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d8cee7f7e7c..c6da9030a2c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -5,6 +5,10 @@ import { translate } from '@/i18n/i18n' import type { NativeChatLiveSession } from './use-native-chat-live-session' import { createNativeChatMessageListProjection } from './native-chat-message-list-projection' import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll' +import { nativeChatTaskListState } from './native-chat-task-list-state' +import { nativeChatTaskListPredecessors } from './native-chat-task-list-history' +import { NativeChatTaskList } from './NativeChatTaskList' +import { projectNativeChatTaskListFrames } from './native-chat-task-list-frames' import { MessageRow } from './NativeChatMessageRow' import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator' import { NativeChatWorkingStatus } from './NativeChatWorkingStatus' @@ -112,9 +116,11 @@ export function NativeChatMessageList({ [session.agent, session.sessionId] ) const messages = useMemo( - () => projectMessages(session.messages), + () => projectNativeChatTaskListFrames(projectMessages(session.messages)), [projectMessages, session.messages] ) + const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) + const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) const showTypingIndicator = showTurnStatus ? isWorking : shouldShowNativeChatTypingIndicator({ messages, isWorking }) @@ -224,123 +230,140 @@ export function NativeChatMessageList({ }, [handleScroll, scrollToBottom]) return ( -
-
+
+
- {hasMore ? ( -
- -
- ) : null} - {messages.map((message, index) => { - const turnKey = turnKeys[index] - const isCurrentTurn = currentTurnKey - ? turnKey === currentTurnKey - : turnKey === undefined - const status = - index === latestUserIndex - ? turnStatuses.active - : message.role === 'user' && turnKey - ? turnStatuses.completedByTurn[turnKey] - : undefined - const receipt = receipts.get(message.id) - const turnDiff = - turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined - return ( - - {receipt ? ( - - ) : ( - - )} - {showTurnStatus && - status && - (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( - toggleExpandedTurn(turnKey) - : undefined - } - /> - ) : null} - {turnDiff ? ( - - ) : null} - - ) - })} - {showTurnStatus && - latestUserIndex === -1 && - turnStatuses.active && - showTypingIndicator ? ( - - ) : null} - {showTurnStatus && isWorking ? ( - - ) : null} - {!showTurnStatus && showTypingIndicator ? : null} +
+ {hasMore ? ( +
+ +
+ ) : null} + {messages.map((message, index) => { + const turnKey = turnKeys[index] + const isCurrentTurn = currentTurnKey + ? turnKey === currentTurnKey + : turnKey === undefined + const status = + index === latestUserIndex + ? turnStatuses.active + : message.role === 'user' && turnKey + ? turnStatuses.completedByTurn[turnKey] + : undefined + const receipt = receipts.get(message.id) + const turnDiff = + turnKey && turnKeys[index + 1] !== turnKey ? turnDiffs.get(turnKey) : undefined + return ( + + {receipt ? ( + + ) : ( + + )} + {showTurnStatus && + status && + (index !== latestUserIndex || showTypingIndicator || !isWorking) ? ( + toggleExpandedTurn(turnKey) + : undefined + } + /> + ) : null} + {turnDiff ? ( + + ) : null} + + ) + })} + {showTurnStatus && + latestUserIndex === -1 && + turnStatuses.active && + showTypingIndicator ? ( + + ) : null} + {showTurnStatus && isWorking ? ( + + ) : null} + {!showTurnStatus && showTypingIndicator ? : null} +
+ {showJump ? ( + + ) : null}
- {showJump ? ( - + {taskListState.list && taskListState.list.tasks.length > 0 ? ( +
+
+ +
+
) : null}
) diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index e3a4e78efb4..773e6f2c2ff 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -8,7 +8,11 @@ import { isSubagentGroupFallbackText, subagentGroupBlocks } from '../../../../shared/native-chat-subagent-summary' -import { isSubagentGroupBlock, type NativeChatMessage } from '../../../../shared/native-chat-types' +import { + isSubagentGroupBlock, + type NativeChatMessage, + type NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' import { splitNativeChatBlocks } from './native-chat-tool-fold' import { NativeChatToolRun } from './NativeChatToolRun' import { NativeChatNoticeRow } from './NativeChatNoticeRow' @@ -29,6 +33,8 @@ import type { RuntimeFileOperationArgs } from '@/runtime/runtime-file-client' * keep their block identity, so only the changed row re-renders. */ export const MessageRow = memo(function MessageRow({ message, + previousTodoWrite, + previousUpdatePlan, revealedDiff, expandSignal, activeTurnIsWorking, @@ -41,6 +47,8 @@ export const MessageRow = memo(function MessageRow({ runtimeContext }: { message: NativeChatMessage + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal expandSignal: boolean activeTurnIsWorking?: boolean @@ -202,6 +210,8 @@ export const MessageRow = memo(function MessageRow({ {tools.length > 0 || subagentGroups.length > 0 ? ( { + it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => { + const { container } = render() + expect(screen.getByText('Read')).toHaveClass('line-through') + expect(screen.getByText('Writing').closest('li')).toHaveClass('text-foreground') + expect(screen.getByText('Test')).toBeInTheDocument() + expect(screen.getByLabelText('1 of 3 tasks completed')).toHaveTextContent('1/3') + for (const glyph of ['circle', 'circle-dot', 'circle-check']) { + expect(container.querySelector(`.lucide-${glyph}`)).not.toBeNull() + } + expect(screen.getByText('In progress:')).toHaveClass('sr-only') + }) + + it('leads with the diff and expands the complete checklist on demand', () => { + render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Write')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + const disclosure = screen.getByRole('button', { name: 'Full task list' }) + expect(disclosure).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(disclosure) + expect(disclosure).toHaveAttribute('aria-expanded', 'true') + expect(screen.getByText('Writing')).toBeInTheDocument() + expect(screen.getByText('Test')).toBeInTheDocument() + }) + + it('shows unchanged feedback and the current explanation', () => { + render( + + ) + expect(screen.getByText('Tasks unchanged')).toBeInTheDocument() + expect(screen.getByText('Continuing verification')).toBeInTheDocument() + expect(screen.queryByText('Test')).toBeNull() + }) + + it('renders empty lists without claiming any task completed', () => { + render() + expect(screen.getByText('No tasks')).toBeInTheDocument() + expect(screen.getByLabelText('0 of 0 tasks completed')).toHaveTextContent('0/0') + }) + + it('switches from full list to diff when earlier history supplies a predecessor', () => { + const { rerender } = render() + expect(screen.getByText('Test')).toBeInTheDocument() + rerender() + expect(screen.queryByText('Test')).toBeNull() + expect(screen.getByText('Started Write')).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatTaskList.tsx b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx new file mode 100644 index 00000000000..3ddaa8959e8 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx @@ -0,0 +1,183 @@ +import { Circle, CircleCheck, CircleDot, ChevronRight, ListChecks } from 'lucide-react' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + type NativeChatTask, + type NativeChatTaskChange, + type NativeChatTaskList as TaskList +} from '../../../../shared/native-chat-task-list' + +function statusLabel(task: NativeChatTask): string { + if (task.status === 'completed') { + return translate('components.native-chat.taskList.completed', 'Completed') + } + if (task.status === 'in_progress') { + return translate('components.native-chat.taskList.inProgress', 'In progress') + } + return translate('components.native-chat.taskList.pending', 'Pending') +} + +function changeLabel(change: NativeChatTaskChange): string { + const values = { task: change.task.content } + switch (change.kind) { + case 'added': + return translate('components.native-chat.taskList.added', 'Added {{task}}', values) + case 'removed': + return translate('components.native-chat.taskList.removed', 'Removed {{task}}', values) + case 'started': + return translate('components.native-chat.taskList.started', 'Started {{task}}', values) + case 'completed': + return translate('components.native-chat.taskList.finished', 'Completed {{task}}', values) + case 'pending': + return translate('components.native-chat.taskList.reset', 'Marked pending: {{task}}', values) + case 'updated': + return translate('components.native-chat.taskList.updated', 'Updated {{task}}', { + task: nativeChatTaskLabel(change.task) + }) + } +} + +function TaskRow({ task, label }: { task: NativeChatTask; label?: string }): React.JSX.Element { + const Icon = + task.status === 'completed' ? CircleCheck : task.status === 'in_progress' ? CircleDot : Circle + return ( +
  • + + {statusLabel(task)}: + + {label ?? nativeChatTaskLabel(task)} + +
  • + ) +} + +function Checklist({ list }: { list: TaskList }): React.JSX.Element { + return list.tasks.length === 0 ? ( +

    + {translate('components.native-chat.taskList.empty', 'No tasks')} +

    + ) : ( +
      + {list.tasks.map((task, index) => ( + + ))} +
    + ) +} + +export function NativeChatTaskList({ + list, + previous, + presentation = 'inline' +}: { + list: TaskList + previous?: TaskList + presentation?: 'inline' | 'composer' +}): React.JSX.Element { + const completed = list.tasks.filter((task) => task.status === 'completed').length + if (presentation === 'composer') { + return ( + + + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + + + + +
    + + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    +
    +
    + ) + } + const changes = previous ? diffNativeChatTaskLists(previous, list) : null + return ( +
    +
    + + + {translate('components.native-chat.taskList.title', 'Tasks')} + + + {completed}/{list.tasks.length} + +
    + {changes ? ( + <> + {changes.length > 0 ? ( +
      + {changes.map((change, index) => ( + + ))} +
    + ) : ( +

    + {translate('components.native-chat.taskList.unchanged', 'Tasks unchanged')} +

    + )} + + + + {translate('components.native-chat.taskList.showAll', 'Full task list')} + + + + + + + ) : ( + + )} + {list.explanation ? ( +

    + {list.explanation} +

    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx index cd819c5ced1..f38f63df4c6 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -730,3 +730,58 @@ describe('NativeChatToolRun', () => { expect(screen.getByTitle('ls')).toHaveTextContent('ls') }) }) + +describe('NativeChatToolRun task lists', () => { + it('renders task updates instead of JSON and consumes successful results', () => { + const blocks: NativeChatBlock[] = [ + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'in_progress' }, + { step: 'Test', status: 'pending' } + ] + } + }, + { type: 'tool-result', output: 'Plan updated' }, + { + type: 'tool-call', + name: 'update_plan', + input: { + plan: [ + { step: 'Read', status: 'completed' }, + { step: 'Test', status: 'in_progress' } + ] + } + } + ] + const { container } = render() + expect(screen.getByText('Completed Read')).toBeInTheDocument() + expect(screen.getByText('Started Test')).toBeInTheDocument() + expect(screen.getByText('1/2')).toBeInTheDocument() + expect(screen.queryByText('Plan updated')).toBeNull() + expect(container.querySelector('pre')).toBeNull() + }) + + it('keeps malformed calls and failed results visible in the generic view', () => { + render( + + ) + expect(screen.getByText('Invalid arguments', { selector: 'pre' })).toBeInTheDocument() + expect(screen.getByText('Update rejected', { selector: 'pre' })).toBeInTheDocument() + expect(screen.queryByText('1/1')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx index 26ff8f40d6e..69f79c4f907 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -31,6 +31,9 @@ import { selectActiveToolCall } from '../../../../shared/native-chat-tool-activity' import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon' +import type { NativeChatToolCallBlock } from '../../../../shared/native-chat-types' +import { NativeChatTaskList } from './NativeChatTaskList' +import { buildNativeChatTaskListRows } from './native-chat-task-list-history' import { NativeChatDiffView } from './NativeChatDiffView' import { NativeChatSubagentRun } from './NativeChatSubagentRun' import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon' @@ -158,6 +161,8 @@ function ToolLine({ * toolbar toggle drive every run at once while still allowing per-run override. */ export function NativeChatToolRun({ blocks, + previousTodoWrite, + previousUpdatePlan, revealedDiff, onRevealDiff, subagentGroups = NO_SUBAGENT_GROUPS, @@ -168,6 +173,8 @@ export function NativeChatToolRun({ onLinkClick }: { blocks: NativeChatBlock[] + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock revealedDiff?: NativeChatDiffReveal onRevealDiff?: (element: HTMLElement) => void /** Spawn-group rosters that belong with this run's activity, one row each. */ @@ -231,6 +238,18 @@ export function NativeChatToolRun({ // The turn caret opens the activity group, while each child tool remains // collapsed. The global expand toolbar still opens child details together. const expandToolLines = expandOverride === undefined ? open : false + // Diffing every edit is the run's most expensive work, so a collapsed run — + // which renders none of it — never pays for it. + const taskLists = useMemo( + () => + open + ? buildNativeChatTaskListRows(blocks, { + todowrite: previousTodoWrite, + update_plan: previousUpdatePlan + }) + : null, + [open, blocks, previousTodoWrite, previousUpdatePlan] + ) // Rollups cache counts only; detailed diff rows are built when the run opens. const { editCards, consumedResults } = useMemo( () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), @@ -381,7 +400,14 @@ export function NativeChatToolRun({
    {(() => { const seen = new Map() - return blocks.map((block) => { + return blocks.map((block, blockIndex) => { + const taskList = taskLists?.rows.get(block) + if (taskList) { + return + } + if (taskLists?.consumedResults.has(block)) { + return null + } const edit = editCards.get(block) if (edit) { return ( diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts new file mode 100644 index 00000000000..f58c4a2bbdd --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-frames.ts @@ -0,0 +1,36 @@ +import { normalizeNativeChatTaskList } from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' + +const projectedFrames = new WeakMap() + +/** Project after tool folding so a notification never takes another call's result. */ +export function projectNativeChatTaskListFrames( + messages: readonly NativeChatMessage[] +): NativeChatMessage[] { + return messages.map((message) => { + const cached = projectedFrames.get(message) + if (cached) { + return cached + } + const block = message.blocks.length === 1 ? message.blocks[0] : undefined + const frame = block?.type === 'text' ? block.providerFrame : undefined + if ( + message.role !== 'system' || + frame?.provider !== 'codex' || + frame.kind !== 'notification:turn/plan/updated' || + frame.payload.truncated || + !normalizeNativeChatTaskList('update_plan', frame.payload.head) + ) { + return message + } + const projected: NativeChatMessage = { + ...message, + role: 'assistant', + blocks: [ + { type: 'tool-call', name: 'update_plan', input: frame.payload.head, state: 'completed' } + ] + } + projectedFrames.set(message, projected) + return projected + }) +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts new file mode 100644 index 00000000000..ef8eae291ad --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { + buildNativeChatTaskListRows, + nativeChatTaskListPredecessors +} from './native-chat-task-list-history' + +function call(name = 'TodoWrite', status = 'pending'): NativeChatToolCallBlock { + return { + type: 'tool-call', + name, + input: + name === 'TodoWrite' + ? { todos: [{ content: 'Test', status }] } + : { plan: [{ step: 'Test', status }] } + } +} +function message( + id: string, + blocks: NativeChatBlock[], + role: NativeChatMessage['role'] = 'assistant' +): NativeChatMessage { + return { id, blocks, role, timestamp: 1, source: 'transcript' } +} + +describe('native chat task list history', () => { + it('carries predecessors across prose, ordinary tools, and user turns', () => { + const first = call() + const next = call('TodoWrite', 'completed') + const history = nativeChatTaskListPredecessors([ + message('a', [first]), + message('b', [{ type: 'text', text: 'Continue' }], 'user'), + message('c', [{ type: 'tool-call', name: 'Read', input: {} }]), + message('d', [next]) + ]) + expect(history.get('d')?.todowrite).toBe(first) + expect( + buildNativeChatTaskListRows([next], history.get('d')).rows.get(next)?.previous?.tasks[0] + .status + ).toBe('pending') + }) + + it('keeps interleaved tool families separate and ignores MCP lookalikes', () => { + const claude = call() + const codex = call('update_plan') + const next = call('TodoWrite', 'completed') + const model = buildNativeChatTaskListRows([claude, codex, call('mcp__x__TodoWrite'), next]) + expect(model.rows.get(codex)?.previous).toBeUndefined() + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(claude)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', [claude]), + message('b', [codex]), + message('c', [next]) + ]) + expect(history.get('c')).toEqual({ todowrite: claude, update_plan: codex }) + }) + + it('skips failed and malformed calls and keeps errors unconsumed', () => { + const first = call() + const failed = { ...call(), state: 'failed' as const } + const rejected = call('TodoWrite', 'completed') + const error: NativeChatBlock = { type: 'tool-result', output: 'Rejected', isError: true } + const next = call('TodoWrite', 'in_progress') + const blocks: NativeChatBlock[] = [ + first, + { type: 'tool-result', output: 'ok' }, + failed, + { type: 'tool-result', output: 'failed' }, + rejected, + error, + { ...call(), input: '{' }, + next + ] + const model = buildNativeChatTaskListRows(blocks) + expect(model.rows.has(failed)).toBe(false) + expect(model.rows.has(rejected)).toBe(false) + expect(model.consumedResults.has(error)).toBe(false) + expect(model.rows.get(next)?.previous).toEqual(model.rows.get(first)?.list) + const history = nativeChatTaskListPredecessors([ + message('a', blocks.slice(0, -1)), + message('b', [next]) + ]) + expect(history.get('b')?.todowrite).toBe(first) + }) + + it('updates predecessor identity after pagination and remains stable on rerender', () => { + const first = call() + const second = call('TodoWrite', 'in_progress') + const tail = message('b', [second]) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + const history = nativeChatTaskListPredecessors([message('a', [first]), tail]) + expect(history.get('b')?.todowrite).toBe(first) + expect(nativeChatTaskListPredecessors([message('a', [first]), tail]).get('b')?.todowrite).toBe( + history.get('b')?.todowrite + ) + expect(nativeChatTaskListPredecessors([tail]).get('b')?.todowrite).toBeUndefined() + }) + + it('diffs a running call before its result arrives and consumes a successful result', () => { + const first = call() + const running = { ...call('TodoWrite', 'in_progress'), state: 'running' as const } + const result: NativeChatBlock = { type: 'tool-result', output: 'ok' } + const model = buildNativeChatTaskListRows([running, result], { + todowrite: first, + update_plan: undefined + }) + expect(model.rows.get(running)?.previous).toBeDefined() + expect(model.consumedResults.has(result)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-history.ts b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts new file mode 100644 index 00000000000..62582e2a7cf --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-history.ts @@ -0,0 +1,83 @@ +import { + nativeChatTaskListTool, + normalizeNativeChatTaskList, + type NativeChatTaskList, + type NativeChatTaskListTool +} from '../../../../shared/native-chat-task-list' +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatToolCallBlock +} from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +export type NativeChatTaskListPredecessors = Partial< + Record +> +export type NativeChatTaskListRow = { list: NativeChatTaskList; previous?: NativeChatTaskList } + +function taskListFromCall(call: NativeChatToolCallBlock): NativeChatTaskList | null { + return call.state === 'failed' ? null : normalizeNativeChatTaskList(call.name, call.input) +} + +/** Store call identities so unchanged rows stay memoized, while prepends replace their context. */ +export function nativeChatTaskListPredecessors( + messages: readonly NativeChatMessage[] +): Map { + const history = new Map() + const previous: NativeChatTaskListPredecessors = {} + for (const message of messages) { + history.set(message.id, { ...previous }) + if (message.role === 'user') { + continue + } + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + if (tool && taskListFromCall(call)) { + previous[tool] = call + } + } + } + return history +} + +export function buildNativeChatTaskListRows( + blocks: readonly NativeChatBlock[], + predecessors: NativeChatTaskListPredecessors = {} +): { + rows: Map + consumedResults: Set +} { + const rows = new Map() + const consumedResults = new Set() + const previous = new Map() + for (const call of Object.values(predecessors)) { + if (!call) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (tool && list) { + previous.set(tool, list) + } + } + for (const { call, result } of pairToolBlocks(blocks)) { + if (!call || result?.isError) { + continue + } + const tool = nativeChatTaskListTool(call.name) + const list = taskListFromCall(call) + if (!tool || !list) { + continue + } + rows.set(call, { list, previous: previous.get(tool) }) + previous.set(tool, list) + if (result) { + consumedResults.add(result) + } + } + return { rows, consumedResults } +} diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts new file mode 100644 index 00000000000..c282c4d4532 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatBlock, NativeChatMessage } from '../../../../shared/native-chat-types' +import { nativeChatTaskListState } from './native-chat-task-list-state' + +function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { + return { id, role: 'assistant', timestamp: 1, source: 'transcript', blocks } +} +function call(content: string, status = 'pending'): NativeChatBlock { + return { type: 'tool-call', name: 'TodoWrite', input: { todos: [{ content, status }] } } +} + +describe('nativeChatTaskListState', () => { + it('projects one latest snapshot, preserves prose and leaves source messages unchanged', () => { + const first = message('first', [call('Read')]) + const last = message('last', [ + { type: 'text', text: 'Here is the result' }, + call('Read', 'completed'), + { type: 'tool-result', output: 'Updated todos' } + ]) + const result = nativeChatTaskListState([first, last]) + expect(result.list?.tasks).toEqual([{ content: 'Read', status: 'completed' }]) + expect(result.messages[0]).toBe(first) + expect(result.messages[1]).toBe(last) + expect(first.blocks).toHaveLength(1) + expect(last.blocks).toHaveLength(3) + expect(nativeChatTaskListState([first, last]).messages[1]).toBe(result.messages[1]) + }) + + it('preserves latest state across user follow-ups and clears it on an explicit empty list', () => { + const first = message('first', [call('Read')]) + const user = { ...message('user', [{ type: 'text', text: 'Continue' }]), role: 'user' as const } + const empty = message('empty', [{ type: 'tool-call', name: 'TodoWrite', input: { todos: [] } }]) + expect(nativeChatTaskListState([first, user]).list?.tasks).toHaveLength(1) + expect(nativeChatTaskListState([first, user, empty]).list?.tasks).toEqual([]) + expect(nativeChatTaskListState([]).list).toBeNull() + }) + + it('does not replace valid state with malformed or failed calls, and retains their diagnostics', () => { + const first = message('first', [call('Read')]) + const malformed = message('malformed', [ + { type: 'tool-call', name: 'TodoWrite', input: '{' }, + { type: 'tool-result', output: 'Invalid arguments', isError: true } + ]) + const failed = message('failed', [ + call('Wrong', 'completed'), + { type: 'tool-result', output: 'Update rejected', isError: true } + ]) + const failedCall = message('failed-call', [ + { type: 'tool-call', name: 'TodoWrite', state: 'failed', input: { todos: [] } } + ]) + const result = nativeChatTaskListState([first, malformed, failed, failedCall]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages.slice(1)).toEqual([malformed, failed, failedCall]) + }) + + it('retains task history and unrelated errors while selecting the paired snapshot', () => { + const tasks: NativeChatBlock = call('Read') + const shell: NativeChatBlock = { + type: 'tool-call', + name: 'shell', + input: {} + } + const error: NativeChatBlock = { + type: 'tool-result', + output: 'Failed', + isError: true + } + const success: NativeChatBlock = { type: 'tool-result', output: 'Updated' } + const result = nativeChatTaskListState([message('mixed', [tasks, success, shell, error])]) + expect(result.list?.tasks[0].content).toBe('Read') + expect(result.messages[0].blocks).toEqual([tasks, success, shell, error]) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-task-list-state.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts new file mode 100644 index 00000000000..f45d6531600 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts @@ -0,0 +1,43 @@ +import { + normalizeNativeChatTaskList, + type NativeChatTaskList +} from '../../../../shared/native-chat-task-list' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { pairToolBlocks } from './native-chat-tool-fold' + +const snapshots = new WeakMap() + +function latestSnapshot(message: NativeChatMessage): NativeChatTaskList | null { + if (snapshots.has(message)) { + return snapshots.get(message) ?? null + } + let list: NativeChatTaskList | null = null + if (message.role === 'assistant') { + for (const { call, result } of pairToolBlocks(message.blocks)) { + if (!call || call.state === 'failed' || result?.isError) { + continue + } + const snapshot = normalizeNativeChatTaskList(call.name, call.input) + if (snapshot) { + list = snapshot + } + } + } + snapshots.set(message, list) + return list +} + +/** Select composer progress without consuming historical transcript updates. */ +export function nativeChatTaskListState(messages: readonly NativeChatMessage[]): { + messages: readonly NativeChatMessage[] + list: NativeChatTaskList | null +} { + let list: NativeChatTaskList | null = null + for (const message of messages) { + const snapshot = latestSnapshot(message) + if (snapshot) { + list = snapshot + } + } + return { messages, list } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f33a2d9745..4f283dc4f47 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16994,6 +16994,22 @@ "empty": "No users found" }, "native-chat": { + "taskList": { + "title": "Tasks", + "completed": "Completed", + "inProgress": "In progress", + "pending": "Pending", + "empty": "No tasks", + "progress": "{{completed}} of {{total}} tasks completed", + "added": "Added {{task}}", + "removed": "Removed {{task}}", + "started": "Started {{task}}", + "finished": "Completed {{task}}", + "reset": "Marked pending: {{task}}", + "updated": "Updated {{task}}", + "unchanged": "Tasks unchanged", + "showAll": "Full task list" + }, "turnDiff": { "one": "1 changed file", "many": "{{count}} changed files", diff --git a/src/shared/native-chat-task-list.test.ts b/src/shared/native-chat-task-list.test.ts new file mode 100644 index 00000000000..0bbb7077f3c --- /dev/null +++ b/src/shared/native-chat-task-list.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest' +import { + diffNativeChatTaskLists, + nativeChatTaskLabel, + normalizeNativeChatTaskList, + type NativeChatTask, + type NativeChatTaskList +} from './native-chat-task-list' + +const task = (content: string, status: NativeChatTask['status'] = 'pending'): NativeChatTask => ({ + content, + status +}) +const list = (...tasks: NativeChatTask[]): NativeChatTaskList => ({ tasks }) + +describe('normalizeNativeChatTaskList', () => { + it('normalizes Claude tasks and uses activeForm only while in progress', () => { + const result = normalizeNativeChatTaskList('TodoWrite', { + todos: [ + { content: 'Read', status: 'completed', activeForm: 'Reading' }, + { content: 'Write', status: 'in_progress', activeForm: 'Writing' }, + { content: 'Test', status: 'pending', activeForm: 'Testing' } + ] + })! + expect(result.tasks.map(nativeChatTaskLabel)).toEqual(['Read', 'Writing', 'Test']) + expect(result.tasks.map((entry) => entry.status)).toEqual([ + 'completed', + 'in_progress', + 'pending' + ]) + }) + + it('normalizes Codex JSON-string arguments and explanation', () => { + expect( + normalizeNativeChatTaskList( + 'update_plan', + JSON.stringify({ + explanation: 'Proceed with verification', + plan: [{ step: 'Test', status: 'in_progress' }] + }) + ) + ).toEqual({ explanation: 'Proceed with verification', tasks: [task('Test', 'in_progress')] }) + }) + + it('defaults unknown/missing statuses and ignores invalid entries', () => { + expect( + normalizeNativeChatTaskList(' TodoWrite ', { + todos: [ + null, + [], + 4, + {}, + { content: ' ' }, + { content: 7 }, + { content: ' One ', status: 'unknown', activeForm: 4 }, + { content: 'Two' } + ] + }) + ).toEqual(list(task('One'), task('Two'))) + }) + + it.each([undefined, null, 42, [], '{', '{}', { todos: null }, { todos: [{}] }])( + 'returns null for malformed input %j', + (input) => { + expect(normalizeNativeChatTaskList('TodoWrite', input)).toBeNull() + } + ) + + it('keeps empty lists valid and recognizes only exact tool families', () => { + expect(normalizeNativeChatTaskList('update_plan', { plan: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('TodoWrite', { todos: [] })).toEqual(list()) + expect(normalizeNativeChatTaskList('mcp__server__TodoWrite', { todos: [] })).toBeNull() + expect(normalizeNativeChatTaskList('ExitPlanMode', { plan: [] })).toBeNull() + expect(normalizeNativeChatTaskList('update_plan', { todos: [] })).toBeNull() + }) +}) + +describe('diffNativeChatTaskLists', () => { + it('reports completions and starts, omitting unchanged tasks', () => { + expect( + diffNativeChatTaskLists( + list(task('Read', 'in_progress'), task('Write'), task('Test')), + list(task('Read', 'completed'), task('Write', 'in_progress'), task('Test')) + ) + ).toEqual([ + { kind: 'completed', task: task('Read', 'completed') }, + { kind: 'started', task: task('Write', 'in_progress') } + ]) + }) + + it('ignores reorder-only updates and explanation changes', () => { + expect( + diffNativeChatTaskLists(list(task('A'), task('B')), { + tasks: [task('B'), task('A')], + explanation: 'Reordered' + }) + ).toEqual([]) + }) + + it('matches duplicate contents by occurrence', () => { + expect( + diffNativeChatTaskLists( + list(task('A'), task('A', 'in_progress')), + list(task('A', 'completed'), task('A', 'in_progress')) + ) + ).toEqual([{ kind: 'completed', task: task('A', 'completed') }]) + }) + + it('reports renamed content as an addition and removal', () => { + expect(diffNativeChatTaskLists(list(task('Old')), list(task('New')))).toEqual([ + { kind: 'added', task: task('New') }, + { kind: 'removed', task: task('Old') } + ]) + }) + + it('reports resets, reopening, and activeForm-only edits', () => { + const changed = { ...task('C', 'in_progress'), activeForm: 'Checking C' } + expect( + diffNativeChatTaskLists( + list(task('A', 'completed'), task('B', 'completed'), task('C', 'in_progress')), + list(task('A'), task('B', 'in_progress'), changed) + ) + ).toEqual([ + { kind: 'pending', task: task('A') }, + { kind: 'started', task: task('B', 'in_progress') }, + { kind: 'updated', task: changed } + ]) + }) + + it('reports clearing a list and removing a duplicate', () => { + expect(diffNativeChatTaskLists(list(task('A')), list())).toEqual([ + { kind: 'removed', task: task('A') } + ]) + expect(diffNativeChatTaskLists(list(task('A'), task('A')), list(task('A')))).toEqual([ + { kind: 'removed', task: task('A') } + ]) + }) +}) diff --git a/src/shared/native-chat-task-list.ts b/src/shared/native-chat-task-list.ts new file mode 100644 index 00000000000..4d4d470b246 --- /dev/null +++ b/src/shared/native-chat-task-list.ts @@ -0,0 +1,122 @@ +export type NativeChatTaskStatus = 'pending' | 'in_progress' | 'completed' +export type NativeChatTask = { + content: string + status: NativeChatTaskStatus + activeForm?: string +} +export type NativeChatTaskList = { tasks: NativeChatTask[]; explanation?: string } +export type NativeChatTaskChange = { + kind: 'added' | 'removed' | 'started' | 'completed' | 'pending' | 'updated' + task: NativeChatTask +} +export type NativeChatTaskListTool = 'todowrite' | 'update_plan' + +export function nativeChatTaskListTool(name: string): NativeChatTaskListTool | null { + const normalized = name.trim().toLowerCase() + return normalized === 'todowrite' || normalized === 'update_plan' ? normalized : null +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +function nonemptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +export function normalizeNativeChatTaskList( + name: string, + input: unknown +): NativeChatTaskList | null { + const tool = nativeChatTaskListTool(name) + if (!tool) { + return null + } + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + return null + } + } + const value = record(input) + const entries = tool === 'todowrite' ? value?.todos : value?.plan + if (!Array.isArray(entries)) { + return null + } + const tasks: NativeChatTask[] = [] + for (const entry of entries) { + const item = record(entry) + const content = nonemptyString(tool === 'todowrite' ? item?.content : item?.step) + if (!item || !content) { + continue + } + const status = + item.status === 'in_progress' || (tool === 'update_plan' && item.status === 'inProgress') + ? 'in_progress' + : item.status === 'completed' + ? 'completed' + : 'pending' + const activeForm = tool === 'todowrite' ? nonemptyString(item.activeForm) : undefined + tasks.push({ content, status, ...(activeForm ? { activeForm } : {}) }) + } + if (entries.length > 0 && tasks.length === 0) { + return null + } + const explanation = tool === 'update_plan' ? nonemptyString(value?.explanation) : undefined + return { tasks, ...(explanation ? { explanation } : {}) } +} + +export function nativeChatTaskLabel(task: NativeChatTask): string { + return task.status === 'in_progress' && task.activeForm ? task.activeForm : task.content +} + +/** Content plus occurrence is the only identity the providers give these entries. */ +export function diffNativeChatTaskLists( + previous: NativeChatTaskList, + current: NativeChatTaskList +): NativeChatTaskChange[] { + const byContent = new Map() + for (const task of previous.tasks) { + const matches = byContent.get(task.content) + if (matches) { + matches.push(task) + } else { + byContent.set(task.content, [task]) + } + } + const occurrences = new Map() + const consumed = new Set() + const changes: NativeChatTaskChange[] = [] + for (const task of current.tasks) { + const occurrence = occurrences.get(task.content) ?? 0 + occurrences.set(task.content, occurrence + 1) + const before = byContent.get(task.content)?.[occurrence] + if (!before) { + changes.push({ kind: 'added', task }) + continue + } + consumed.add(before) + if (before.status !== task.status) { + changes.push({ + kind: + task.status === 'completed' + ? 'completed' + : task.status === 'in_progress' + ? 'started' + : 'pending', + task + }) + } else if (before.activeForm !== task.activeForm) { + changes.push({ kind: 'updated', task }) + } + } + for (const task of previous.tasks) { + if (!consumed.has(task)) { + changes.push({ kind: 'removed', task }) + } + } + return changes +} diff --git a/src/shared/native-chat-tool-icon.test.ts b/src/shared/native-chat-tool-icon.test.ts index 695400c94ca..f8cabed950c 100644 --- a/src/shared/native-chat-tool-icon.test.ts +++ b/src/shared/native-chat-tool-icon.test.ts @@ -50,6 +50,9 @@ describe('native chat tool icons', () => { expect(nativeChatToolCategory('list')).toBe('listFiles') expect(nativeChatToolCategory('shell')).toBe('unknown') expect(nativeChatToolCategory('apply_patch')).toBe('fileChange') + expect(nativeChatToolCategory('update_plan')).toBe('todoList') + expect(nativeChatToolIconName('update_plan')).toBe('list-checks') + expect(nativeChatToolRunIconName([{ name: 'update_plan' }])).toBe('list-checks') expect(nativeChatToolCategory('web search')).toBe('webSearch') }) diff --git a/src/shared/native-chat-tool-icon.ts b/src/shared/native-chat-tool-icon.ts index d546a76e865..d52df9e52e9 100644 --- a/src/shared/native-chat-tool-icon.ts +++ b/src/shared/native-chat-tool-icon.ts @@ -81,6 +81,7 @@ const CATEGORY_BY_ROW_WORD = new Map([ ['task', 'subAgentActivity'], ['webfetch', 'webSearch'], ['todowrite', 'todoList'], + ['update_plan', 'todoList'], ['web search', 'webSearch'], ['websearch', 'webSearch'], ['web_search', 'webSearch']