From 1ed71b63b5af9a98b942ff47c66df5799d4d2c8a Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 21 Oct 2026 13:41:18 +0900 Subject: [PATCH] fix(B01): retain native card state and restore mode and confirmation styles --- ...pleted-fold-native-identity-regression.tsx | 99 +++++++++++++++++++ scripts/run-alignment-regression.mjs | 5 +- scripts/task-popconfirm-browser-fixture.tsx | 62 ++++++++++++ scripts/task-popconfirm-regression.tsx | 18 +++- scripts/test-mode-chip-native.mjs | 77 +++++++++++++++ scripts/test-mode-chip.mjs | 47 +++++++++ scripts/test-task-popconfirm-browser.mjs | 25 +++++ scripts/verify-task-menu.cjs | 27 ++++- .../src/components/TaskPopconfirm.tsx | 8 +- .../src/components/task-popconfirm.css | 15 +-- .../src/workbuddy/composer/ModeChip.tsx | 14 ++- .../src/workbuddy/composer/mode-chip.css | 11 ++- .../workbuddy/conversation/CompletedFold.tsx | 13 +-- .../workbuddy/conversation/completed-fold.ts | 23 +++-- 14 files changed, 407 insertions(+), 37 deletions(-) create mode 100644 scripts/completed-fold-native-identity-regression.tsx create mode 100644 scripts/task-popconfirm-browser-fixture.tsx create mode 100644 scripts/test-mode-chip-native.mjs create mode 100644 scripts/test-mode-chip.mjs create mode 100644 scripts/test-task-popconfirm-browser.mjs diff --git a/scripts/completed-fold-native-identity-regression.tsx b/scripts/completed-fold-native-identity-regression.tsx new file mode 100644 index 0000000..886b4ce --- /dev/null +++ b/scripts/completed-fold-native-identity-regression.tsx @@ -0,0 +1,99 @@ +/** F01 acceptance reproduction extended through the real reducer and MessageBubble. + * No Electron, model, SDK, or external service; DOM behavior, not visual parity. + */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import type { ChatMessage, ChatEvent, MessagePart } from '../src/shared/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']) 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 }) +const { act } = require('react') as typeof import('react') +const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') +const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default +const { applyAssistantEvent } = require('../src/shared/message-parts') as typeof import('../src/shared/message-parts') +const { completedFoldPartIdentity } = require('../src/renderer/src/workbuddy/conversation/completed-fold') as typeof import('../src/renderer/src/workbuddy/conversation/completed-fold') +const root = createRoot(document.getElementById('root')!) +const plan = '.cb-plan-header-left' +const task = '.cb-task-result-item-title' +const outer = '.cr-collapse--completed > .cr-collapse__header' +const nodes = (selector: string) => [...document.querySelectorAll(selector)] +const get = (selector: string, index = 0) => { const node = nodes(selector)[index]; assert.ok(node, `${selector}[${index}]`); return node } +const states = () => ({ plan: nodes(plan).map(node => node.getAttribute('aria-expanded')), task: nodes(task).map(node => node.getAttribute('aria-expanded')) }) +async function render(message: ChatMessage) { await act(async () => root.render()) } +async function click(selector: string, index = 0) { await act(async () => get(selector, index).dispatchEvent(new MouseEvent('click', { bubbles: true }))) } +type EventInput = ChatEvent extends infer E ? E extends ChatEvent ? Omit : never : never +const event = (value: EventInput) => ({ runId: 'native-identity', ...value }) as ChatEvent +const planPart = (id: string): Extract => ({ type: 'plan', plan: { id, title: `Plan ${id}`, steps: [] } }) +const taskPart = (id: string): Extract => ({ type: 'task', task: { id, title: `Task ${id}`, status: 'success', summary: `Result ${id}` } }) +async function main() { + // All native business IDs, including non-disclosure image/artifact parts, are stable. + const stableParts: MessagePart[] = [planPart('same'), taskPart('same'), { type: 'artifact', artifact: { id: 'same', name: 'File', path: '/file' } }, { type: 'image', id: 'same', url: '/image' }, { type: 'reasoning', id: 'same', text: 'Thought' }, { type: 'tool', toolId: 'same' }] + const identities = stableParts.map(part => completedFoldPartIdentity(part, 1)) + assert.equal(new Set(identities).size, stableParts.length, 'part types cannot share identity') + stableParts.forEach((part, i) => assert.equal(completedFoldPartIdentity(part, 99), identities[i], `${part.type} ignores position`)) + assert.notEqual(completedFoldPartIdentity(planPart('1'), 1), completedFoldPartIdentity({ type: 'plan', plan: {} }, 1), 'business IDs cannot collide with fallback indices') + assert.equal(completedFoldPartIdentity({ type: 'future', id: 'native' }, 1), completedFoldPartIdentity({ type: 'future', id: 'native' }, 2), 'unknown top-level IDs remain supported') + + let message: ChatMessage = { id: 'native-one', role: 'assistant', createdAt: 1, content: '', status: 'streaming', parts: [] } + // Exact acceptance event sequence: two text chunks separated by reasoning. + const streamingEvents: EventInput[] = [ + { type: 'text', delta: 'Draft one' }, + { type: 'part', part: { type: 'reasoning', id: 'r', text: 'Thought', finished: true } }, + { type: 'text', delta: 'Draft two' }, + { type: 'part', part: planPart('p') }, + { type: 'part', part: taskPart('x') } + ] + for (const value of streamingEvents) message = applyAssistantEvent(message, event(value)) + assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'text', 'plan', 'task']) + await render(message) + assert.deepEqual(states(), { plan: ['true'], task: ['true'] }) + await click(plan); await click(task) + const collapsed = { plan: ['false'], task: ['false'] } + assert.deepEqual(states(), collapsed) + message = applyAssistantEvent(message, event({ type: 'done', content: 'Corrected final prose', tools: [], turnDurationMs: 1000 })) + assert.deepEqual(message.parts?.map(part => part.type), ['text', 'reasoning', 'plan', 'task'], 'real done reducer coalesces text') + await render(message) + assert.deepEqual(states(), collapsed, 'terminal correction preserves choices') + for (let repeat = 0; repeat < 2; repeat++) { + await click(outer); assert.deepEqual(states(), collapsed, 'outer expansion preserves choices') + await click(outer); assert.deepEqual(states(), collapsed, 'outer collapse preserves choices') + } + // Insert new same-kind cards before originals, then reorder. New cards must not inherit state. + const prefix = message.parts!.filter(part => part.type !== 'plan' && part.type !== 'task') + message = { ...message, parts: [...prefix, planPart('p2'), taskPart('x2'), planPart('p'), taskPart('x')] } + await render(message) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['true', 'false'] }, 'insertions retain original identity only') + await click(plan, 1) // explicitly open original; keep task closed + await click(plan, 0) // close new plan; leave new task open + message = { ...message, parts: [taskPart('x'), planPart('p'), ...prefix, taskPart('x2'), planPart('p2')] } + await render(message) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'true'] }, 'reorder retains independent choices') + await click(task, 1) + await click(outer); await click(outer) + assert.deepEqual(states(), { plan: ['true', 'false'], task: ['false', 'false'] }, 'reordered choices survive outer reparenting') + await render({ ...message, id: 'native-two' }) + assert.deepEqual(states(), { plan: ['true', 'true'], task: ['true', 'true'] }, 'same part IDs in another message are isolated') + assert.equal(get(outer).getAttribute('aria-expanded'), 'false') + + // Legacy persisted payloads may predate required ID fields. Keep their positional fallback, + // without promising identity across arbitrary insertions when no business identity exists. + const legacyParts = [planPart(''), taskPart('')].map(part => { + const copy = structuredClone(part) as any + delete copy[copy.type].id + return copy + }) as MessagePart[] + const legacy = { ...message, id: 'legacy', parts: [...prefix, ...legacyParts] } + await render(legacy) + await click(plan); await click(task) + await render(structuredClone(legacy)) + await click(outer); await click(outer) + assert.deepEqual(states(), collapsed, 'no-ID old history retains choice at the same position') + assert.notEqual(completedFoldPartIdentity(legacyParts[0], 1), completedFoldPartIdentity(legacyParts[0], 2), 'no-ID fallback remains positional') + await render({ ...legacy, id: 'legacy-other' }) + assert.deepEqual(states(), { plan: ['true'], task: ['true'] }, 'legacy fallback remains message-isolated') + await act(async () => root.unmount()); dom.window.close() + console.log('completed-fold-native-identity: real reducer done correction, native IDs, insertion/reorder, repeated outer toggles, message isolation and legacy fallback passed') +} +main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) }) diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs index ea0af70..1ec50cc 100644 --- a/scripts/run-alignment-regression.mjs +++ b/scripts/run-alignment-regression.mjs @@ -24,11 +24,14 @@ 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 (process.env.YCODE_TEST_DOM_MODULE) { - for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'permission-intercept-dom-regression']) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-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 }) if (result.error) throw result.error if (result.status !== 0) process.exit(result.status ?? 1) } + const modeChip = spawnSync(process.execPath, ['scripts/test-mode-chip.mjs'], { stdio: 'inherit', timeout: 120000 }) + if (modeChip.error) throw modeChip.error + if (modeChip.status !== 0) process.exit(modeChip.status ?? 1) } diff --git a/scripts/task-popconfirm-browser-fixture.tsx b/scripts/task-popconfirm-browser-fixture.tsx new file mode 100644 index 0000000..6debf6d --- /dev/null +++ b/scripts/task-popconfirm-browser-fixture.tsx @@ -0,0 +1,62 @@ +/** Real Chromium fixture, isolated callbacks only: no IPC, sessions, models, or original app. */ +import React from 'react' +import { createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import '../src/renderer/src/workbuddy/typography/tokens.css' +import '../src/renderer/src/styles/tokens.css' +import '../src/renderer/src/ui/tokens.css' +import '../src/renderer/src/styles/app.css' +import '../src/renderer/src/styles/sidebar.css' +import '../src/renderer/src/workbuddy/conversation/source-styles.css' +import '../src/renderer/src/workbuddy/conversation/source-adapters.css' +import '../src/renderer/src/components/workspace-source.css' +import '../src/renderer/src/components/source-select/source-select-recovered.css' +import '../src/renderer/src/components/source-select/source-select.css' +import { TaskPopconfirm } from '../src/renderer/src/components/TaskPopconfirm' +const root = createRoot(document.getElementById('root')!) +const anchor = document.getElementById('anchor')! +const results: unknown[] = [] +const pause = () => new Promise(resolve => setTimeout(resolve, 200)) +function check(actual: unknown, expected: unknown, label: string) { + results.push({label, actual, expected}) + if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label + ': ' + JSON.stringify(actual)) +} +const button = (name: string) => document.querySelector('.task-popconfirm__' + name)! +async function run() { + for (const theme of ['light', 'dark']) for (const kind of ['archive', 'delete'] as const) for (const running of (kind === 'delete' ? [false, true] : [false])) { + document.documentElement.dataset.theme = theme + let calls = 0, closes = 0, resolve!: () => void, reject!: (cause: Error) => void + const close = () => { closes++; flushSync(() => root.render(null)) } + flushSync(() => root.render( { calls++; return new Promise((yes, no) => { resolve = yes; reject = no }) }} />)) + await pause() + const label = `${theme}/${kind}/${running}` + const dark = theme === 'dark' + const confirm = button('confirm'), cancel = button('cancel') + for (const node of [confirm, cancel]) { + const s = getComputedStyle(node) + check([s.minWidth, s.paddingLeft, s.paddingRight, s.height, s.borderRadius, s.fontSize], ['66px', '19px', '19px', '28px', '6px', '12px'], label + ' geometry') + } + const primary = getComputedStyle(confirm), secondary = getComputedStyle(cancel) + check([primary.backgroundColor, primary.color], kind === 'delete' ? [dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)', 'rgb(255, 255, 255)'] : dark ? ['rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.9)'] : ['rgba(0, 0, 0, 0.9)', 'rgb(255, 255, 255)'], label + ' primary/danger colors') + check([secondary.backgroundColor, secondary.borderTopWidth, secondary.borderTopStyle, secondary.borderTopColor], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', '1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)'], label + ' secondary colors/border') + const popup = document.querySelector('.task-popconfirm')! + check([getComputedStyle(popup).width, getComputedStyle(popup).borderRadius, getComputedStyle(document.querySelector('.task-popconfirm__title')!).fontWeight], ['240px', '16px', '600'], label + ' shell') + check(getComputedStyle(popup).backgroundColor, dark ? 'rgb(36, 36, 36)' : 'rgb(255, 255, 255)', label + ' shell background') + check(document.activeElement === confirm, true, label + ' initial focus') + if (running) check(confirm.textContent, '停止并删除', label + ' running delete semantic') + confirm.click(); confirm.click(); await pause() + check([calls, confirm.disabled, cancel.disabled, confirm.textContent], [1, true, true, '处理中…'], label + ' busy prevents duplicates') + document.dispatchEvent(new KeyboardEvent('keydown', {key:'Escape', bubbles:true})); cancel.click() + check(closes, 0, label + ' busy cannot dismiss') + if (kind === 'delete') check(getComputedStyle(confirm).backgroundColor, dark ? 'rgb(128, 56, 56)' : 'rgb(245, 185, 185)', label + ' danger busy background') + reject(new Error('fixture failure')); await pause() + check(document.querySelector('[role="alert"]')?.textContent, 'fixture failure', label + ' failure retained') + check(confirm.disabled, false, label + ' retry enabled') + confirm.click(); await pause(); resolve(); await pause() + check([calls, closes, !!document.querySelector('.task-popconfirm')], [2, 1, false], label + ' retry success') + } +} +run().then(() => finish(true), error => finish(false, String(error))) +function finish(pass: boolean, error?: string) { + const output = document.createElement('pre'); output.id = 'result'; output.textContent = JSON.stringify({pass, error, results}); document.body.append(output) +} diff --git a/scripts/task-popconfirm-regression.tsx b/scripts/task-popconfirm-regression.tsx index ac6d8b8..414d045 100644 --- a/scripts/task-popconfirm-regression.tsx +++ b/scripts/task-popconfirm-regression.tsx @@ -41,7 +41,7 @@ async function main() { check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title') check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses') check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy') - check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit') + check(!document.querySelector('.task-popconfirm__compatibility'), 'archive preserves selected original copy without extra explanation') const confirm = document.querySelector('.task-popconfirm__confirm')! await act(async () => { click(confirm); click(confirm) }) check(calls === 1, 'duplicate confirmation guarded synchronously') @@ -56,6 +56,22 @@ async function main() { await act(async () => resolve!()) check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes') await render('delete') + await act(async () => root.render(null)) + await act(async () => root.render()); await settle() + const runningConfirm = document.querySelector('.task-popconfirm__confirm')! + const runningCancel = document.querySelector('.task-popconfirm__cancel')! + check(runningConfirm.textContent === '停止并删除', 'running delete uses stop-and-delete action') + const beforeRunning = calls, beforeRunningClose = closes + await act(async () => { click(runningConfirm); click(runningConfirm); click(runningCancel) }) + check(calls === beforeRunning + 1 && closes === beforeRunningClose, 'running delete is single-flight and cancellation locked') + check(runningConfirm.disabled && runningCancel.disabled && runningConfirm.textContent === '处理中…', 'running delete busy buttons remain readable and disabled') + await act(async () => reject!(new Error('stop failed'))) + check(document.querySelector('[role="alert"]')?.textContent === 'stop failed', 'stop-and-delete failure retained') + check(!runningConfirm.disabled && runningConfirm.textContent === '停止并删除', 'stop-and-delete failure restores action') + await act(async () => click(runningConfirm)) + await act(async () => resolve!()) + check(calls === beforeRunning + 2 && closes === beforeRunningClose + 1, 'stop-and-delete retry succeeds once') + await render('delete') let underlyingClicks = 0 other.addEventListener('click', () => underlyingClicks++) await act(async () => { diff --git a/scripts/test-mode-chip-native.mjs b/scripts/test-mode-chip-native.mjs new file mode 100644 index 0000000..11d4ca9 --- /dev/null +++ b/scripts/test-mode-chip-native.mjs @@ -0,0 +1,77 @@ +/** Own-YCode isolated component fixture. Run on a permitted Electron desktop: + * node scripts/test-mode-chip-native.mjs + * No original app, model, SDK, user data, or production session access. + */ +import fs from 'node:fs' +import path from 'node:path' +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { spawn } from 'node:child_process' +import { createHash } from 'node:crypto' +const require = createRequire(import.meta.url), module = require('electron') +const base = path.resolve('.tmp') +fs.mkdirSync(base, { recursive: true }) +const file = 'src/renderer/src/workbuddy/composer/ModeChip.tsx' +async function prepareFixture(output) { + const src = relative => JSON.stringify(path.resolve(relative).replaceAll('\\', '/')) + fs.writeFileSync(path.join(output, 'fixture.tsx'), ` +import React,{useState} from 'react'; import {createRoot} from 'react-dom/client'; +import {ModeChip} from ${src(file)}; +import ${src('src/renderer/src/workbuddy/typography/tokens.css')}; +import ${src('src/renderer/src/styles/tokens.css')}; +import ${src('src/renderer/src/ui/tokens.css')}; +import ${src('src/renderer/src/styles/app.css')}; +import ${src('src/renderer/src/workbuddy/conversation/source-styles.css')}; +const q=(window as any).qa={clears:0}; +function Fixture(){const [mode,setMode]=useState('plan'),[disabled,setDisabled]=useState(false);Object.assign(q,{setMode,setDisabled});return
{q.clears++;setMode('craft')}}/>
};createRoot(document.getElementById('root')!).render();`) + await require('esbuild').build({ entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', jsx: 'automatic', outfile: path.join(output, 'fixture.js'), logLevel: 'warning' }) + fs.writeFileSync(path.join(output, 'index.html'), '
') +} +if (!process.versions.electron) { + const output = fs.mkdtempSync(path.join(base, 'mode-chip-native-')), env = { ...process.env } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + if (process.argv.includes('--build-only')) { + await prepareFixture(output); console.log(JSON.stringify({ output, fixtureBuilt: true, nativeExecuted: false })) + } else { + const child = spawn(module, [path.resolve('scripts/test-mode-chip-native.mjs'), output], { env, windowsHide: true, stdio: 'inherit' }) + const timeout = setTimeout(() => child.kill(), 60000) + child.on('error', error => { clearTimeout(timeout); console.error(error); process.exitCode = 1 }) + child.on('exit', code => { clearTimeout(timeout); process.exitCode = code ?? 1; console.log(JSON.stringify({ output, exitCode: code })) }) + } +} else { + const { app, BrowserWindow } = module, output = path.resolve(process.argv.at(-1)) + assert.ok(path.dirname(output) === base && path.basename(output).startsWith('mode-chip-native-')) + fs.mkdirSync(path.join(output, 'data')); app.setPath('userData', path.join(output, 'data')); app.disableHardwareAcceleration() + let win; const checks = [], samples = [], errors = [] + try { + await prepareFixture(output) + await app.whenReady(); win = new BrowserWindow({ width: 800, height: 300, show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } }) + win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) }) + await win.loadFile(path.join(output, 'index.html')); win.showInactive() + const read = code => win.webContents.executeJavaScript(code), pause = () => new Promise(resolve => setTimeout(resolve, 80)) + const check = (label, actual, expected = true) => { assert.deepEqual(actual, expected, label); checks.push(label) } + const key = async keyCode => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode }); await pause() } + const sample = () => read(`(()=>{const c=document.querySelector('.yc-mode-chip'),i=c.querySelector('.yc-mode-chip__mode-icon'),b=c.querySelector('button'),s=getComputedStyle(c),r=e=>{const x=e.getBoundingClientRect();return{x:x.x,y:x.y,width:x.width,height:x.height}};return{chip:r(c),icon:r(i),button:b?r(b):null,background:s.backgroundColor,radius:s.borderRadius,color:s.color,expectedColor:s.getPropertyValue('--cr-text-secondary').trim(),font:s.fontSize,weight:s.fontWeight,line:s.lineHeight,padding:s.paddingLeft,iconOpacity:getComputedStyle(i).opacity,clearOpacity:b?getComputedStyle(b).opacity:null}})()`) + await pause() + for (const theme of ['light', 'dark']) for (const mode of ['plan', 'ask']) { + await read(`document.documentElement.dataset.theme='${theme}'; document.querySelector('.chat-pane').dataset.crTheme='${theme}'; qa.setMode('${mode}');qa.setDisabled(false);document.querySelector('#before').focus()`) + win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await pause() + const normal = await sample(); samples.push({ theme, mode, state: 'normal', ...normal }) + check('transparent', normal.background, 'rgba(0, 0, 0, 0)'); check('pill', parseFloat(normal.radius) >= normal.chip.height / 2) + check('height', normal.chip.height, 32); check('font', normal.font, '13px'); check('weight', normal.weight, '500'); check('line', normal.line, '20px'); check('padding', normal.padding, '8px') + check('same slot', normal.icon, normal.button); check('icon width', normal.icon.width, 16); check('default icon', normal.iconOpacity, '1'); check('default close', normal.clearOpacity, '0') + check('secondary color', await read(`(()=>{const c=document.querySelector('.yc-mode-chip'),p=document.createElement('span');p.style.color='var(--cr-text-secondary)';c.append(p);const ok=getComputedStyle(p).color===getComputedStyle(c).color;p.remove();return ok})()`)) + win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(normal.chip.x + normal.chip.width / 2), y: Math.round(normal.chip.y + normal.chip.height / 2) }); await pause() + const hover = await sample(); samples.push({ theme, mode, state: 'hover', ...hover }); check('hover icon hidden', hover.iconOpacity, '0'); check('hover X', hover.clearOpacity, '1'); check('no hover layout shift', hover.chip, normal.chip) + win.webContents.sendInputEvent({ type: 'mouseMove', x: 1, y: 1 }); await read(`document.querySelector('.yc-mode-chip').focus()`); await pause() + const focus = await sample(); check('focus icon hidden', focus.iconOpacity, '0'); check('focus X', focus.clearOpacity, '1'); check('no focus layout shift', focus.chip, normal.chip) + await fs.promises.writeFile(path.join(output, `${theme}-${mode}-focus.png`), (await win.webContents.capturePage()).toPNG()) + await key('Tab'); check('Tab reaches native clear', await read(`document.activeElement.matches('.yc-mode-chip__clear')`)) + const before = await read('qa.clears'); await key(mode === 'plan' ? 'Enter' : 'Space'); check('native keyboard exit', await read('qa.clears'), before + 1); check('craft hidden', await read(`!document.querySelector('.yc-mode-chip')`)) + await read(`qa.setMode('${mode}');qa.setDisabled(true)`); await pause(); check('disabled has no clear', await read(`!document.querySelector('.yc-mode-chip button')`)); check('disabled not tabbable', await read(`document.querySelector('.yc-mode-chip').tabIndex`), -1) + } + win.setSize(320, 300); await read(`qa.setMode('ask');qa.setDisabled(false)`); await pause(); const narrow = await sample(); check('narrow slot preserved', narrow.icon, narrow.button); check('narrow no overflow', narrow.chip.x + narrow.chip.width <= 320) + await fs.promises.writeFile(path.join(output, 'narrow.png'), (await win.webContents.capturePage()).toPNG()); check('no renderer errors', errors, []) + fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ checks, samples, errors, sourceSha256: createHash('sha256').update(fs.readFileSync(file)).digest('hex'), scope: 'Own-YCode isolated component browser test, not full app/store integration or original-app pixel acceptance' }, null, 2)); app.exit(0) + } catch (error) { fs.writeFileSync(path.join(output, 'failure.txt'), String(error.stack || error)); console.error(error); app.exit(1) } +} diff --git a/scripts/test-mode-chip.mjs b/scripts/test-mode-chip.mjs new file mode 100644 index 0000000..2868a45 --- /dev/null +++ b/scripts/test-mode-chip.mjs @@ -0,0 +1,47 @@ +/** Isolated DOM regression; not a browser layout/pixel or original-app acceptance test. */ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { build } from 'esbuild' +const require = createRequire(import.meta.url) +const { JSDOM } = require(process.env.YCODE_TEST_DOM_MODULE || process.env.YCODE_JSDOM_PATH || 'jsdom') +const output = fs.mkdtempSync(path.resolve('.tmp/mode-chip-dom-')) +await build({ entryPoints: ['src/renderer/src/workbuddy/composer/ModeChip.tsx'], bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', packages: 'external', loader: { '.css': 'empty' }, outfile: path.join(output, 'component.cjs'), logLevel: 'error' }) +const dom = new JSDOM('
', { url: 'http://localhost' }) +Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }) +const React = require('react'), { createRoot } = require('react-dom/client') +const { ModeChip } = require(path.join(output, 'component.cjs')) +const css = fs.readFileSync('src/renderer/src/workbuddy/composer/mode-chip.css', 'utf8') +const style = document.createElement('style'); style.textContent = css; document.head.append(style) +const root = createRoot(document.getElementById('root')); let clears = 0, checks = 0 +const check = (actual, expected, message) => { assert.deepEqual(actual, expected, message); checks++ } +const render = async (mode, disabled = false) => React.act(async () => root.render(React.createElement(ModeChip, { mode, disabled, onClear: () => { clears++ } }))) +for (const mode of ['plan', 'ask']) { + await render(mode) + const chip = document.querySelector('.yc-mode-chip'), slot = chip.querySelector('.yc-mode-chip__icon-slot'), button = slot.querySelector('button'), icon = slot.querySelector('.yc-mode-chip__mode-icon') + check(chip.textContent, mode === 'plan' ? '计划' : '仅问答', 'mode label has no extra X text') + check(chip.getAttribute('role'), 'group', 'chip remains a group, not a menu') + check(button.type, 'button', 'clear cannot submit composer') + check(button.getAttribute('aria-label'), `退出${chip.textContent}模式`, 'accessible exit name') + check(icon.parentElement === button.parentElement, true, 'icon and close share one slot') + check(dom.window.getComputedStyle(slot).display, 'grid', 'shared grid slot') + check(dom.window.getComputedStyle(button).gridArea, '1 / 1', 'button occupies original icon cell') + check(dom.window.getComputedStyle(icon).gridArea, '1 / 1', 'mode occupies same original cell') + check(dom.window.getComputedStyle(button).position, 'static', 'no floating absolute close') + check(dom.window.getComputedStyle(chip).backgroundColor, 'rgba(0, 0, 0, 0)', 'transparent background') + await React.act(async () => button.focus()) + check(document.activeElement === button, true, 'native button receives keyboard focus') + const before = clears + await React.act(async () => button.click()) + check(clears, before + 1, 'activation calls existing host clear exactly once') + await render(mode, true) + check(document.querySelector('button'), null, 'disabled mode exposes no clear action') + check(document.querySelector('.yc-mode-chip').getAttribute('aria-disabled'), 'true', 'disabled state is announced') + check(document.querySelector('.yc-mode-chip').tabIndex, -1, 'disabled mode not in keyboard tab order') +} +await render('craft'); check(document.querySelector('.yc-mode-chip'), null, 'craft stays hidden') +// CSS contracts complement DOM checks; test-mode-chip-native.mjs is an unexecuted native hover/focus/layout probe. +for (const contract of ['border-radius:var(--yc-radius-full)', 'color:var(--cr-text-secondary)', ':hover .yc-mode-chip__mode-icon', ':focus-within .yc-mode-chip__mode-icon', ':hover .yc-mode-chip__clear', ':focus-within .yc-mode-chip__clear']) check(css.includes(contract), true, contract) +await React.act(async () => root.unmount()); dom.window.close() +console.log(JSON.stringify({ checks, passed: true, scope: 'Isolated React DOM and CSS contracts; no native keyboard/layout/pixel acceptance' })) diff --git a/scripts/test-task-popconfirm-browser.mjs b/scripts/test-task-popconfirm-browser.mjs new file mode 100644 index 0000000..92b07a6 --- /dev/null +++ b/scripts/test-task-popconfirm-browser.mjs @@ -0,0 +1,25 @@ +/** Build and run real-browser computed-style checks; no jsdom style substitute. + * CHROMIUM_PATH=/path/to/chrome node scripts/test-task-popconfirm-browser.mjs + * --build-only creates a portable HTML fixture for an available browser. + * Chromium sandbox remains enabled. Runtime failure is NOT a passing test. + */ +import { build } from 'esbuild' +import { mkdtemp, writeFile, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { spawnSync } from 'node:child_process' +const dir = await mkdtemp(resolve('.tmp/task-popconfirm-browser-')) +await build({ entryPoints: ['scripts/task-popconfirm-browser-fixture.tsx'], bundle: true, platform: 'browser', format: 'iife', jsx: 'automatic', outfile: dir + '/fixture.js', loader: {'.svg':'dataurl', '.woff2':'dataurl', '.woff':'dataurl'}, logLevel: 'warning' }) +const js = await readFile(dir + '/fixture.js', 'utf8'), css = await readFile(dir + '/fixture.css', 'utf8') +await writeFile(dir + '/index.html', `
`) +console.log('Browser fixture: ' + dir + '/index.html') +if (!process.argv.includes('--build-only')) { + const proc = spawnSync(process.env.CHROMIUM_PATH || 'chromium', ['--headless', '--disable-gpu', '--no-first-run', '--user-data-dir=' + dir + '/profile', '--virtual-time-budget=20000', '--dump-dom', pathToFileURL(dir + '/index.html').href], { encoding:'utf8', timeout:60000, maxBuffer:20*1024*1024 }) + await writeFile(dir + '/browser.log', (proc.stderr || '') + '\n' + (proc.error || '')) + const match = proc.stdout?.match(/
([\s\S]*?)<\/pre>/)
+  if (proc.status !== 0 || !match) throw Error(`Browser did not finish; status=${proc.status}; see ${dir}/browser.log`)
+  const report = JSON.parse(match[1].replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>').replaceAll('&', '&'))
+  await writeFile(dir + '/report.json', JSON.stringify(report, null, 2))
+  if (!report.pass) throw Error(report.error)
+  console.log(`Task confirmation real Chromium: ${report.results.length} assertions passed; ${dir}/report.json`)
+}
diff --git a/scripts/verify-task-menu.cjs b/scripts/verify-task-menu.cjs
index 9620f73..2513072 100644
--- a/scripts/verify-task-menu.cjs
+++ b/scripts/verify-task-menu.cjs
@@ -66,6 +66,27 @@ if (!process.versions.electron) {
     const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false })
     await fs.writeFile(path.join(directory, name + '.png'), Buffer.from(capture.data, 'base64'))
   }
+  // Assert styles in the real body portal, not just variant class names.
+  async function checkConfirmationStyles(kind) {
+    const previous = await read('document.documentElement.dataset.theme || "light"')
+    const setTheme = async theme => {
+      await read(`(()=>{localStorage.setItem('ycode:theme',${JSON.stringify(theme)});window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}))})()`)
+      await wait(`document.documentElement.dataset.theme === ${JSON.stringify(theme)}`, 'application theme synchronization')
+      await pause(400)
+    }
+    for (const theme of ['light', 'dark']) {
+      await setTheme(theme)
+      const values = await read(`(()=>{const style=name=>{const s=getComputedStyle(document.querySelector('.task-popconfirm__'+name));return {background:s.backgroundColor,color:s.color,border:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],geometry:[s.minWidth,s.paddingLeft,s.paddingRight,s.height,s.borderRadius,s.fontSize]}};return {cancel:style('cancel'),confirm:style('confirm'),titleWeight:getComputedStyle(document.querySelector('.task-popconfirm__title')).fontWeight}})()`)
+      const dark = theme === 'dark'
+      check(kind+' '+theme+' secondary background/border', [values.cancel.background, values.cancel.border], [dark ? 'rgb(38, 38, 38)' : 'rgb(255, 255, 255)', ['1px', 'solid', dark ? 'rgb(61, 61, 61)' : 'rgb(222, 222, 222)']])
+      check(kind+' '+theme+' primary/danger background', values.confirm.background, kind === 'delete' ? (dark ? 'rgb(175, 25, 26)' : 'rgb(232, 92, 92)') : (dark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'))
+      check(kind+' '+theme+' confirmation foreground', values.confirm.color, kind === 'archive' && dark ? 'rgba(0, 0, 0, 0.9)' : 'rgb(255, 255, 255)')
+      for (const action of ['confirm', 'cancel']) check(kind+' '+theme+' '+action+' geometry', values[action].geometry, ['66px', '19px', '19px', '28px', '6px', '12px'])
+      check(kind+' '+theme+' title weight', values.titleWeight, '600')
+      await shot(kind+'-confirm-'+theme)
+    }
+    await setTheme(previous)
+  }
   // 真实用户路径:先悬停行让操作按钮显形,再点击按钮。
   const openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
   const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
@@ -167,9 +188,10 @@ if (!process.versions.electron) {
     const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
     check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
     check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
-    check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
+    check('archive does not append unrequested compatibility copy', await read(`!!document.querySelector('.task-popconfirm__compatibility')`), false)
     check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
-    check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
+    check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '16px'])
+    await checkConfirmationStyles('archive')
     await shot('archive-confirm')
     await read(`document.querySelector('.task-popconfirm__cancel').click()`)
     await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
@@ -195,6 +217,7 @@ if (!process.versions.electron) {
     check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
     check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
     check('idle delete is not marked as running', deleteConfirm.running, null)
+    await checkConfirmationStyles('delete')
     await shot('delete-confirm')
     await read(`document.querySelector('.task-popconfirm__confirm').click()`)
     await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
diff --git a/src/renderer/src/components/TaskPopconfirm.tsx b/src/renderer/src/components/TaskPopconfirm.tsx
index 685e2a4..10be730 100644
--- a/src/renderer/src/components/TaskPopconfirm.tsx
+++ b/src/renderer/src/components/TaskPopconfirm.tsx
@@ -1,4 +1,5 @@
 import { useEffect, useId, useRef, useState } from 'react'
+import { Button } from '../ui/Button'
 import { Popover } from '../ui/Popover'
 import './task-popconfirm.css'
 
@@ -49,12 +50,11 @@ export function TaskPopconfirm({ kind, running, anchor, onConfirm, onClose }: Ta
     

{copy.title}

{copy.description}

- {kind === 'archive' &&

YCode 暂无此设置入口,请从侧栏“已归档”查看和恢复。

} {error && }
- - + +
diff --git a/src/renderer/src/components/task-popconfirm.css b/src/renderer/src/components/task-popconfirm.css index af0e8a7..4da600f 100644 --- a/src/renderer/src/components/task-popconfirm.css +++ b/src/renderer/src/components/task-popconfirm.css @@ -4,7 +4,7 @@ width: calc(var(--yc-space-10) * 5); max-width: calc(100vw - var(--yc-space-5)); padding: var(--yc-space-5); - border-radius: var(--yc-radius-xl); + border-radius: var(--yc-radius-2xl); overflow: visible; color: var(--wb-color-text-primary); background: var(--wb-bg-popover); @@ -14,17 +14,20 @@ margin: 0 0 var(--yc-space-3); font-size: var(--yc-font-size-5); line-height: var(--yc-line-height-5-2); - font-weight: 500; + font-weight: var(--yc-font-weight-600); } -.task-popconfirm__description, -.task-popconfirm__compatibility { +.task-popconfirm__description { margin: 0; color: var(--wb-color-text-secondary); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); overflow-wrap: anywhere; } -.task-popconfirm__compatibility { margin-top: var(--yc-space-3); color: var(--wb-color-text-tertiary); } .task-popconfirm__error { margin-top: var(--yc-space-3); color: var(--wb-color-text-error-default); font-size: var(--yc-font-size-4); line-height: var(--yc-line-height-5); } .task-popconfirm__actions { display: flex; justify-content: flex-end; gap: var(--yc-space-3); margin-top: var(--yc-space-5); } -.task-popconfirm-source .task-popconfirm__actions .wb-button { min-width: calc(var(--yc-space-10) + var(--yc-space-2)); height: var(--yc-line-height-7); padding: 0 var(--yc-space-3); } +.task-popconfirm-source .task-popconfirm__actions .yc-button { + /* Recorded minimum width and inset, composed from existing tokens. */ + min-width: calc(var(--yc-size-17) - var(--yc-border-width) * 2); + height: var(--yc-line-height-7); + padding: 0 calc(var(--yc-space-6) - var(--yc-border-width)); +} diff --git a/src/renderer/src/workbuddy/composer/ModeChip.tsx b/src/renderer/src/workbuddy/composer/ModeChip.tsx index 36435f6..f21782a 100644 --- a/src/renderer/src/workbuddy/composer/ModeChip.tsx +++ b/src/renderer/src/workbuddy/composer/ModeChip.tsx @@ -5,10 +5,14 @@ import './mode-chip.css' export function ModeChip({ mode, disabled, onClear }: { mode: AgentMode; disabled?: boolean; onClear(): void | Promise }): React.JSX.Element | null { if (mode === 'craft') return null const label = mode === 'plan' ? '计划' : '仅问答' - return - {label} - {!disabled && } + return + + + {!disabled && } + {label} } diff --git a/src/renderer/src/workbuddy/composer/mode-chip.css b/src/renderer/src/workbuddy/composer/mode-chip.css index 838c12e..6f2432c 100644 --- a/src/renderer/src/workbuddy/composer/mode-chip.css +++ b/src/renderer/src/workbuddy/composer/mode-chip.css @@ -1,6 +1,9 @@ -.yc-mode-chip { position:relative; display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-space-3); background:var(--cr-bg-tertiary); color:var(--cr-text-primary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; } -.yc-mode-chip > svg { flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip { display:inline-flex; flex-shrink:0; align-items:center; gap:var(--yc-space-2); height:var(--yc-space-8); padding:0 var(--yc-space-3); border-radius:var(--yc-radius-full); background:transparent; color:var(--cr-text-secondary); font-size:var(--yc-font-size-4); line-height:var(--yc-line-height-5); font-weight:500; } +.yc-mode-chip__icon-slot { display:grid; flex-shrink:0; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip__mode-icon, .yc-mode-chip__clear { grid-area:1 / 1; width:var(--yc-space-5); height:var(--yc-space-5); } +.yc-mode-chip__clear > svg { width:100%; height:100%; } .yc-mode-chip:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); outline-offset:var(--yc-space-1); } -.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; width:var(--yc-space-5); height:var(--yc-space-5); padding:0; border:0; border-radius:50%; background:var(--cr-bg-tertiary); color:inherit; font-size:var(--yc-space-5); cursor:pointer; opacity:0; position:absolute; right:calc(-1 * var(--wb-spacing-2-5)); top:calc(-1.5 * var(--yc-space-1)); } -.yc-mode-chip:hover .yc-mode-chip__clear, .yc-mode-chip:focus-within .yc-mode-chip__clear { opacity:1; } +.yc-mode-chip__clear { display:inline-flex; align-items:center; justify-content:center; padding:0; border:0; border-radius:var(--yc-radius-full); background:transparent; color:inherit; cursor:pointer; opacity:0; } +.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__mode-icon, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__mode-icon { opacity:0; } +.yc-mode-chip:not([aria-disabled="true"]):hover .yc-mode-chip__clear, .yc-mode-chip:not([aria-disabled="true"]):focus-within .yc-mode-chip__clear { opacity:1; } .yc-mode-chip__clear:focus-visible { outline:var(--yc-space-1) solid var(--cr-text-secondary); } diff --git a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx index 50f1252..0568e63 100644 --- a/src/renderer/src/workbuddy/conversation/CompletedFold.tsx +++ b/src/renderer/src/workbuddy/conversation/CompletedFold.tsx @@ -4,6 +4,7 @@ import { SourceFoldChevronIcon } from '../message-actions/SourceActionIcons' import { buildCompletedFoldModel, classifyCompletedFoldPart, + completedFoldPartIdentity, type CompletedFoldOptions, type CompletedFoldPart, type CompletedFoldSegment, @@ -26,7 +27,7 @@ function renderPart( summaryGroup?: CompletedFoldSummaryGroup ): ReactNode { const classification = unit.classification ?? classifyCompletedFoldPart(unit.part, unit.index) - return {render(unit.part, unit.index, { + return {render(unit.part, unit.index, { kind: summaryGroup ? 'summary-member' : 'part', classification, presentationGroupId: classification.presentationGroupId, @@ -42,7 +43,7 @@ function renderPresentationUnit( if (unit.kind === 'part') return renderPart(unit.unit, render) const children = unit.group.units.map(member => renderPart(member, render, unit.group)) return ( - + {renderGroup ? renderGroup(unit.group, children) : <>{children}} ) @@ -146,10 +147,6 @@ export function LiveEventDisclosure({ ) } -function partDisclosureId(part: CompletedFoldPart, index: number): string { - const id = 'id' in part && typeof part.id === 'string' ? part.id : part.type === 'tool' && typeof part.toolId === 'string' ? part.toolId : index - return JSON.stringify([part.type, id]) -} export function CompletedFold(props: CompletedFoldProps) { return @@ -183,7 +180,7 @@ function CompletedFoldContent({ if (!model) return <>{parts.map((part, index) => { const classification = classifyCompletedFoldPart(part, index, classifyToolCall) if (!classification.hasRootBlock) return null - return {render(part, index, { + return {render(part, index, { kind: 'part', classification, presentationGroupId: classification.presentationGroupId @@ -208,7 +205,7 @@ function CompletedFoldContent({ ) : parts.map((part, index) => { const classification = classifyCompletedFoldPart(part, index, classifyToolCall) if (!classification.hasRootBlock) return null - return {render(part, index, { kind: 'part', classification })} + return {render(part, index, { kind: 'part', classification })} }) })()} diff --git a/src/renderer/src/workbuddy/conversation/completed-fold.ts b/src/renderer/src/workbuddy/conversation/completed-fold.ts index 65e8336..8218432 100644 --- a/src/renderer/src/workbuddy/conversation/completed-fold.ts +++ b/src/renderer/src/workbuddy/conversation/completed-fold.ts @@ -241,13 +241,24 @@ export function formatCompletedFoldDuration(durationMs: number): string { return `${seconds}s` } +/** Business identity survives terminal text coalescing and presentation reordering. + * Old histories without an ID intentionally retain positional fallback semantics. + */ +export function completedFoldPartIdentity(part: CompletedFoldPart, index: number): string { + let id: unknown = 'id' in part ? part.id : undefined + if (part.type === 'tool') id = part.toolId + let content: unknown + if (part.type === 'plan') content = part.plan + if (part.type === 'task') content = part.task + if (part.type === 'artifact') content = part.artifact + if (content && typeof content === 'object' && 'id' in content) id = content.id + return typeof id === 'string' && id.length > 0 + ? JSON.stringify([part.type, 'id', id]) + : JSON.stringify([part.type, 'index', index]) +} + function makeUnit(part: CompletedFoldPart, index: number): CompletedFoldUnit { - const partId = 'id' in part && typeof part.id === 'string' - ? part.id - : part.type === 'tool' && typeof part.toolId === 'string' - ? part.toolId - : String(index) - return { key: `${part.type}:${partId}:${index}`, index, part } + return { key: completedFoldPartIdentity(part, index), index, part } } function buildSourceSegments(