diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx index d09b0a05750..0bc6810ee78 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.tsx @@ -8,6 +8,7 @@ import { stripNoiseMessages } from './native-chat-noise' import { foldToolMessages } from './native-chat-tool-fold' 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' @@ -90,6 +91,7 @@ export function NativeChatMessageList({ ), [session.messages] ) + const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages]) const taskListState = useMemo(() => nativeChatTaskListState(messages), [messages]) const showTypingIndicator = showTurnStatus ? isWorking @@ -239,6 +241,8 @@ export function NativeChatMessageList({ 0 || subagentGroups.length > 0 ? ( { - it('starts as quiet chrome and reveals tri-state tasks on demand', () => { + it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => { const { container } = render() - const toggle = screen.getByRole('button', { name: 'Tasks 1 of 3 tasks completed' }) - expect(toggle).toHaveAttribute('aria-expanded', 'false') - expect(screen.queryByText('Test')).toBeNull() - fireEvent.click(toggle) 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() @@ -37,18 +34,42 @@ describe('NativeChatTaskList', () => { expect(screen.getByText('In progress:')).toHaveClass('sr-only') }) - it('updates the same expanded list without appending a change feed', () => { - const { rerender } = render() - const toggle = screen.getByRole('button', { name: 'Tasks 0 of 3 tasks completed' }) - fireEvent.click(toggle) - rerender() - expect(screen.getByRole('button', { name: 'Tasks 1 of 3 tasks completed' })).toBe(toggle) - expect(toggle).toHaveAttribute('aria-expanded', 'true') - expect(screen.getAllByRole('list')).toHaveLength(1) - expect(screen.getByText('Read')).toHaveClass('line-through') + 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.queryByText('Completed Read')).toBeNull() - expect(screen.queryByText('Started Write')).toBeNull() + 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 index 408bd55bbf9..e91845189a3 100644 --- a/src/renderer/src/components/native-chat/NativeChatTaskList.tsx +++ b/src/renderer/src/components/native-chat/NativeChatTaskList.tsx @@ -3,8 +3,10 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component 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' @@ -18,7 +20,27 @@ function statusLabel(task: NativeChatTask): string { return translate('components.native-chat.taskList.pending', 'Pending') } -function TaskRow({ task }: { task: NativeChatTask }): React.JSX.Element { +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 ( @@ -33,10 +55,10 @@ function TaskRow({ task }: { task: NativeChatTask }): React.JSX.Element { - {nativeChatTaskLabel(task)} + {label ?? nativeChatTaskLabel(task)} ) @@ -59,13 +81,20 @@ function Checklist({ list }: { list: TaskList }): React.JSX.Element { ) } -export function NativeChatTaskList({ list }: { list: TaskList }): React.JSX.Element { +export function NativeChatTaskList({ + list, + previous +}: { + list: TaskList + previous?: TaskList +}): React.JSX.Element { const completed = list.tasks.filter((task) => task.status === 'completed').length + const changes = previous ? diffNativeChatTaskLists(previous, list) : null return ( - - +
+
- + {translate('components.native-chat.taskList.title', 'Tasks')} {completed}/{list.tasks.length} - - - -
- - {list.explanation ? ( -

- {list.explanation} +

+ {changes ? ( + <> + {changes.length > 0 ? ( +
    + {changes.map((change, index) => ( + + ))} +
+ ) : ( +

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

- ) : null} -
- - + )} + + + + {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 da9202254c0..625d53933b4 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx @@ -616,3 +616,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 5935add6932..8f40e4ca81a 100644 --- a/src/renderer/src/components/native-chat/NativeChatToolRun.tsx +++ b/src/renderer/src/components/native-chat/NativeChatToolRun.tsx @@ -34,6 +34,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' @@ -205,6 +208,8 @@ function buildEditCards(blocks: NativeChatBlock[]): EditCardModel { * toolbar toggle drive every run at once while still allowing per-run override. */ export function NativeChatToolRun({ blocks, + previousTodoWrite, + previousUpdatePlan, subagentGroups = NO_SUBAGENT_GROUPS, expandSignal, activeTurnIsWorking, @@ -213,6 +218,8 @@ export function NativeChatToolRun({ onLinkClick }: { blocks: NativeChatBlock[] + previousTodoWrite?: NativeChatToolCallBlock + previousUpdatePlan?: NativeChatToolCallBlock /** Spawn-group rosters that belong with this run's activity, one row each. */ subagentGroups?: NativeChatSubagentGroupBlock[] /** Toolbar-driven desired open state. Each change re-syncs this run's state. */ @@ -247,6 +254,7 @@ export function NativeChatToolRun({ 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]) const { editCards, consumedResults } = useMemo( () => (open ? buildEditCards(blocks) : NO_EDIT_CARDS), [open, blocks] @@ -349,7 +357,10 @@ 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-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.ts b/src/renderer/src/components/native-chat/native-chat-task-list-state.ts index a2af1593f80..31a9c0b301d 100644 --- 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 @@ -52,13 +52,9 @@ export function nativeChatTaskListState(messages: readonly NativeChatMessage[]): list: NativeChatTaskList | null } { let list: NativeChatTaskList | null = null - const transcript: NativeChatMessage[] = [] for (const message of messages) { const projection = projectMessage(message) - if (projection.list) { - list = projection.list - } - transcript.push(projection.message) + if (projection.list) list = projection.list } - return { messages: transcript, list } + return { messages: [...messages], list } } diff --git a/src/shared/native-chat-task-list.test.ts b/src/shared/native-chat-task-list.test.ts index ff385d8e80d..0bbb7077f3c 100644 --- a/src/shared/native-chat-task-list.test.ts +++ b/src/shared/native-chat-task-list.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { + diffNativeChatTaskLists, nativeChatTaskLabel, normalizeNativeChatTaskList, type NativeChatTask, @@ -73,3 +74,65 @@ describe('normalizeNativeChatTaskList', () => { 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 index eb65a371c88..4d4d470b246 100644 --- a/src/shared/native-chat-task-list.ts +++ b/src/shared/native-chat-task-list.ts @@ -5,6 +5,10 @@ export type NativeChatTask = { 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 { @@ -68,3 +72,51 @@ export function normalizeNativeChatTaskList( 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 +}