)
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']