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 <sim@local>
This commit is contained in:
Brennan Benson
2026-09-08 23:50:32 -07:00
committed by GitHub
co-authored by Merge Sim
parent addd9f3da7
commit d15a6df224
18 changed files with 1352 additions and 116 deletions
@@ -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',
@@ -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 (
<NativeChatMessageList
session={{
messages,
status: 'ready',
sessionId,
agent: 'codex',
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn(),
readPhase: 'ready'
}}
isWorking={false}
expandSignal
fontScale={1}
showTurnStatus={false}
/>
)
}
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()
})
})
@@ -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 (
<div className="relative min-h-0 flex-1">
<div
ref={scrollRef}
onScroll={handleScroll}
className="scrollbar-sleek h-full overflow-y-auto [scrollbar-gutter:stable_both-edges] px-3 pt-10 pb-4 sm:px-4"
>
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
<div
ref={contentRef}
// Why: matches composer column (max-w-4xl) with 5px horizontal inset
// on each side so content is slightly narrower than the input box.
className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-[5px]"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this container so the rest of the app is untouched. It's
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
style={{ zoom: fontScale }}
ref={scrollRef}
onScroll={handleScroll}
className="scrollbar-sleek h-full overflow-y-auto [scrollbar-gutter:stable_both-edges] px-3 pt-10 pb-4 sm:px-4"
>
{hasMore ? (
<div className="flex justify-center py-1">
<button
type="button"
onClick={loadEarlier}
disabled={loadingEarlier}
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
{loadingEarlier
? translate('components.native-chat.loadingEarlier', 'Loading…')
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
</button>
</div>
) : 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 (
<Fragment key={message.id}>
{receipt ? (
<NativeChatResolutionReceipt body={receipt} />
) : (
<MessageRow
message={message}
revealedDiff={revealedDiff?.messageId === message.id ? revealedDiff : undefined}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
// ended. Structured sessions and legacy live hooks still expose
// the authoritative session-level working state.
activeTurnIsWorking={
showTurnStatus &&
isCurrentTurn &&
(isWorking || session.transcriptLifecycle?.state === 'working')
}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
structuredActivityUi={showTurnStatus}
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
runtimeContext={runtimeContext}
/>
)}
{showTurnStatus &&
status &&
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
thinking={status.thinking}
workedSeconds={status.workedSeconds}
expanded={turnKey ? expandedTurnIds.has(turnKey) : false}
onToggleExpanded={
status.workedSeconds != null && turnKey
? () => toggleExpandedTurn(turnKey)
: undefined
}
/>
) : null}
{turnDiff ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={revealDiff} />
) : null}
</Fragment>
)
})}
{showTurnStatus &&
latestUserIndex === -1 &&
turnStatuses.active &&
showTypingIndicator ? (
<NativeChatWorkingStatus
startedAt={turnStatuses.active.startedAt}
thinking={turnStatuses.active.thinking}
workedSeconds={turnStatuses.active.workedSeconds}
/>
) : null}
{showTurnStatus && isWorking ? (
<NativeChatTurnActivityLine activity={turnActivity} />
) : null}
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
<div
ref={contentRef}
// Why: matches composer column (max-w-4xl) with 5px horizontal inset
// on each side so content is slightly narrower than the input box.
className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-[5px]"
// Why: `zoom` scales the chat transcript's text and layout together,
// scoped to this container so the rest of the app is untouched. It's
// the desktop analog of the mobile pinch-zoom (Chromium/Electron only).
style={{ zoom: fontScale }}
>
{hasMore ? (
<div className="flex justify-center py-1">
<button
type="button"
onClick={loadEarlier}
disabled={loadingEarlier}
className="rounded-md px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
>
{loadingEarlier
? translate('components.native-chat.loadingEarlier', 'Loading…')
: translate('components.native-chat.loadEarlier', 'Load earlier messages')}
</button>
</div>
) : 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 (
<Fragment key={message.id}>
{receipt ? (
<NativeChatResolutionReceipt body={receipt} />
) : (
<MessageRow
message={message}
previousTodoWrite={taskListPredecessors.get(message.id)?.todowrite}
previousUpdatePlan={taskListPredecessors.get(message.id)?.update_plan}
revealedDiff={
revealedDiff?.messageId === message.id ? revealedDiff : undefined
}
expandSignal={expandSignal}
// A missing transcript lifecycle is not evidence that the turn
// ended. Structured sessions and legacy live hooks still expose
// the authoritative session-level working state.
activeTurnIsWorking={
showTurnStatus &&
isCurrentTurn &&
(isWorking || session.transcriptLifecycle?.state === 'working')
}
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
structuredActivityUi={showTurnStatus}
activityExpandOverride={turnKey ? expandedTurnIds.has(turnKey) : undefined}
runtimeContext={runtimeContext}
/>
)}
{showTurnStatus &&
status &&
(index !== latestUserIndex || showTypingIndicator || !isWorking) ? (
<NativeChatWorkingStatus
startedAt={status.startedAt}
thinking={status.thinking}
workedSeconds={status.workedSeconds}
expanded={turnKey ? expandedTurnIds.has(turnKey) : false}
onToggleExpanded={
status.workedSeconds != null && turnKey
? () => toggleExpandedTurn(turnKey)
: undefined
}
/>
) : null}
{turnDiff ? (
<NativeChatTurnDiffRollup diff={turnDiff} onReveal={revealDiff} />
) : null}
</Fragment>
)
})}
{showTurnStatus &&
latestUserIndex === -1 &&
turnStatuses.active &&
showTypingIndicator ? (
<NativeChatWorkingStatus
startedAt={turnStatuses.active.startedAt}
thinking={turnStatuses.active.thinking}
workedSeconds={turnStatuses.active.workedSeconds}
/>
) : null}
{showTurnStatus && isWorking ? (
<NativeChatTurnActivityLine activity={turnActivity} />
) : null}
{!showTurnStatus && showTypingIndicator ? <NativeChatTypingIndicatorRow /> : null}
</div>
</div>
{showJump ? (
<button
type="button"
onClick={scrollToBottom}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
) : null}
</div>
{showJump ? (
<button
type="button"
onClick={scrollToBottom}
aria-label={translate('components.native-chat.jumpToLatest', 'Jump to latest')}
className="absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-card/90 px-3 py-1.5 text-xs text-muted-foreground shadow-sm backdrop-blur hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowDown className="size-3.5" />
<span>{translate('components.native-chat.jumpToLatest', 'Jump to latest')}</span>
</button>
{taskListState.list && taskListState.list.tasks.length > 0 ? (
<div className="shrink-0 px-3 pb-2 sm:px-4">
<div className="mx-auto w-full max-w-4xl" style={{ zoom: fontScale }}>
<NativeChatTaskList
key={session.sessionId}
list={taskListState.list}
presentation="composer"
/>
</div>
</div>
) : null}
</div>
)
@@ -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 ? (
<NativeChatToolRun
blocks={tools}
previousTodoWrite={previousTodoWrite}
previousUpdatePlan={previousUpdatePlan}
revealedDiff={revealedDiff}
onRevealDiff={onScrollMessageToTop}
onLinkClick={onLinkClick}
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { NativeChatTaskList } from './NativeChatTaskList'
import type { NativeChatTaskList as TaskList } from '../../../../shared/native-chat-task-list'
afterEach(cleanup)
const previous: TaskList = {
tasks: [
{ content: 'Read', status: 'in_progress', activeForm: 'Reading' },
{ content: 'Write', status: 'pending', activeForm: 'Writing' },
{ content: 'Test', status: 'pending' }
]
}
const current: TaskList = {
tasks: [
{ content: 'Read', status: 'completed', activeForm: 'Reading' },
{ content: 'Write', status: 'in_progress', activeForm: 'Writing' },
{ content: 'Test', status: 'pending' }
]
}
describe('NativeChatTaskList', () => {
it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => {
const { container } = render(<NativeChatTaskList list={current} />)
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(<NativeChatTaskList list={current} previous={previous} />)
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(
<NativeChatTaskList
list={{ ...current, explanation: 'Continuing verification' }}
previous={current}
/>
)
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(<NativeChatTaskList list={{ tasks: [] }} />)
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(<NativeChatTaskList list={current} />)
expect(screen.getByText('Test')).toBeInTheDocument()
rerender(<NativeChatTaskList list={current} previous={previous} />)
expect(screen.queryByText('Test')).toBeNull()
expect(screen.getByText('Started Write')).toBeInTheDocument()
})
})
@@ -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 (
<li
className={cn(
'flex items-start gap-1.5 text-xs text-muted-foreground',
task.status === 'in_progress' && 'font-medium text-foreground'
)}
>
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
<span className="sr-only">{statusLabel(task)}: </span>
<span
className={cn(
'min-w-0 whitespace-pre-wrap break-words',
!label && task.status === 'completed' && 'line-through'
)}
>
{label ?? nativeChatTaskLabel(task)}
</span>
</li>
)
}
function Checklist({ list }: { list: TaskList }): React.JSX.Element {
return list.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground">
{translate('components.native-chat.taskList.empty', 'No tasks')}
</p>
) : (
<ul
aria-label={translate('components.native-chat.taskList.title', 'Tasks')}
className="space-y-1 py-1"
>
{list.tasks.map((task, index) => (
<TaskRow key={`${task.content}:${index}`} task={task} />
))}
</ul>
)
}
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 (
<Collapsible className="rounded-md border border-border bg-muted/30">
<CollapsibleTrigger className="group flex w-full items-center gap-1.5 rounded-md px-3 py-2 text-left text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ListChecks aria-hidden className="size-4 shrink-0" />
<span className="flex-1 font-medium">
{translate('components.native-chat.taskList.title', 'Tasks')}
</span>
<span
className="tabular-nums"
aria-label={translate(
'components.native-chat.taskList.progress',
'{{completed}} of {{total}} tasks completed',
{ completed, total: list.tasks.length }
)}
>
{completed}/{list.tasks.length}
</span>
<ChevronRight aria-hidden className="size-3.5 group-data-[state=open]:rotate-90" />
</CollapsibleTrigger>
<CollapsibleContent>
<div className="max-h-40 overflow-y-auto px-3 pb-2 scrollbar-sleek">
<Checklist list={list} />
{list.explanation ? (
<p className="whitespace-pre-wrap break-words text-xs text-muted-foreground">
{list.explanation}
</p>
) : null}
</div>
</CollapsibleContent>
</Collapsible>
)
}
const changes = previous ? diffNativeChatTaskLists(previous, list) : null
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<ListChecks aria-hidden className="size-4 shrink-0" />
<span className="font-medium">
{translate('components.native-chat.taskList.title', 'Tasks')}
</span>
<span
className="tabular-nums"
aria-label={translate(
'components.native-chat.taskList.progress',
'{{completed}} of {{total}} tasks completed',
{ completed, total: list.tasks.length }
)}
>
{completed}/{list.tasks.length}
</span>
</div>
{changes ? (
<>
{changes.length > 0 ? (
<ul className="space-y-1 py-1">
{changes.map((change, index) => (
<TaskRow
key={`${change.kind}:${index}`}
task={change.task}
label={changeLabel(change)}
/>
))}
</ul>
) : (
<p className="text-xs text-muted-foreground">
{translate('components.native-chat.taskList.unchanged', 'Tasks unchanged')}
</p>
)}
<Collapsible>
<CollapsibleTrigger className="group flex items-center gap-1 rounded py-0.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<ChevronRight aria-hidden className="size-3.5 group-data-[state=open]:rotate-90" />
{translate('components.native-chat.taskList.showAll', 'Full task list')}
</CollapsibleTrigger>
<CollapsibleContent>
<Checklist list={list} />
</CollapsibleContent>
</Collapsible>
</>
) : (
<Checklist list={list} />
)}
{list.explanation ? (
<p className="whitespace-pre-wrap break-words text-xs text-muted-foreground">
{list.explanation}
</p>
) : null}
</div>
)
}
@@ -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(<NativeChatToolRun blocks={blocks} expandSignal />)
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(
<NativeChatToolRun
blocks={[
{ type: 'tool-call', name: 'TodoWrite', input: '{' },
{ type: 'tool-result', output: 'Invalid arguments', isError: true },
{
type: 'tool-call',
name: 'TodoWrite',
input: { todos: [{ content: 'Test', status: 'completed' }] }
},
{ type: 'tool-result', output: 'Update rejected', isError: true }
]}
expandSignal
/>
)
expect(screen.getByText('Invalid arguments', { selector: 'pre' })).toBeInTheDocument()
expect(screen.getByText('Update rejected', { selector: 'pre' })).toBeInTheDocument()
expect(screen.queryByText('1/1')).toBeNull()
})
})
@@ -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({
<div className="mt-1 pl-4">
{(() => {
const seen = new Map<string, number>()
return blocks.map((block) => {
return blocks.map((block, blockIndex) => {
const taskList = taskLists?.rows.get(block)
if (taskList) {
return <NativeChatTaskList key={`tasks:${blockIndex}`} {...taskList} />
}
if (taskLists?.consumedResults.has(block)) {
return null
}
const edit = editCards.get(block)
if (edit) {
return (
@@ -0,0 +1,36 @@
import { normalizeNativeChatTaskList } from '../../../../shared/native-chat-task-list'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
const projectedFrames = new WeakMap<NativeChatMessage, NativeChatMessage>()
/** 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
})
}
@@ -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)
})
})
@@ -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<NativeChatTaskListTool, NativeChatToolCallBlock>
>
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<string, NativeChatTaskListPredecessors> {
const history = new Map<string, NativeChatTaskListPredecessors>()
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<NativeChatBlock, NativeChatTaskListRow>
consumedResults: Set<NativeChatBlock>
} {
const rows = new Map<NativeChatBlock, NativeChatTaskListRow>()
const consumedResults = new Set<NativeChatBlock>()
const previous = new Map<NativeChatTaskListTool, NativeChatTaskList>()
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 }
}
@@ -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])
})
})
@@ -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<NativeChatMessage, NativeChatTaskList | null>()
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 }
}
+16
View File
@@ -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",
+138
View File
@@ -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') }
])
})
})
+122
View File
@@ -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<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, NativeChatTask[]>()
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<string, number>()
const consumed = new Set<NativeChatTask>()
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
}
+3
View File
@@ -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')
})
+1
View File
@@ -81,6 +81,7 @@ const CATEGORY_BY_ROW_WORD = new Map<string, NativeChatToolCategory>([
['task', 'subAgentActivity'],
['webfetch', 'webSearch'],
['todowrite', 'todoList'],
['update_plan', 'todoList'],
['web search', 'webSearch'],
['websearch', 'webSearch'],
['web_search', 'webSearch']