fix(conversation): align TodoWrite task projection and disclosure lifecycle
This commit is contained in:
1 parent
8d10743fb2
commit
d423d59b08
9 files changed
+335
-66
No files matched your search
@@ -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, /Completed task/)
|
||||||
assert.match(todoWriteRenderer, /Writing the active task/)
|
assert.match(todoWriteRenderer, /Writing the active task/)
|
||||||
assert.match(todoWriteRenderer, /Pending task/)
|
assert.match(todoWriteRenderer, /Pending task/)
|
||||||
assert.match(todoWriteRenderer, /cb-plan-task--completed/)
|
assert.match(todoWriteRenderer, /cr-tool-plan-task__text--done/)
|
||||||
assert.match(todoWriteRenderer, /cb-plan-task--in_progress/)
|
assert.match(todoWriteRenderer, /cr-tool-plan-task__text--running/)
|
||||||
assert.match(todoWriteRenderer, /cb-plan-task--pending/)
|
assert.match(todoWriteRenderer, /data-task-status="pending"/)
|
||||||
const todoStepsRenderer = renderToStaticMarkup(
|
const todoStepsRenderer = renderToStaticMarkup(
|
||||||
<ToolCallRenderer tool={{ id: 'todo-write-steps', name: 'TodoWrite', status: 'running', args: { steps: [{ title: 'Compatibility step', status: 'doing' }] } }} defaultExpanded />
|
<ToolCallRenderer tool={{ id: 'todo-write-steps', name: 'TodoWrite', status: 'running', args: { steps: [{ title: 'Compatibility step', status: 'doing' }] } }} defaultExpanded />
|
||||||
)
|
)
|
||||||
assert.match(todoStepsRenderer, /Compatibility step/)
|
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(
|
const fileListRenderer = renderToStaticMarkup(
|
||||||
<ToolCallRenderer tool={{ id: 'files-1', name: 'Glob', status: 'success', args: { pattern: 'src/**/*.ts' }, result: { result: { results: [{ path: 'src/main.ts' }, { path: 'src/util.ts' }] } } }} defaultExpanded />
|
<ToolCallRenderer tool={{ id: 'files-1', name: 'Glob', status: 'success', args: { pattern: 'src/**/*.ts' }, result: { result: { results: [{ path: 'src/main.ts' }, { path: 'src/util.ts' }] } } }} defaultExpanded />
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ if (domModule) {
|
|||||||
const tests = [
|
const tests = [
|
||||||
'model-selection-alignment-regression.ts', 'model-settings-alignment-regression.ts', 'session-factory-alignment-regression.ts',
|
'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',
|
'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) {
|
for (const file of tests) {
|
||||||
const format = file.endsWith('.tsx') ? 'cjs' : 'esm'
|
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.
|
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
||||||
if (domModule) {
|
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`
|
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' })
|
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 })
|
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({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
|
||||||
console.log(JSON.stringify(domModule
|
console.log(JSON.stringify(domModule
|
||||||
? { result: 'PASS', phase: 'alignment-dom', suites: 9 }
|
? { result: 'PASS', phase: 'alignment-dom', suites: 10 }
|
||||||
: { 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: '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.' }))
|
||||||
@@ -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('<html><body><main id="root"></main></body></html>', { 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<active?'completed':'pending'}))
|
||||||
|
const makeTool = (id:string,last:boolean,args:Record<string,unknown>={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 <CompletedFold foldId={`${session}:message`} parts={parts} turnFinished={finished}
|
||||||
|
classifyToolCall={part=>part.toolId==='question'?{role:'inline-question'}:undefined}
|
||||||
|
renderUnit={part=>part.type==='reasoning'?<ThinkingBlock text="Saved thought" isFinished />:part.type==='tool'?<div data-fixture-tool={part.toolId}>
|
||||||
|
<ToolCallRenderer key={`${part.toolId}:${remount}`} sessionId={session}
|
||||||
|
tool={part.toolId==='todo-1'?makeTool('todo-1',last,{todos:tasks(count,active)}):part.toolId==='todo-2'?makeTool('todo-2',true,{todos:tasks(6,1)}):part.toolId==='question'?{id:'question',name:'AskUserQuestion',status:'executed',metaData:{questionAnswer:{questions:[{question:'Choose?',answers:['Yes']}]}}}:{id:'ordinary',name:'UnknownOrdinaryTool',status:'executed',args:{value:1},result:'Ordinary result'}}
|
||||||
|
onTodoTaskClick={callback?request=>calls.push(request):undefined} />
|
||||||
|
</div>:<p>Answer</p>} />
|
||||||
|
}
|
||||||
|
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(<Fixture {...options} />)) }
|
||||||
|
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)})
|
||||||
@@ -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> = {}): 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(<ToolCallRenderer tool={value} sessionId="synthetic" />)
|
||||||
|
const ids = (items: ReturnType<typeof tasks>) => windowTodoTasks(items).map(item => String(item.id))
|
||||||
|
const fixtures: [string, ReturnType<typeof tasks>, 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</)
|
||||||
|
for(const status of ['completed','in_progress','pending','failed']) assert.match(html,new RegExp(`data-task-status="${status}"`))
|
||||||
|
assert.doesNotMatch(html,/cb-plan-task|Todos have been modified successfully|cr-captured-tool__output/)
|
||||||
|
assert.deepEqual(value,before)
|
||||||
|
})
|
||||||
|
check('merge progress title computed from complete list not five-row window', () => {
|
||||||
|
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 ' + '<script>not executable</script> '.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,/<script>/)
|
||||||
|
})
|
||||||
|
console.log(`todo-write: ${checks} projection and SSR checks passed; real Electron visual acceptance not covered`)
|
||||||
@@ -117,6 +117,9 @@ export interface SourceToolExpandableProps extends Omit<SourceToolHeaderProps, '
|
|||||||
canExpand?: boolean
|
canExpand?: boolean
|
||||||
defaultExpanded?: boolean
|
defaultExpanded?: boolean
|
||||||
expand?: boolean
|
expand?: boolean
|
||||||
|
/** Todo-only opt-in: retain actual position edges through presentation remounts. */
|
||||||
|
retainExpandEdges?: boolean
|
||||||
|
disclosureKey?: string
|
||||||
autoScroll?: boolean
|
autoScroll?: boolean
|
||||||
disableMotion?: boolean
|
disableMotion?: boolean
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
@@ -127,6 +130,8 @@ export function SourceToolExpandable({
|
|||||||
canExpand = true,
|
canExpand = true,
|
||||||
defaultExpanded = false,
|
defaultExpanded = false,
|
||||||
expand,
|
expand,
|
||||||
|
retainExpandEdges = false,
|
||||||
|
disclosureKey,
|
||||||
autoScroll = false,
|
autoScroll = false,
|
||||||
disableMotion = false,
|
disableMotion = false,
|
||||||
children,
|
children,
|
||||||
@@ -134,7 +139,9 @@ export function SourceToolExpandable({
|
|||||||
className,
|
className,
|
||||||
...headerProps
|
...headerProps
|
||||||
}: SourceToolExpandableProps) {
|
}: SourceToolExpandableProps) {
|
||||||
const [expanded, setExpanded] = useDisclosureState('tool-expandable', () => defaultExpanded || expand === true)
|
const slot = disclosureKey ? `tool-expandable:${disclosureKey}` : 'tool-expandable'
|
||||||
|
const [expanded, setExpanded] = useDisclosureState(slot, () => defaultExpanded || expand === true)
|
||||||
|
const [retainedExpand, setRetainedExpand] = useDisclosureState(`${slot}:expand-signal`, expand === true)
|
||||||
const priorExpand = useRef<boolean | undefined>(undefined)
|
const priorExpand = useRef<boolean | undefined>(undefined)
|
||||||
const expandable = canExpand && children != null
|
const expandable = canExpand && children != null
|
||||||
const open = expandable && expanded
|
const open = expandable && expanded
|
||||||
@@ -143,11 +150,12 @@ export function SourceToolExpandable({
|
|||||||
// Same transition semantics as source ToolExpandable [988614,989631).
|
// Same transition semantics as source ToolExpandable [988614,989631).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expand === undefined) return
|
if (expand === undefined) return
|
||||||
const previous = priorExpand.current
|
const previous = retainExpandEdges ? retainedExpand : priorExpand.current
|
||||||
priorExpand.current = expand
|
priorExpand.current = expand
|
||||||
|
if (retainExpandEdges) setRetainedExpand(expand)
|
||||||
if (previous === true && !expand) setExpanded(false)
|
if (previous === true && !expand) setExpanded(false)
|
||||||
else if (previous === false && expand) setExpanded(true)
|
else if (previous === false && expand) setExpanded(true)
|
||||||
}, [expand])
|
}, [expand, retainExpandEdges, retainedExpand, setRetainedExpand, setExpanded])
|
||||||
// Electron 37 supports source interpolate-size CSS; disabled motion uses source setStaticState.
|
// Electron 37 supports source interpolate-size CSS; disabled motion uses source setStaticState.
|
||||||
// The older-renderer JS animation-library fallback is not recovered here.
|
// The older-renderer JS animation-library fallback is not recovered here.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { ConversationToolCall } from './types'
|
||||||
|
import { SourceToolExpandable, SourceToolHeader } from './SourceToolHeader'
|
||||||
|
import { CheckIcon, FailedIcon, LoaderIcon, PlanIcon } from './RecoveredBlockIcons'
|
||||||
|
import { getToolCallPhase } from './ToolCall'
|
||||||
|
import { sourceConversationLabel as t } from './source-labels'
|
||||||
|
import { getPlanTodoStatus, selectPlanTasks, type PlanTodoItem } from './SpecializedBlocks'
|
||||||
|
import './todo-write.css'
|
||||||
|
|
||||||
|
/** Payload for the tool.todo.task.click integration. No project task is implied. */
|
||||||
|
export interface TodoTaskClickRequest {
|
||||||
|
toolCallId: string
|
||||||
|
toolName: string
|
||||||
|
taskId: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown): Record<string, unknown> {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try { return record(JSON.parse(value)) } catch { return {} }
|
||||||
|
}
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}
|
||||||
|
}
|
||||||
|
function text(...values: unknown[]): string {
|
||||||
|
return values.find(value => typeof value === 'string' && value.trim()) as string || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only presentation projection; never edits tool args, results or history. */
|
||||||
|
export function normalizeTodoWrite(tool: ConversationToolCall) {
|
||||||
|
const args = tool.args ?? {}
|
||||||
|
const raw = record(args._rawInput)
|
||||||
|
const data = Object.keys(raw).length ? raw : args
|
||||||
|
const candidates = [data.todos, data.steps, args.todos, args.steps]
|
||||||
|
const rawTasks = candidates.find(Array.isArray) as unknown[] | undefined ?? []
|
||||||
|
const tasks: PlanTodoItem[] = rawTasks.flatMap((value, index) => {
|
||||||
|
const item = record(value)
|
||||||
|
const content = text(item.content, item.title, item.text, item.step)
|
||||||
|
if (!content) return []
|
||||||
|
return [{
|
||||||
|
id: typeof item.id === 'string' || typeof item.id === 'number' ? item.id : String(index + 1),
|
||||||
|
content,
|
||||||
|
activeForm: text(item.activeForm) || undefined,
|
||||||
|
status: text(item.status)
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
tasks,
|
||||||
|
completedCount: tasks.filter(task => getPlanTodoStatus(task.status) === 'completed').length,
|
||||||
|
totalCount: tasks.length,
|
||||||
|
merge: (data.merge ?? args.merge) === true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function windowTodoTasks(tasks: PlanTodoItem[], maxDisplay = 5): PlanTodoItem[] {
|
||||||
|
const limit = Number.isFinite(maxDisplay) ? Math.max(1, Math.floor(maxDisplay)) : 5
|
||||||
|
return selectPlanTasks(tasks, limit).visible
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusGlyph({ status }: { status: ReturnType<typeof getPlanTodoStatus> }) {
|
||||||
|
if (status === 'completed') return <CheckIcon />
|
||||||
|
if (status === 'in_progress') return <LoaderIcon className="cr-tool-plan-task__loader" />
|
||||||
|
if (status === 'failed') return <FailedIcon />
|
||||||
|
return <span className="cr-tool-plan-task__pending" />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TodoWriteView({ tool, sessionId, defaultExpanded = false, onTaskClick }: {
|
||||||
|
tool: ConversationToolCall
|
||||||
|
sessionId?: string
|
||||||
|
defaultExpanded?: boolean
|
||||||
|
onTaskClick?: (request: TodoTaskClickRequest) => void
|
||||||
|
}) {
|
||||||
|
const { tasks, completedCount, totalCount, merge } = normalizeTodoWrite(tool)
|
||||||
|
const phase = getToolCallPhase(tool.status)
|
||||||
|
const title = merge ? t('tool.todo.progress', { completed: completedCount, total: totalCount }) : t('tool.todo.list')
|
||||||
|
if (phase === 'error') return <SourceToolHeader className="cr-tool-head--todo-write" error statusText={t('tool.todo.generateError')} />
|
||||||
|
if (!tasks.length) return <SourceToolHeader className="cr-tool-head--todo-write" icon={<PlanIcon />} statusText={title}
|
||||||
|
secondaryInfo={phase === 'running' ? t('tool.todo.receiving') : undefined} />
|
||||||
|
return <SourceToolExpandable className="cr-tool-head--todo-write" icon={<PlanIcon />} statusText={title}
|
||||||
|
defaultExpanded={defaultExpanded} expand={tool.isLastContent === true} retainExpandEdges
|
||||||
|
disclosureKey={`todo:${JSON.stringify([sessionId ?? '', tool.id])}`}>
|
||||||
|
<div className="cr-tool-plan-task">
|
||||||
|
{windowTodoTasks(tasks).map((task, index) => {
|
||||||
|
const status = getPlanTodoStatus(task.status)
|
||||||
|
const label = status === 'in_progress' ? task.activeForm || task.content : task.content
|
||||||
|
const activate = onTaskClick ? () => onTaskClick({ toolCallId: tool.id, toolName: tool.name, taskId: task.id }) : undefined
|
||||||
|
return <div key={`${typeof task.id}:${task.id}:${index}`} className={`cr-tool-plan-task__row${activate ? ' cr-tool-plan-task__row--clickable' : ''}`}
|
||||||
|
data-task-id={task.id} data-task-status={status} role={activate ? 'button' : undefined} tabIndex={activate ? 0 : undefined}
|
||||||
|
onClick={activate} onKeyDown={activate ? event => {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); activate() }
|
||||||
|
} : undefined}>
|
||||||
|
<span className={`cr-tool-plan-task__glyph cr-tool-plan-task__glyph--${status}`} aria-hidden="true"><StatusGlyph status={status} /></span>
|
||||||
|
<span className={`cr-tool-plan-task__text${status === 'completed' ? ' cr-tool-plan-task__text--done' : ''}${status === 'in_progress' ? ' cr-tool-plan-task__text--running' : ''}`} title={label}>{label}</span>
|
||||||
|
{activate && <span className="cr-tool-plan-task__action" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="m6 4 4 4-4 4" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" /></svg></span>}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SourceToolExpandable>
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useEffect, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|||||||
import { AskUserQuestion } from './AskUserQuestion'
|
import { AskUserQuestion } from './AskUserQuestion'
|
||||||
import { ToolCallCard, getToolCallPhase, getToolResultText } from './ToolCall'
|
import { ToolCallCard, getToolCallPhase, getToolResultText } from './ToolCall'
|
||||||
import { SourceToolExpandable, SourceToolHeader } from './SourceToolHeader'
|
import { SourceToolExpandable, SourceToolHeader } from './SourceToolHeader'
|
||||||
|
import { TodoWriteView, type TodoTaskClickRequest } from './TodoWriteView'
|
||||||
import { resolveSourceToolIcon } from './SourceToolIcons'
|
import { resolveSourceToolIcon } from './SourceToolIcons'
|
||||||
import { SourceWriteFileView } from './SourceWriteFileView'
|
import { SourceWriteFileView } from './SourceWriteFileView'
|
||||||
import { SourcePresentFilesView } from './SourcePresentFilesView'
|
import { SourcePresentFilesView } from './SourcePresentFilesView'
|
||||||
@@ -12,7 +13,7 @@ import { SourceToolApprovalActions } from './SourceToolActions'
|
|||||||
import { hasSourcePermissionRequest, type ToolApprovalControl } from './tool-approval'
|
import { hasSourcePermissionRequest, type ToolApprovalControl } from './tool-approval'
|
||||||
import { sourceConversationLabel as t } from './source-labels'
|
import { sourceConversationLabel as t } from './source-labels'
|
||||||
import { CheckIcon, FailedIcon } from './RecoveredBlockIcons'
|
import { CheckIcon, FailedIcon } from './RecoveredBlockIcons'
|
||||||
import { getPlanTodoStatus, PlanCreateBlock, PlanUpdateBlock, TaskBlock, type PlanTodoItem, type TaskToolData } from './SpecializedBlocks'
|
import { PlanCreateBlock, PlanUpdateBlock, TaskBlock, type PlanTodoItem, type TaskToolData } from './SpecializedBlocks'
|
||||||
import type { ConversationToolCall } from './types'
|
import type { ConversationToolCall } from './types'
|
||||||
import type { PreviewArtifact } from '@shared/artifacts'
|
import type { PreviewArtifact } from '@shared/artifacts'
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export interface ToolCallRendererProps {
|
|||||||
onOpenLink?: (url: string, toolCallId: string, toolName: string) => void
|
onOpenLink?: (url: string, toolCallId: string, toolName: string) => void
|
||||||
onTaskApprove?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
onTaskApprove?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
||||||
onTaskReject?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
onTaskReject?: (index: number, toolCallId: string, data?: TaskToolData) => void | Promise<void>
|
||||||
|
onTodoTaskClick?: (request: TodoTaskClickRequest) => void
|
||||||
onPlanTaskClick?: (request: { toolCallId: string; toolName: string; planId: string; taskId: string | number }) => void
|
onPlanTaskClick?: (request: { toolCallId: string; toolName: string; planId: string; taskId: string | number }) => void
|
||||||
onPlanView?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
onPlanView?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
||||||
onPlanDownload?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
onPlanDownload?: (request: { toolCallId: string; toolName: string; planId: string; planName: string }) => void
|
||||||
@@ -156,6 +158,7 @@ export function ToolCallRenderer({
|
|||||||
onOpenLink,
|
onOpenLink,
|
||||||
onTaskApprove,
|
onTaskApprove,
|
||||||
onTaskReject,
|
onTaskReject,
|
||||||
|
onTodoTaskClick,
|
||||||
onPlanTaskClick,
|
onPlanTaskClick,
|
||||||
onPlanView,
|
onPlanView,
|
||||||
onPlanDownload,
|
onPlanDownload,
|
||||||
@@ -188,7 +191,7 @@ export function ToolCallRenderer({
|
|||||||
return withToolOutput(tool, view)
|
return withToolOutput(tool, view)
|
||||||
}
|
}
|
||||||
if (kind === 'todo-write') {
|
if (kind === 'todo-write') {
|
||||||
return <TodoWriteView tool={tool} defaultExpanded={defaultExpanded} />
|
return <TodoWriteView key={`${sessionId ?? ''}:${tool.id}`} tool={tool} sessionId={sessionId} defaultExpanded={defaultExpanded} onTaskClick={onTodoTaskClick} />
|
||||||
}
|
}
|
||||||
if (kind === 'exit-plan') {
|
if (kind === 'exit-plan') {
|
||||||
const plan = normalizeExitPlan(tool)
|
const plan = normalizeExitPlan(tool)
|
||||||
@@ -251,57 +254,6 @@ function ToolOutput({ tool }: { tool: ConversationToolCall }) {
|
|||||||
return <pre className="cr-captured-tool__output">{tool.output}</pre>
|
return <pre className="cr-captured-tool__output">{tool.output}</pre>
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodoWriteView({ tool, defaultExpanded }: { tool: ConversationToolCall; defaultExpanded: boolean }) {
|
|
||||||
const rawInput = parseRecord(tool.args?._rawInput)
|
|
||||||
const data = Object.keys(rawInput).length > 0 ? rawInput : tool.args ?? {}
|
|
||||||
const rawTasks = Array.isArray(data.todos) ? data.todos
|
|
||||||
: Array.isArray(data.steps) ? data.steps
|
|
||||||
: Array.isArray(tool.args?.todos) ? tool.args.todos
|
|
||||||
: Array.isArray(tool.args?.steps) ? tool.args.steps : []
|
|
||||||
const tasks = rawTasks.flatMap((value, index): PlanTodoItem[] => {
|
|
||||||
const task = asRecord(value)
|
|
||||||
const content = firstString(task.content, task.title, task.text, task.step)
|
|
||||||
if (!content) return []
|
|
||||||
return [{
|
|
||||||
id: typeof task.id === 'string' || typeof task.id === 'number' ? task.id : String(index + 1),
|
|
||||||
content,
|
|
||||||
activeForm: firstString(task.activeForm) || undefined,
|
|
||||||
status: firstString(task.status)
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
const completedCount = tasks.filter(task => getPlanTodoStatus(task.status) === 'completed').length
|
|
||||||
const totalCount = typeof data.totalCount === 'number' ? data.totalCount : tasks.length
|
|
||||||
const isRunning = getToolCallPhase(tool.status) === 'running'
|
|
||||||
const isError = getToolCallPhase(tool.status) === 'error'
|
|
||||||
|
|
||||||
return <CapturedToolCard tool={tool} title={t('tool.todo.list')} defaultExpanded={defaultExpanded || tool.isLastContent === true}>
|
|
||||||
{isError
|
|
||||||
? <ToolSummary primary={t('tool.todo.generateError')} />
|
|
||||||
: tasks.length === 0
|
|
||||||
? <ToolSummary primary={t('tool.todo.list')} secondary={isRunning ? t('tool.todo.receiving') : undefined} />
|
|
||||||
: <div className="cr-captured-tool__details">
|
|
||||||
<ToolSummary primary={data.merge === true ? t('tool.todo.progress', { completed: completedCount, total: totalCount }) : t('tool.todo.list')} />
|
|
||||||
<div className="cb-plan-todolist"><div className="cb-plan-todolist-wrapper">
|
|
||||||
{tasks.map(task => {
|
|
||||||
const status = getPlanTodoStatus(task.status)
|
|
||||||
return <div className={`cb-plan-task cb-plan-task--${status}`} key={task.id}>
|
|
||||||
<span className="cb-plan-task-status" aria-hidden="true">{todoStatusMark(status)}</span>
|
|
||||||
<span className="cb-plan-task-text">{status === 'in_progress' ? task.activeForm || task.content : task.content}</span>
|
|
||||||
</div>
|
|
||||||
})}
|
|
||||||
</div></div>
|
|
||||||
</div>}
|
|
||||||
<ToolOutput tool={tool} />
|
|
||||||
</CapturedToolCard>
|
|
||||||
}
|
|
||||||
|
|
||||||
function todoStatusMark(status: ReturnType<typeof getPlanTodoStatus>): string {
|
|
||||||
if (status === 'completed') return 'OK'
|
|
||||||
if (status === 'in_progress') return '>'
|
|
||||||
if (status === 'failed') return 'x'
|
|
||||||
return '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolSummary({ primary, secondary }: { primary: string; secondary?: string }) {
|
function ToolSummary({ primary, secondary }: { primary: string; secondary?: string }) {
|
||||||
return <div className="cr-captured-tool__summary"><strong>{primary}</strong>{secondary && <span>{secondary}</span>}</div>
|
return <div className="cr-captured-tool__summary"><strong>{primary}</strong>{secondary && <span>{secondary}</span>}</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1344,7 +1344,7 @@ const zhCN = {
|
|||||||
"tool.todo.generateError": "生成失败",
|
"tool.todo.generateError": "生成失败",
|
||||||
"tool.todo.list": "任务列表",
|
"tool.todo.list": "任务列表",
|
||||||
"tool.todo.receiving": "接收中...",
|
"tool.todo.receiving": "接收中...",
|
||||||
"tool.todo.progress": "进度更新 {completed}/{total}",
|
"tool.todo.progress": "进度 {completed}/{total}",
|
||||||
"tool.question.waitingAnswer": "等待你的回答...",
|
"tool.question.waitingAnswer": "等待你的回答...",
|
||||||
"tool.question.generating": "问题生成中...",
|
"tool.question.generating": "问题生成中...",
|
||||||
"tool.question.cancelled": "已取消提问",
|
"tool.question.cancelled": "已取消提问",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/* Modern TodoWrite PlanTask contract from REA ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94.
|
||||||
|
See docs/协作/TodoWrite样式-REA核对-2026-10-21.md. Not the older cb-plan-task branch. */
|
||||||
|
.cr-tool-plan-task { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||||
|
.cr-tool-plan-task__row { display: flex; align-items: flex-start; gap: 8px; min-width: 0; border-radius: 8px; padding: 1px 4px 2px; }
|
||||||
|
.cr-tool-plan-task__glyph { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 16px; width: 16px; min-height: 25px; color: var(--cr-text-tertiary); }
|
||||||
|
.cr-tool-plan-task__glyph svg { width: 16px; height: 16px; }
|
||||||
|
.cr-tool-plan-task__pending { display: block; width: 12px; height: 12px; box-sizing: border-box; border: 1px solid currentColor; border-radius: 50%; }
|
||||||
|
.cr-tool-plan-task__text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; line-height: 1.75; color: var(--cr-text-primary); }
|
||||||
|
.cr-tool-plan-task__text--done { color: var(--cr-text-tertiary); text-decoration: line-through; }
|
||||||
|
.cr-tool-plan-task__text--running { font-weight: 600; }
|
||||||
|
.cr-tool-plan-task__glyph--failed { color: var(--cr-status-error); }
|
||||||
|
.cr-tool-plan-task__loader { animation: cr-tool-plan-task-spin 2s linear infinite; }
|
||||||
|
.cr-tool-plan-task__row--clickable { cursor: pointer; }
|
||||||
|
.cr-tool-plan-task__row--clickable:hover { background: var(--cr-bg-hover); }
|
||||||
|
.cr-tool-plan-task__row--clickable:focus-visible { outline: 2px solid var(--cr-text-primary); outline-offset: 1px; }
|
||||||
|
.cr-tool-plan-task__action { display: inline-flex; align-items: center; flex: 0 0 16px; min-height: 25px; color: var(--cr-text-tertiary); }
|
||||||
|
@keyframes cr-tool-plan-task-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .cr-tool-plan-task__loader { animation: none; } }
|
||||||
Reference in new issue
Block a user