From d423d59b08395360cd7263709d4560ab436d7c0c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 19:36:34 +0900 Subject: [PATCH 1/2] fix(conversation): align TodoWrite task projection and disclosure lifecycle --- scripts/conversation-renderer-regression.tsx | 10 +- scripts/run-alignment-regression.mjs | 8 +- scripts/todo-write-dom-regression.tsx | 97 ++++++++++++++++++ scripts/todo-write-regression.tsx | 98 +++++++++++++++++++ .../conversation/SourceToolHeader.tsx | 14 ++- .../workbuddy/conversation/TodoWriteView.tsx | 96 ++++++++++++++++++ .../conversation/ToolCallRenderer.tsx | 58 +---------- .../workbuddy/conversation/source-labels.ts | 2 +- .../src/workbuddy/conversation/todo-write.css | 18 ++++ 9 files changed, 335 insertions(+), 66 deletions(-) create mode 100644 scripts/todo-write-dom-regression.tsx create mode 100644 scripts/todo-write-regression.tsx create mode 100644 src/renderer/src/workbuddy/conversation/TodoWriteView.tsx create mode 100644 src/renderer/src/workbuddy/conversation/todo-write.css diff --git a/scripts/conversation-renderer-regression.tsx b/scripts/conversation-renderer-regression.tsx index a668bba..0275367 100644 --- a/scripts/conversation-renderer-regression.tsx +++ b/scripts/conversation-renderer-regression.tsx @@ -557,18 +557,18 @@ const todoWriteRenderer = renderToStaticMarkup( }} /> ) -assert.match(todoWriteRenderer, /进度更新 1\/3/) +assert.match(todoWriteRenderer, /进度 1\/3/) assert.match(todoWriteRenderer, /Completed task/) assert.match(todoWriteRenderer, /Writing the active task/) assert.match(todoWriteRenderer, /Pending task/) -assert.match(todoWriteRenderer, /cb-plan-task--completed/) -assert.match(todoWriteRenderer, /cb-plan-task--in_progress/) -assert.match(todoWriteRenderer, /cb-plan-task--pending/) +assert.match(todoWriteRenderer, /cr-tool-plan-task__text--done/) +assert.match(todoWriteRenderer, /cr-tool-plan-task__text--running/) +assert.match(todoWriteRenderer, /data-task-status="pending"/) const todoStepsRenderer = renderToStaticMarkup( ) assert.match(todoStepsRenderer, /Compatibility step/) -assert.match(todoStepsRenderer, /cb-plan-task--in_progress/) +assert.match(todoStepsRenderer, /cr-tool-plan-task__text--running/) const fileListRenderer = renderToStaticMarkup( ) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index c372106..639e8ba 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -22,7 +22,7 @@ if (domModule) { const tests = [ 'model-selection-alignment-regression.ts', 'model-settings-alignment-regression.ts', 'session-factory-alignment-regression.ts', 'session-composer-alignment-regression.tsx', 'alignment-conversation-regression.tsx', 'question-answer-shell-regression.tsx', - 'runtime-authorization-regression.ts', 'permission-intercept-regression.tsx' + 'runtime-authorization-regression.ts', 'permission-intercept-regression.tsx', 'todo-write-regression.tsx' ] for (const file of tests) { const format = file.endsWith('.tsx') ? 'cjs' : 'esm' @@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv // Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. if (domModule) { - for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'question-composer-dom-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'question-composer-dom-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression', 'todo-write-dom-regression']) { const outfile = `.tmp/alignment-tests/${file}.cjs` await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) @@ -56,5 +56,5 @@ if (domModule) { console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 })) console.log(JSON.stringify(domModule - ? { result: 'PASS', phase: 'alignment-dom', suites: 9 } - : { result: 'SKIP', phase: 'alignment-dom', suites: 9, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) + ? { result: 'PASS', phase: 'alignment-dom', suites: 10 } + : { result: 'SKIP', phase: 'alignment-dom', suites: 10, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' })) diff --git a/scripts/todo-write-dom-regression.tsx b/scripts/todo-write-dom-regression.tsx new file mode 100644 index 0000000..32aadcb --- /dev/null +++ b/scripts/todo-write-dom-regression.tsx @@ -0,0 +1,97 @@ +/** B05 real React TodoWrite + CompletedFold DOM tests. Synthetic data only, no native UI claim. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import type { ConversationToolCall } from '../src/renderer/src/workbuddy/conversation/types' +const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') +const dom = new JSDOM('
', { pretendToBeVisual:true, url:'http://localhost/' }) +for (const key of ['window','document','Node','Element','HTMLElement','MouseEvent','KeyboardEvent','Event','CustomEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) +Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true,requestAnimationFrame:dom.window.requestAnimationFrame.bind(dom.window),cancelAnimationFrame:dom.window.cancelAnimationFrame.bind(dom.window)}) +const { act } = require('react') as typeof import('react') +const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') +const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold') +const { ToolCallRenderer } = require('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') as typeof import('../src/renderer/src/workbuddy/conversation/ToolCallRenderer') +const { ThinkingBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') +const root = createRoot(document.getElementById('root')!) +let checks = 0 +function equal(actual:unknown, expected:unknown, label:string) { assert.deepEqual(actual,expected,label);checks++ } +const tasks = (count=7, active=4) => Array.from({length:count},(_,i)=>({id:String(i+1),content:`Task ${i+1}`,activeForm:`Working ${i+1}`,status:i===active?'in_progress':i={todos:tasks()}): ConversationToolCall => ({id,name:'TodoWrite',status:'executed',isLastContent:last,args,result:'Todos have been modified successfully...',output:'Todos have been modified successfully...'}) +const calls: Array<{toolCallId:string;toolName:string;taskId:string|number}> = [] +interface Options { session?:string; finished?:boolean; last?:boolean; remount?:number; second?:boolean; callback?:boolean; active?:number; count?:number } +function Fixture({session='session-a',finished=true,last=true,remount=0,second=false,callback=false,active=4,count=7}: Options) { + const parts = [{type:'tool',toolId:'todo-1'},{type:'reasoning',id:'thought',text:'Saved thought'},{type:'tool',toolId:'ordinary'},{type:'tool',toolId:'question'},...(second?[{type:'tool',toolId:'todo-2'}]:[]),{type:'text',text:'Answer'}] + return part.toolId==='question'?{role:'inline-question'}:undefined} + renderUnit={part=>part.type==='reasoning'?:part.type==='tool'?
+ calls.push(request):undefined} /> +
:

Answer

} /> +} +const find=(selector:string)=>{const el=document.querySelector(selector);assert.ok(el,selector);return el} +const todo='[data-fixture-tool="todo-1"] .cr-tool-head__main' +const secondTodo='[data-fixture-tool="todo-2"] .cr-tool-head__main' +const ordinary='[data-fixture-tool="ordinary"] .cr-tool-head__main' +const question='[data-fixture-tool="question"] .cr-tool-head__main' +const thought='.cr-reasoning > .cr-collapse__header' +const outer='.cr-collapse--completed > .cr-collapse__header' +const expanded=(selector:string)=>find(selector).getAttribute('aria-expanded') +const rows=()=>Array.from(document.querySelectorAll('[data-fixture-tool="todo-1"] .cr-tool-plan-task__row')).map(el=>el.getAttribute('data-task-id')) +async function render(options:Options={}) { await act(async()=>root.render()) } +async function click(selector:string) { await act(async()=>find(selector).dispatchEvent(new MouseEvent('click',{bubbles:true}))) } +async function key(selector:string,value:string) { await act(async()=>find(selector).dispatchEvent(new KeyboardEvent('keydown',{key:value,bubbles:true,cancelable:true}))) } +async function main() { + await render() + equal(expanded(outer),'false','finished outer starts closed') + await click(outer) + equal(expanded(todo),'true','last Todo initially open') + equal(rows(),['3','4','5','6','7'],'real component displays five centered rows') + equal(document.querySelector('[data-fixture-tool="todo-1"]')!.textContent!.includes('Todos have been modified successfully'),false,'SDK success hidden') + for(const selector of [ordinary,question,thought]) {equal(expanded(selector),'false',`${selector} starts independent`); await click(selector)} + await click(todo); equal(expanded(todo),'false','manual Todo close') + await render(); equal(expanded(todo),'false','same isLastContent preserves manual choice') + await click(outer); await click(outer) + equal(expanded(todo),'false','outer collapse/reopen preserves manual close') + for(const selector of [ordinary,question,thought]) equal(expanded(selector),'true','Todo state does not alter other disclosure') + await render({remount:1}); equal(expanded(todo),'false','same-value inner remount preserves manual choice') + await render({last:false,remount:1}); equal(expanded(todo),'false','true to false closes') + await render({last:true,remount:1}); equal(expanded(todo),'true','false to true opens') + await render({last:false,remount:2}); equal(expanded(todo),'false','real true-false edge concurrent with remount closes') + await click(todo); equal(expanded(todo),'true','manual open while not last') + await render({last:false,remount:3}); equal(expanded(todo),'true','same false remount does not overwrite manual open') + await click(todo); equal(expanded(todo),'false','manual close before next real edge') + await render({last:true,remount:4}); equal(expanded(todo),'true','false-true remount opens despite retained manual close') + await render({session:'session-b',last:false}); equal(expanded(outer),'false','session identity resets completed outer') + await click(outer); equal(expanded(todo),'false','same tool id in other session does not inherit') + for(const selector of [ordinary,question,thought]) equal(expanded(selector),'false','other disclosures isolated per session') + await render({session:'live-session',finished:false,last:true}) + equal(expanded(todo),'true','live Todo open') + await render({session:'live-session',finished:true,last:false}) + equal(expanded(todo),'false','live-to-completed reparent plus actual edge closes') + await click(outer); await click(todo) + await render({session:'live-session',finished:true,last:false,second:true}) + equal(expanded(todo),'true','older manually opened snapshot stays open after later call') + equal(expanded(secondTodo),'true','later call has own disclosure') + equal(document.querySelectorAll('.cr-tool-head--todo-write').length,2,'distinct historical calls are not merged') + equal(document.querySelectorAll('[data-fixture-tool="todo-2"] .cr-tool-plan-task__row').length,5,'second snapshot independently projected') + await click(secondTodo); equal(expanded(todo),'true','closing later Todo cannot close older snapshot') + await render({session:'live-session',finished:true,last:false,second:true,active:6}) + equal(rows(),['3','4','5','6','7'],'same call update refreshes window without data merge') + assert.match(find('[data-fixture-tool="todo-1"]').textContent!,/Working 7/); checks++ + await render({session:'callbacks',callback:true}) + await click(outer) + const row='[data-fixture-tool="todo-1"] [data-task-id="3"]' + equal(find(row).getAttribute('role'),'button','host callback exposes button semantics') + equal(find(row).getAttribute('tabindex'),'0','host callback exposes Tab target') + await click(row); await key(row,'Enter'); await key(row,' ') + equal(calls,[0,1,2].map(()=>({toolCallId:'todo-1',toolName:'TodoWrite',taskId:'3'})),'click and keyboard preserve exact event identity') + equal(expanded(todo),'true','row activation does not toggle parent') + await render({session:'callbacks',callback:false}) + equal(find(row).getAttribute('role'),null,'no fake role without actual host target') + equal(find(row).getAttribute('tabindex'),null,'no fake keyboard navigation without target') + await click(row); await key(row,'Enter'); equal(calls.length,3,'callback removed disables interaction') + await act(async()=>root.unmount());dom.window.close() + console.log(`todo-write-dom: ${checks} actual TodoWrite/CompletedFold checks passed; no Electron/pixel claim`) +} +main().then(()=>process.exit(0)).catch(error=>{console.error(error);dom.window.close();process.exit(1)}) diff --git a/scripts/todo-write-regression.tsx b/scripts/todo-write-regression.tsx new file mode 100644 index 0000000..33ffaa9 --- /dev/null +++ b/scripts/todo-write-regression.tsx @@ -0,0 +1,98 @@ +/** B05 projection and SSR checks; no SDK, network, or pixel claims. */ +import assert from 'node:assert/strict' +import { renderToStaticMarkup } from 'react-dom/server' +import { ToolCallRenderer, getCapturedToolKind } from '../src/renderer/src/workbuddy/conversation/ToolCallRenderer' +import { normalizeTodoWrite, windowTodoTasks } from '../src/renderer/src/workbuddy/conversation/TodoWriteView' +import type { ConversationToolCall } from '../src/renderer/src/workbuddy/conversation/types' +let checks = 0 +function check(name: string, run: () => void) { run(); checks++; console.log(`PASS ${name}`) } +const tasks = (count: number, active = -1, completed: number[] = []) => Array.from({ length: count }, (_, i) => ({ id: String(i + 1), content: `Task ${i + 1}`, activeForm: `Working ${i + 1}`, status: i === active ? 'in_progress' : completed.includes(i) ? 'completed' : 'pending' })) +const tool = (patch: Partial = {}): ConversationToolCall => ({ id: 'todo-1', name: 'TodoWrite', status: 'executed', isLastContent: true, args: { todos: tasks(7, 4) }, result: 'Todos have been modified successfully...', output: 'Todos have been modified successfully...', ...patch }) +const render = (value: ConversationToolCall) => renderToStaticMarkup() +const ids = (items: ReturnType) => windowTodoTasks(items).map(item => String(item.id)) +const fixtures: [string, ReturnType, string[]][] = [ + ['six second active', tasks(6, 1), ['1','2','3','4','5']], + ['seven fifth active', tasks(7, 4), ['3','4','5','6','7']], + ['seven first active', tasks(7, 0), ['1','2','3','4','5']], + ['seven last active', tasks(7, 6), ['3','4','5','6','7']], + ['seven all pending', tasks(7), ['1','2','3','4','5']], + ['seven all completed', tasks(7, -1, [0,1,2,3,4,5,6]), ['3','4','5','6','7']], + ['only fourth completed', tasks(7, -1, [3]), ['2','3','4','5','6']], + ['multiple active uses first', tasks(7, 1).map((x,i) => i === 5 ? {...x,status:'in_progress'} : x), ['1','2','3','4','5']], + ['empty', tasks(0), []], ['one', tasks(1), ['1']], ['five', tasks(5), ['1','2','3','4','5']], + ['long middle', tasks(100, 49), ['48','49','50','51','52']], +] +for (const [name,input,expected] of fixtures) check(`window ${name}`, () => { + const before = structuredClone(input) + assert.deepEqual(ids(input), expected); assert.deepEqual(input,before) + const html = render(tool({args:{todos:input}})) + assert.equal((html.match(/data-task-id=/g) || []).length,expected.length) + assert.doesNotMatch(html, /更多|下一页|上一页/) +}) +for (const alias of ['TodoWrite','todo_write','todoWrite','todo-write']) check(`alias ${alias}`, () => { + assert.equal(getCapturedToolKind(alias),'todo-write') + assert.match(render(tool({name:alias})), /cr-tool-head--todo-write/) +}) +check('ordinary tools do not enter dedicated view', () => { + assert.equal(getCapturedToolKind('NotTodoWrite'),undefined) + assert.doesNotMatch(render(tool({name:'NotTodoWrite'})), /cr-tool-head--todo-write/) +}) +for (const raw of [false,true,'json'] as const) check(`args and raw input ${raw}`, () => { + const input = { merge:true, todos:[{id:0, content:'Done', activeForm:'Doing',status:'completed'},{id:'a',content:'Active',activeForm:'Active now',status:'in_progress'}] } + const value = tool({args: raw ? {_rawInput:raw === 'json' ? JSON.stringify(input) : input} : input}) + const before = structuredClone(value), projection = normalizeTodoWrite(value) + assert.equal(projection.merge,true); assert.equal(projection.completedCount,1); assert.equal(projection.totalCount,2) + assert.equal(projection.tasks[0].id,0); assert.equal(projection.tasks[1].activeForm,'Active now') + render(value); assert.deepEqual(value,before) +}) +check('historical steps retain compatible task aliases', () => { + const value = tool({args:{steps:[{title:'Legacy task',status:'doing'}]}}) + assert.match(render(value),/Legacy task/); assert.match(render(value),/data-task-status="in_progress"/) +}) +check('single dedicated title, modern status rows and output projection', () => { + const value = tool({args:{todos:[...tasks(3,1,[0]),{id:'failed',content:'Failed task',status:'failed'}]}}) + const before = structuredClone(value), html = render(value) + assert.equal((html.match(/任务列表/g)||[]).length,1) + assert.match(html,/cr-tool-plan-task__text--done/) + assert.match(html,/cr-tool-plan-task__text--running/) + assert.match(html,/Working 2/); assert.doesNotMatch(html,/>Task 2 { + const html = render(tool({args:{merge:true,todos:tasks(7,6,[0,1,2,3,4,5])}})) + assert.match(html,/进度 6\/7/); assert.doesNotMatch(html,/任务列表|进度更新/) +}) +check('empty running is header only with receiving cue', () => { + const html = render(tool({status:'running',args:{todos:[]}})) + assert.match(html,/接收中/); assert.equal((html.match(/任务列表/g)||[]).length,1) + assert.doesNotMatch(html,/cr-tool-plan-task__row|cr-tool-exp__content-shell/) +}) +check('empty finished is header only without receiving cue', () => { + const html = render(tool({args:{todos:[]}})) + assert.match(html,/任务列表/); assert.doesNotMatch(html,/接收中|cr-tool-exp__content-shell/) +}) +check('failure is dedicated error header only', () => { + const html = render(tool({status:'failed',result:'error',output:'SDK ERROR RAW'})) + assert.match(html,/生成失败/); assert.match(html,/cr-tool-head__icon--error/) + assert.doesNotMatch(html,/cr-tool-plan-task__row|cr-tool-exp__content-shell|SDK ERROR RAW/) +}) +check('without host callback rows are not fake buttons', () => { + const html = render(tool()) + assert.doesNotMatch(html,/cr-tool-plan-task__row[^>]*role="button"|cr-tool-plan-task__action/) +}) +check('malformed raw input falls back without modifying original data', () => { + const value = tool({args:{_rawInput:'{bad JSON',todos:[null, 7, {}, {content:' '}, {id:0,content:'Safe task',activeForm:' ',status:'in_progress'}]}}) + const before = structuredClone(value), projection = normalizeTodoWrite(value) + assert.equal(projection.tasks.length,1); assert.equal(projection.tasks[0].id,0) + assert.equal(projection.tasks[0].activeForm,undefined) + const html = render(value); assert.match(html,/Safe task/); assert.deepEqual(value,before) +}) +check('long and escaped content stays plain text with full title', () => { + const content = 'Long task ' + ' '.repeat(20) + const html = render(tool({args:{todos:[{id:'long',content,status:'pending'}]}})) + assert.match(html,/title="Long task/); assert.match(html,/<script>/) + assert.doesNotMatch(html,/