mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
Render native chat task lists with incremental checklist updates
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
# Native chat task lists
|
||||
|
||||
## Scope
|
||||
Render TodoWrite and update_plan inputs as inline checklists using existing tool-call
|
||||
blocks. No journal schema, transport, main-process, plan-document, or mobile UI changes.
|
||||
The shared module is a pure, opt-in model; existing mobile consumers stay unchanged.
|
||||
|
||||
## Design
|
||||
- Normalize object and JSON-string inputs into task entries with content, optional
|
||||
activeForm, and pending/in_progress/completed status. Unknown/missing statuses become
|
||||
pending; malformed payloads fall back to the existing generic tool line. Empty lists
|
||||
are valid; invalid entries are ignored unless no valid entries remain in a nonempty list.
|
||||
- Compare successive valid task-list calls throughout the loaded session, across message
|
||||
and user-turn boundaries, ignoring intervening ordinary tools and failed list calls.
|
||||
Diff each tool family only against its own predecessor (TodoWrite or update_plan),
|
||||
including during streaming before a result arrives. Use existing FIFO tool/result pairing to preserve error visibility.
|
||||
- Match entries by content plus occurrence order, so duplicates are deterministic and
|
||||
reorder-only changes are not reported as starts/completions. Renames are removals/additions.
|
||||
Report additions, removals, status transitions (including reopened tasks), and activeForm
|
||||
label edits on content-matched entries only. Content renames remain removals/additions.
|
||||
- First call displays a full read-only checklist. Later calls lead with changed entries,
|
||||
keep the full list behind a disclosure, and show concise unchanged feedback when needed.
|
||||
Every call includes completed/total progress; optional explanation remains readable.
|
||||
- Use Circle, CircleDot, and CircleCheck status glyphs with accessible translated status
|
||||
text, existing foreground/muted tokens, and activeForm only for in-progress labels.
|
||||
Preserve the existing parent tool-run and turn disclosure behavior.
|
||||
- Derive predecessor context in one transcript pass, passing stable previous call objects
|
||||
to message rows rather than introducing a session store or render-time state updates.
|
||||
Recompute from loaded history so pagination, rerenders, and session switches stay correct.
|
||||
|
||||
## Exact files
|
||||
- Add src/shared/native-chat-task-list.ts: normalization and deterministic diff model.
|
||||
- Add src/shared/native-chat-task-list.test.ts: normalizer and diff cases.
|
||||
- Add src/renderer/src/components/native-chat/native-chat-task-list-history.ts:
|
||||
message predecessors and tool-row models using existing pairing.
|
||||
- Add src/renderer/src/components/native-chat/native-chat-task-list-history.test.ts:
|
||||
cross-message/turn, failed-call, independent tool families, and independent-history cases.
|
||||
Prepending history must change the formerly-first row predecessor prop referentially,
|
||||
making memoized rows switch from the full list to a diff.
|
||||
- Add src/renderer/src/components/native-chat/NativeChatTaskList.tsx: checklist/disclosure.
|
||||
- Add src/renderer/src/components/native-chat/NativeChatTaskList.test.tsx: rendering and
|
||||
disclosure, progress, accessibility, activeForm, and unchanged updates.
|
||||
- Modify src/renderer/src/components/native-chat/NativeChatMessageList.tsx and
|
||||
NativeChatMessageRow.tsx to thread prior task-list call context. Add pagination/session
|
||||
switch integration coverage in NativeChatMessageList.test.tsx.
|
||||
- Modify src/renderer/src/components/native-chat/NativeChatToolRun.tsx to replace valid
|
||||
list tool lines and fold successful associated results while preserving failures.
|
||||
- Modify src/renderer/src/components/native-chat/NativeChatToolRun.test.tsx to verify
|
||||
integration, generic malformed fallback, and failure visibility.
|
||||
- Modify src/shared/native-chat-tool-icon.ts and its test to classify update_plan as todoList.
|
||||
- Keep all user-facing copy renderer-side with translate; shared model contains no UI copy.
|
||||
- Modify src/renderer/src/i18n/locales/en.json for every new visible/accessibility string.
|
||||
|
||||
## Verification
|
||||
Run targeted shared/model/component tests plus existing message-list and tool-run suites
|
||||
with ORCA_BACKGROUND_LAUNCH=1. Format only changed files and run oxlint on those files.
|
||||
Do not run pnpm tc or repository-wide formatting. If rendered app validation is available,
|
||||
use the electron skill, background launch, and hidden-renderer CDP screenshots only.
|
||||
Commit only lane files, push, open a focused PR, verify clean status and no unpushed commits.
|
||||
Preserve the supplied brief unchanged under .tmp/task-list-qa/TASK-BRIEF.md with
|
||||
ignored validation evidence when cleaning the worktree for delivery.
|
||||
|
||||
## Risks
|
||||
- No task IDs exist: duplicate matching is occurrence-based; renames cannot prove identity.
|
||||
- Earlier unloaded history is unavailable: the first loaded list displays in full until
|
||||
pagination supplies a predecessor.
|
||||
- Existing tool pairing is ordinal; reuse it rather than inventing a second pairing rule.
|
||||
- Parent activity disclosure still controls visibility; a pinned or always-visible list
|
||||
would be a separate product change.
|
||||
@@ -560,3 +560,64 @@ describe('NativeChatMessageList assistant messages', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NativeChatMessageList task list history', () => {
|
||||
it('updates a memoized row when pagination supplies a predecessor and resets 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 renderSession = (messages: NativeChatLiveSession['messages'], sessionId = 'one') => (
|
||||
<NativeChatMessageList
|
||||
session={{ ...session, messages, sessionId }}
|
||||
isWorking={false}
|
||||
expandSignal
|
||||
fontScale={1}
|
||||
showTurnStatus={false}
|
||||
/>
|
||||
)
|
||||
const { rerender } = render(renderSession([last]))
|
||||
expect(screen.queryByText('Completed Read')).toBeNull()
|
||||
rerender(renderSession([first, last]))
|
||||
expect(screen.getByText('Completed Read')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Full task list' })).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false'
|
||||
)
|
||||
rerender(renderSession([last], 'two'))
|
||||
expect(screen.queryByText('Completed Read')).toBeNull()
|
||||
expect(screen.getByText('Read')).toHaveClass('line-through')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import { orderNativeChatMessages } from './native-chat-message-grouping'
|
||||
import { stripNoiseMessages } from './native-chat-noise'
|
||||
import { foldToolMessages } from './native-chat-tool-fold'
|
||||
import { isNearBottom, shouldShowJumpToLatest, type ScrollGeometry } from './native-chat-autoscroll'
|
||||
import { nativeChatTaskListPredecessors } from './native-chat-task-list-history'
|
||||
import { MessageRow } from './NativeChatMessageRow'
|
||||
import { shouldShowNativeChatTypingIndicator } from './native-chat-typing-indicator'
|
||||
import { NativeChatWorkingStatus } from './NativeChatWorkingStatus'
|
||||
@@ -84,6 +85,7 @@ export function NativeChatMessageList({
|
||||
() => stripNoiseMessages(foldToolMessages(orderNativeChatMessages(session.messages))),
|
||||
[session.messages]
|
||||
)
|
||||
const taskListPredecessors = useMemo(() => nativeChatTaskListPredecessors(messages), [messages])
|
||||
const showTypingIndicator = showTurnStatus
|
||||
? isWorking
|
||||
: shouldShowNativeChatTypingIndicator({ messages, isWorking })
|
||||
@@ -231,6 +233,8 @@ export function NativeChatMessageList({
|
||||
<Fragment key={message.id}>
|
||||
<MessageRow
|
||||
message={message}
|
||||
previousTodoWrite={taskListPredecessors.get(message.id)?.todowrite}
|
||||
previousUpdatePlan={taskListPredecessors.get(message.id)?.update_plan}
|
||||
expandSignal={expandSignal}
|
||||
// A missing transcript lifecycle is not evidence that the turn
|
||||
// ended. Structured sessions and legacy live hooks still expose
|
||||
|
||||
@@ -4,7 +4,10 @@ import CommentMarkdown, {
|
||||
} from '@/components/sidebar/CommentMarkdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatMessage,
|
||||
NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { splitNativeChatBlocks } from './native-chat-tool-fold'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import { nativeChatProseToMarkdown } from './native-chat-prose'
|
||||
@@ -22,6 +25,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,
|
||||
expandSignal,
|
||||
activeTurnIsWorking,
|
||||
onScrollMessageToTop,
|
||||
@@ -33,6 +38,8 @@ export const MessageRow = memo(function MessageRow({
|
||||
runtimeContext
|
||||
}: {
|
||||
message: NativeChatMessage
|
||||
previousTodoWrite?: NativeChatToolCallBlock
|
||||
previousUpdatePlan?: NativeChatToolCallBlock
|
||||
expandSignal: boolean
|
||||
activeTurnIsWorking?: boolean
|
||||
/** Align this message's top to the top of the scroll viewport. */
|
||||
@@ -154,6 +161,8 @@ export const MessageRow = memo(function MessageRow({
|
||||
{tools.length > 0 ? (
|
||||
<NativeChatToolRun
|
||||
blocks={tools}
|
||||
previousTodoWrite={previousTodoWrite}
|
||||
previousUpdatePlan={previousUpdatePlan}
|
||||
expandSignal={expandSignal}
|
||||
expandOverride={activityExpandOverride}
|
||||
activeTurnIsWorking={activeTurnIsWorking}
|
||||
|
||||
@@ -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,148 @@
|
||||
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
|
||||
}: {
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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(<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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,8 @@ import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
isToolCallBlock,
|
||||
isToolResultBlock,
|
||||
type NativeChatBlock
|
||||
type NativeChatBlock,
|
||||
type NativeChatToolCallBlock
|
||||
} from '../../../../shared/native-chat-types'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { NativeChatDiffCard } from './NativeChatDiffCard'
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
selectActiveToolCall
|
||||
} from '../../../../shared/native-chat-tool-activity'
|
||||
import { nativeChatToolRunIconName } from '../../../../shared/native-chat-tool-icon'
|
||||
import { NativeChatTaskList } from './NativeChatTaskList'
|
||||
import { buildNativeChatTaskListRows } from './native-chat-task-list-history'
|
||||
import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
import { NativeChatToolIcon, NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
import { nativeChatToolActivityLabel } from './native-chat-tool-activity-label'
|
||||
@@ -182,12 +185,16 @@ function buildEditCards(blocks: NativeChatBlock[]): EditCardModel {
|
||||
* toolbar toggle drive every run at once while still allowing per-run override. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
previousTodoWrite,
|
||||
previousUpdatePlan,
|
||||
expandSignal,
|
||||
activeTurnIsWorking,
|
||||
expandOverride,
|
||||
structuredActivityUi = true
|
||||
}: {
|
||||
blocks: NativeChatBlock[]
|
||||
previousTodoWrite?: NativeChatToolCallBlock
|
||||
previousUpdatePlan?: NativeChatToolCallBlock
|
||||
/** Toolbar-driven desired open state. Each change re-syncs this run's state. */
|
||||
expandSignal: boolean
|
||||
/** Per-turn disclosure state controlled by the completed turn status row. */
|
||||
@@ -215,6 +222,16 @@ export function NativeChatToolRun({
|
||||
() => (open ? buildEditCards(blocks) : NO_EDIT_CARDS),
|
||||
[open, blocks]
|
||||
)
|
||||
const taskLists = useMemo(
|
||||
() =>
|
||||
open
|
||||
? buildNativeChatTaskListRows(blocks, {
|
||||
todowrite: previousTodoWrite,
|
||||
update_plan: previousUpdatePlan
|
||||
})
|
||||
: null,
|
||||
[open, blocks, previousTodoWrite, previousUpdatePlan]
|
||||
)
|
||||
// Only the settled header reads this. It stands over `summary`, which speaks
|
||||
// for the run's first calls rather than its last, so a glyph taken from one
|
||||
// call would assert a category the text beside it doesn't describe. A run that
|
||||
@@ -292,7 +309,14 @@ export function NativeChatToolRun({
|
||||
<div className="mt-1">
|
||||
{(() => {
|
||||
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,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 }
|
||||
}
|
||||
@@ -16943,6 +16943,22 @@
|
||||
"empty": "No users found"
|
||||
},
|
||||
"native-chat": {
|
||||
"taskList": {
|
||||
"title": "Tasks",
|
||||
"completed": "Completed",
|
||||
"inProgress": "In progress",
|
||||
"pending": "Pending",
|
||||
"added": "Added {{task}}",
|
||||
"removed": "Removed {{task}}",
|
||||
"started": "Started {{task}}",
|
||||
"finished": "Completed {{task}}",
|
||||
"reset": "Marked pending: {{task}}",
|
||||
"updated": "Updated {{task}}",
|
||||
"empty": "No tasks",
|
||||
"progress": "{{completed}} of {{total}} tasks completed",
|
||||
"unchanged": "Tasks unchanged",
|
||||
"showAll": "Full task list"
|
||||
},
|
||||
"composer": {
|
||||
"imageUnsupported": "Image paste is not supported for this agent.",
|
||||
"send": "Send",
|
||||
|
||||
@@ -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') }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
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' || item.status === 'completed' ? item.status : '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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ const CATEGORY_BY_ROW_WORD = new Map<string, NativeChatToolCategory>([
|
||||
['task', 'subAgentActivity'],
|
||||
['webfetch', 'webSearch'],
|
||||
['todowrite', 'todoList'],
|
||||
['update_plan', 'todoList'],
|
||||
['web search', 'webSearch'],
|
||||
['websearch', 'webSearch']
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user