feat(conversation): replicate in-place user message editing (TODO 03)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 06:31:37 +08:00
1 parent ed31a7c1b4
commit ee51208931
18 files changed
+992 -67

No files matched your search

+30 -5
View File
@@ -4,6 +4,8 @@ import { ComposerControls } from '../src/renderer/src/workbuddy/composer/Compose
import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl'
import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls'
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
import type { ChatMessage } from '../src/shared/types'
@@ -132,11 +134,34 @@ const assistantMarkup = renderToStaticMarkup(<MessageActionToolbar message={assi
assert.match(assistantMarkup, /复制/)
assert.match(assistantMarkup, /重新生成/)
assert.doesNotMatch(assistantMarkup, /编辑/)
const userMarkup = renderToStaticMarkup(<MessageActionToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onEdit={() => undefined} onRevert={() => undefined} onResend={() => undefined} />)
assert.match(userMarkup, /编辑/)
assert.match(userMarkup, /重新发送/)
assert.doesNotMatch(userMarkup, /重新生成/)
assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only')
// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => undefined} onRevert={() => undefined} />)
assert.match(userMarkup, /class="cr-toolbar"/)
assert.match(userMarkup, /cr-toolbar__time/)
assert.match(userMarkup, /data-action="copy"/)
assert.match(userMarkup, /data-action="revert"/)
assert.match(userMarkup, /data-action="edit"/)
assert.match(userMarkup, /cr-icon-button__icon/)
assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/)
assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签')
const editorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
assert.match(editorMarkup, /class="cr-user-message-editor"/)
assert.match(editorMarkup, /cr-input-container-wrapper/)
assert.match(editorMarkup, /cr-input-editor-host/)
assert.match(editorMarkup, /data-slate-editor/)
assert.match(editorMarkup, /cr-user-message-editor__hint-icon/)
assert.match(editorMarkup, /编辑后将从此处重新开始对话,产物也将回退至此信息前版本/)
assert.match(editorMarkup, /cr-user-message-editor__cancel/)
assert.match(editorMarkup, /cr-user-message-editor__send/)
assert.match(editorMarkup, />取消</)
assert.match(editorMarkup, />发送</)
assert.doesNotMatch(editorMarkup, /role="alert"/, '有内容时不显示错误')
const emptyEditorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: '', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
assert.match(emptyEditorMarkup, /消息内容不能为空/)
assert.match(emptyEditorMarkup, /role="alert"/)
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
const composerMarkup = renderToStaticMarkup(
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
+55
View File
@@ -355,6 +355,60 @@ async function testActionBranchFailure(bundle: TestBundle, state: TestState): Pr
'failed action branch should restore the prior marker')
}
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
const invoke = invokeFor(state)
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
const rolledPath = join(workspace, 'edit-rollback.txt')
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
const rolledMessages = await waitForAssistant(invoke, rolledSession)
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
'edit submit must roll the artifact back to its version before the message')
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
assert.equal(rolledMessages[0].content, 'edited prompt')
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
// 分支失败:撤销文件回退,保持会话不变。
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
const failedBefore = await bundle.sessions.getMessages(failedSession)
state.failBranchIds.add('edit-rollback-failed-native')
await assert.rejects(invoke('chat:send', {
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
}), /branch failure/)
state.failBranchIds.delete('edit-rollback-failed-native')
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
'failed edit must leave the persisted conversation untouched')
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
const plainMessages = await waitForAssistant(invoke, plainSession.id)
assert.equal(plainMessages.length, 2)
assert.equal(plainMessages[0].content, 'edited plain')
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
const blockedPath = join(workspace, 'edit-blocked.txt')
await writeFile(blockedPath, 'after', 'utf8')
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
}))
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
await assert.rejects(invoke('chat:send', {
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
}), /无法完整回滚/)
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
'rejected edit must leave history untouched')
}
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
const invoke = invokeFor(state)
for (const nativeAccepted of [false, true]) {
@@ -684,6 +738,7 @@ async function main(): Promise<void> {
await testConcurrency(bundle, state, invoke)
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
await testActionBranchFailure(bundle, state)
await testEditArtifactRollback(bundle, state, workspace)
await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state)
@@ -0,0 +1,15 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [
".cr-self-bubble",
".cr-frame--right",
".cr-toolbar"
],
"actions": [
{ "kind": "click", "step_id": "open-first-space-task", "selector": ".conversation-item", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "conversation-loaded", "duration_ms": 1500, "frames": 1 },
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1 },
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 400, "frames": 1 }
]
}
@@ -0,0 +1,26 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [
".cr-frame--right",
".cr-toolbar",
".cr-toolbar__time",
".cr-toolbar .cr-icon-button[data-action='copy']",
".cr-toolbar .cr-icon-button[data-action='revert']",
".cr-toolbar .cr-icon-button[data-action='edit']",
".cr-self-bubble--editing",
".cr-user-message-editor",
".cr-user-message-editor .cr-input-editor-host",
".cr-user-message-editor__hint",
".cr-user-message-editor__cancel",
".cr-user-message-editor__send"
],
"actions": [
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1, "interval_ms": 50 },
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 350, "frames": 1 },
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "editor-visible", "duration_ms": 700, "frames": 1, "interval_ms": 50 },
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,13 @@
{
"include_fonts": false,
"include_message_content": false,
"track_selectors": [
".cr-self-bubble",
".cr-toolbar",
".cr-user-message-editor"
],
"actions": [
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
]
}
@@ -0,0 +1,24 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [
".cr-self-bubble--editing",
".cr-user-message-editor",
".cr-user-message-editor .cr-input-container",
".cr-user-message-editor .cr-input-editor-host",
".cr-user-message-editor__error",
".cr-user-message-editor__hint",
".cr-user-message-editor__hint-icon",
".cr-user-message-editor__actions",
".cr-user-message-editor__cancel",
".cr-user-message-editor__send",
".cr-user-message-editor__resume",
".cr-user-message-editor__send svg"
],
"actions": [
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 300, "frames": 0 },
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
{ "kind": "wait", "step_id": "editor-open", "duration_ms": 800, "frames": 1 }
]
}
+259
View File
@@ -0,0 +1,259 @@
// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证用户消息就地编辑(TODO 03)。
// 原版期望值来自:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3(打开/取消编辑)、
// ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f(保持编辑器打开)、
// ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405(编辑态布局/计算样式快照)、
// src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe)。
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/edit-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const cwd = path.join(directory, 'fixture-workspace')
await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd)
const crypto = require('node:crypto')
const now = Date.now()
const firstUserId = crypto.randomUUID()
const secondUserId = crypto.randomUUID()
// 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。
const editedFilePath = path.join(cwd, 'edited-artifact.txt')
const editedFileBefore = '旧内容:尚未编辑'
const editedFileAfter = '新内容:编辑后写入'
await fs.writeFile(editedFilePath, editedFileAfter, 'utf8')
const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [
{ id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done' },
{ id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done' },
{ id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', tools: [
{ id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } }
] }
] }]
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data,
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('USER_MESSAGE_EDIT_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output)
process.exitCode = code ? 1 : 0
})
})()
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
app.disableHardwareAcceleration()
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('edit-'))
const expected = JSON.parse(require('node:fs').readFileSync(path.join(directory, 'expected.json'), 'utf8'))
app.setPath('userData', path.join(directory, 'data')); app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main, phase = 'boot'
const checks = [], snapshots = {}, rendererErrors = []
const sentPayloads = []
let sendDelayMs = 0
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
async function read(source) {
let timer
try {
return await Promise.race([main.webContents.executeJavaScript(source), new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 100))), 15000)
})])
} catch (error) { throw Error(error.message + ' - ' + source.slice(0, 240)) } finally { clearTimeout(timer) }
}
async function wait(source, label) {
for (let index = 0; index < 250; index++) { const value = await read(source); if (value) return value; await pause(30) }
throw Error('Timeout: ' + label)
}
async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) }
function check(label, actual, expected) {
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected })
}
async function shot(name) {
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'))
}
const lastFrame = `[...document.querySelectorAll('.cr-frame--right')].pop()`
const toolbarSource = `(()=>{const frame=${lastFrame};const toolbar=frame&&frame.querySelector('.cr-toolbar');if(!toolbar)return null;const style=getComputedStyle(toolbar);const time=toolbar.querySelector('.cr-toolbar__time');const buttons=[...toolbar.querySelectorAll('.cr-icon-button')].map(button=>{const bs=getComputedStyle(button);const box=button.getBoundingClientRect();const icon=button.querySelector('svg');return {action:button.getAttribute('data-action'),label:button.getAttribute('aria-label'),width:box.width,height:box.height,radius:bs.borderRadius,color:bs.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height,icon.getAttribute('viewBox')]:null}});const bubble=frame.querySelector('.cr-self-bubble').getBoundingClientRect();const frameBox=frame.getBoundingClientRect();const toolbarBox=toolbar.getBoundingClientRect();return {messageId:toolbar.getAttribute('data-message-id'),role:toolbar.getAttribute('role'),position:style.position,top:style.top,right:style.right,gap:style.gap,paddingTop:style.paddingTop,opacity:style.opacity,pointerEvents:style.pointerEvents,transition:style.transitionProperty+' '+style.transitionDuration+' '+style.transitionTimingFunction,time:time?{text:time.textContent,fontSize:getComputedStyle(time).fontSize,lineHeight:getComputedStyle(time).lineHeight,color:getComputedStyle(time).color}:null,buttons,buttonBox:[bubble.x,bubble.y,bubble.width,bubble.height],frameBox:[frameBox.x,frameBox.y,frameBox.width,frameBox.height],toolbarBox:[toolbarBox.x,toolbarBox.y,toolbarBox.width,toolbarBox.height]}})()`
const editorSource = `(()=>{const frame=${lastFrame};const bubble=frame&&frame.querySelector('.cr-self-bubble');const editor=frame&&frame.querySelector('.cr-user-message-editor');if(!bubble||!editor)return null;const s=getComputedStyle(editor);const footer=editor.querySelector('.cr-user-message-editor__footer');const hint=editor.querySelector('.cr-user-message-editor__hint');const hintIcon=editor.querySelector('.cr-user-message-editor__hint-icon');const actions=editor.querySelector('.cr-user-message-editor__actions');const cancel=editor.querySelector('.cr-user-message-editor__cancel');const send=editor.querySelector('.cr-user-message-editor__send');const host=editor.querySelector('.cr-input-editor-host');const editable=editor.querySelector('[data-slate-editor]');const container=editor.querySelector('.cr-input-container');const box=node=>{const rect=node.getBoundingClientRect();return [rect.x,rect.y,rect.width,rect.height]};const style=node=>getComputedStyle(node);return {bubbleClass:bubble.className,bubbleBackground:style(bubble).backgroundColor,bubbleRadius:style(bubble).borderRadius,bubblePadding:style(bubble).padding,display:s.display,width:s.width,footerPaddingTop:footer?style(footer).paddingTop:null,footerGap:footer?style(footer).gap:null,footerJustify:footer?style(footer).justifyContent:null,hintText:hint?hint.textContent:null,hintFont:hint?style(hint).fontSize+'/'+style(hint).lineHeight:null,hintColor:hint?style(hint).color:null,hintGap:hint?style(hint).gap:null,hintIcon:hintIcon?box(hintIcon):null,actionsGap:actions?style(actions).gap:null,cancel:cancel?{box:box(cancel),disabled:cancel.disabled,radius:style(cancel).borderRadius,padding:style(cancel).padding,font:style(cancel).fontSize+'/'+style(cancel).lineHeight,border:style(cancel).borderTopWidth+' '+style(cancel).borderTopColor,color:style(cancel).color,background:style(cancel).backgroundColor}:null,send:send?{box:box(send),disabled:send.disabled,busy:send.getAttribute('aria-busy'),radius:style(send).borderRadius,padding:style(send).padding,font:style(send).fontSize+'/'+style(send).lineHeight,color:style(send).color,background:style(send).backgroundColor,gap:style(send).gap,spinner:!!send.querySelector('svg')}:null,host:host?{box:box(host),minHeight:style(host).minHeight,overflow:style(host).overflowY,padding:style(host).padding}:null,editable:editable?{box:box(editable),fontSize:style(editable).fontSize,lineHeight:style(editable).lineHeight,minHeight:style(editable).minHeight,text:editable.innerText}:null,container:container?{box:box(container),background:style(container).backgroundColor,border:style(container).borderTopWidth,boxShadow:style(container).boxShadow,radius:style(container).borderRadius,paddingTop:style(container).paddingTop,minHeight:style(container).minHeight}:null,error:editor.querySelector('.cr-user-message-editor__error')?editor.querySelector('.cr-user-message-editor__error').textContent:null,toolbarMounted:!!frame.querySelector('.cr-toolbar')}})()`
const setEditable = text => `(()=>{const editable=document.querySelector('.cr-user-message-editor [data-slate-editor]');if(!editable)return false;editable.focus();editable.innerText=${JSON.stringify(text)};editable.dispatchEvent(new Event('input',{bubbles:true}));return true})()`
;(async () => {
await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href))
await app.whenReady()
// 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。
ipcMain.removeHandler('chat:send')
ipcMain.handle('chat:send', async (_event, payload) => {
if (sendDelayMs) await pause(sendDelayMs)
sentPayloads.push(payload)
const runId = 'edit-run-' + sentPayloads.length
const userMessage = { id: 'edit-user-' + sentPayloads.length, role: 'user', content: payload.text, createdAt: Date.now(), status: 'done', requestId: runId }
const assistant = { id: 'edit-assistant-' + sentPayloads.length, role: 'assistant', content: '已重新开始。', createdAt: Date.now(), status: 'done', requestId: runId, tools: [], parts: [] }
// ChatEvent 的 done 形状:{ runId, type:'done', content, tools }。
setTimeout(() => { try { main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'done', content: assistant.content, tools: [] }) } catch { /* window closed */ } }, 60)
return { runId, assistantMessageId: assistant.id, userMessage, messages: [userMessage, assistant] }
})
main = BrowserWindow.getAllWindows()[0]
main.setSize(1336, 902)
main.showInactive()
main.webContents.debugger.attach('1.3')
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!document.querySelector("#root")', 'renderer root')
await pause(1500)
await step('select-session')
await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows')
await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Live'));if(!row)return false;row.click();return true})()`)
await wait(`!!${lastFrame}.querySelector('.cr-toolbar')`, 'user toolbar')
await pause(300)
await step('toolbar-idle')
main.webContents.sendInputEvent({ type: 'mouseMove', x: 4, y: 4 })
await pause(200)
const idle = await wait(toolbarSource, 'toolbar snapshot')
snapshots.idle = idle
const userBubbles = await read(`[...document.querySelectorAll('.cr-frame--right')].length`)
check('fixture renders two user messages', userBubbles, 2)
check('toolbar keeps the original DOM contract', idle.role === 'toolbar' && typeof idle.messageId === 'string' && idle.messageId.length > 0, true)
check('toolbar is absolutely positioned with right 0', idle.position === 'absolute' && idle.right === '0px', true)
// 原版实测(ev_245ea174…):工具条底边与消息 frame 底边重合、右边缘对齐(frame 182.59 / toolbar 182.59、925.98 / 925.98)。
check('toolbar bottom and right edge align with the message frame like the original', Math.abs((idle.toolbarBox[1] + idle.toolbarBox[3]) - (idle.frameBox[1] + idle.frameBox[3])) <= 1 && Math.abs((idle.toolbarBox[0] + idle.toolbarBox[2]) - (idle.frameBox[0] + idle.frameBox[2])) <= 1, true)
check('toolbar gap is 8 and padding-top is 4', idle.gap === '8px' && idle.paddingTop === '4px', true)
check('toolbar fades with the original transition', idle.transition, 'opacity 0.2s ease-in-out')
check('toolbar is hidden while not hovered', idle.opacity === '0' && idle.pointerEvents === 'none', true)
check('toolbar time follows the 14/20 tertiary rule', idle.time.fontSize === '14px' && idle.time.lineHeight === '20px' && idle.time.color === 'rgba(0, 0, 0, 0.5)', true)
check('toolbar time uses HH:mm for today', /^\d{2}:\d{2}$/.test(idle.time.text), true)
// 该消息之后存在可回退的产物写入,因此原版会额外显示“恢复此版本”入口(ev_245ea174… 的无产物会话只有 copy + edit)。
check('toolbar keeps the original button set (copy + revert + edit, no resend)', idle.buttons.length, 3)
check('button order is copy, revert, edit like the original', idle.buttons.map(item => item.action).join(','), 'copy,revert,edit')
check('buttons are 24x24 with 14x14 icons and radius 6', idle.buttons.every(item => item.width === 24 && item.height === 24 && item.radius === '6px' && item.icon[0] === 14 && item.icon[1] === 14), true)
check('copy/edit keep the 16x16 viewBox while revert keeps the recovered 14x14 one', idle.buttons.map(item => item.action + ':' + item.icon[2]).join(','), 'copy:0 0 16 16,revert:0 0 14 14,edit:0 0 16 16')
check('buttons expose the original aria labels', idle.buttons.map(item => item.label).join(','), '复制,恢复此版本,编辑')
await step('toolbar-hover')
const bubble = idle.buttons[0] && (await read(`(()=>{const box=${lastFrame}.querySelector('.cr-self-bubble').getBoundingClientRect();return {x:Math.round(box.x+box.width/2),y:Math.round(box.y+box.height/2)}})()`))
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
await pause(400)
check('hovering the message reveals the toolbar', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).opacity`), '1')
check('hovered toolbar accepts pointer events', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).pointerEvents`), 'auto')
await shot('toolbar-hover')
await step('open-editor')
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
const editor = await wait(editorSource, 'editor snapshot')
snapshots.editor = editor
check('editing unmounts the hover toolbar like the original', editor.toolbarMounted, false)
check('bubble gets the editing class, surface and 8x12 padding', editor.bubbleClass.includes('cr-self-bubble--editing') && editor.bubbleBackground === 'rgb(242, 242, 242)' && editor.bubblePadding === '8px 12px', true)
check('editor container is transparent with no border or shadow', editor.container.background === 'rgba(0, 0, 0, 0)' && editor.container.border === '0px' && editor.container.boxShadow === 'none' && editor.container.radius === '0px', true)
check('editor container keeps min-height 50 with no top padding', editor.container.minHeight === '50px' && editor.container.paddingTop === '0px', true)
check('editor host keeps the 64px min-height without padding', editor.host.minHeight === '64px' && editor.host.padding === '0px', true)
check('editable uses 15px/1.75 like the original', editor.editable.fontSize === '15px' && editor.editable.lineHeight === '26.25px', true)
check('editable keeps the original min-height 50px', editor.editable.minHeight === '50px', true)
check('editable is prefilled with the message text', editor.editable.text, 'workbuddy是否有目标模式')
check('footer uses 12px top padding, 12px gap and space-between', editor.footerPaddingTop === '12px' && editor.footerGap === '12px' && editor.footerJustify === 'space-between', true)
check('hint keeps the recovered copy', editor.hintText, '编辑后将从此处重新开始对话,产物也将回退至此信息前版本')
check('hint uses 13/18 with 4px gap and 14x14 icon', editor.hintFont === '13px/18px' && editor.hintGap === '4px' && editor.hintIcon[2] === 14 && editor.hintIcon[3] === 14, true)
check('actions gap is 8', editor.actionsGap, '8px')
check('cancel is 62x32 with 8px radius and 5px 16px padding', Math.round(editor.cancel.box[2]) === 62 && Math.round(editor.cancel.box[3]) === 32 && editor.cancel.radius === '8px' && editor.cancel.padding === '5px 16px', true)
check('cancel uses the default border and secondary text', editor.cancel.border === '1px rgb(235, 235, 235)' && editor.cancel.color === 'rgba(0, 0, 0, 0.7)', true)
check('send is 60x30 on the contrast surface', Math.round(editor.send.box[2]) === 60 && Math.round(editor.send.box[3]) === 30 && editor.send.background === 'rgb(26, 26, 26)' && editor.send.color === 'rgb(255, 255, 255)', true)
check('send button is not disabled when content is unchanged', editor.send.disabled, false)
check('no error is shown while content is valid', editor.error, null)
await shot('editor-open')
await step('cancel-restores')
await read(setEditable('改动但不提交'))
await pause(120)
check('edited text is reflected in the field', await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').innerText`), '改动但不提交')
await read(`document.querySelector('.cr-user-message-editor__cancel').click()`)
await pause(250)
check('cancel closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
check('cancel restores the original bubble text', await read(`[...document.querySelectorAll('.cr-frame--right')].pop().querySelector('.cr-self-bubble').textContent`), 'workbuddy是否有目标模式')
await step('outside-mousedown-keeps-changes')
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.scrollIntoView({block:'center'});bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));bubble.dispatchEvent(new MouseEvent('mouseover',{bubbles:true}));return true})()`)
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
await pause(350)
await read(`${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]').click()`)
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor reopen')
await read(setEditable('有改动时点击外部'))
await pause(120)
await read(`document.body.dispatchEvent(new MouseEvent('mousedown',{bubbles:true}))`)
await pause(200)
check('mousedown outside keeps the editor when there are changes', await read(`!!document.querySelector('.cr-user-message-editor')`), true)
await step('empty-validation')
await read(setEditable(' '))
await pause(150)
const emptyEditor = await read(editorSource)
check('blank content raises the recovered empty error', emptyEditor.error, '消息内容不能为空')
check('send is disabled while the content is empty', emptyEditor.send.disabled, true)
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
await pause(200)
check('blocked submit never reaches chat:send', sentPayloads.filter(item => item.action === 'edit').length, 0)
await read(setEditable('workbuddy是否有目标模式'))
await pause(120)
check('error clears once content is valid again', await read(`!!document.querySelector('.cr-user-message-editor__error')`), false)
await step('submit-edit')
sendDelayMs = 700
await read(setEditable('workbuddy 有目标模式吗'))
await pause(120)
await read(`document.querySelector('.cr-user-message-editor__send').click()`)
await pause(250)
const submitting = await read(editorSource)
snapshots.submitting = submitting
check('submitting disables cancel and send with aria-busy', submitting.cancel.disabled === true && submitting.send.disabled === true && submitting.send.busy === 'true', true)
check('submitting shows the spinner inside the send button', submitting.send.spinner, true)
for (let index = 0; index < 60 && sentPayloads.length === 0; index++) await pause(50)
const editPayload = sentPayloads.find(item => item.action === 'edit')
check('edit submit reaches chat:send through IPC', !!editPayload, true)
check('edit payload carries action=edit with the original message id', editPayload.action === 'edit' && editPayload.fromMessageId === expected.secondUserId, true)
check('edit payload sends the modified text', editPayload.text, 'workbuddy 有目标模式吗')
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after submit')
check('submit closes the editor', await read(`!!document.querySelector('.cr-user-message-editor')`), false)
// 产物回退由真实主进程语义覆盖:npm run test:conversation-ipc 的 testEditArtifactRollback
// (本脚本把 chat:send 换成 stub,因此不在此处重复断言文件内容)。
await shot('editor-submitted')
await step('enter-submits')
sendDelayMs = 0
await read(`(()=>{const bubble=[...document.querySelectorAll('.cr-frame--right')].pop();bubble.dispatchEvent(new MouseEvent('mousemove',{bubbles:true}));return true})()`)
main.webContents.sendInputEvent({ type: 'mouseMove', x: bubble.x, y: bubble.y })
await pause(300)
await read(`(()=>{const button=${lastFrame}.querySelector('.cr-toolbar .cr-icon-button[data-action="edit"]');if(!button)return false;button.click();return true})()`)
await wait(`!!document.querySelector('.cr-user-message-editor')`, 'editor for enter test')
await read(setEditable('回车提交'))
await pause(120)
await read(`document.querySelector('.cr-user-message-editor [data-slate-editor]').dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',bubbles:true}))`)
for (let index = 0; index < 60 && sentPayloads.filter(item => item.action === 'edit').length < 2; index++) await pause(50)
check('Enter submits the edited message like the original onEnterSend', sentPayloads.filter(item => item.action === 'edit').length, 2)
check('second edit payload keeps the typed text', sentPayloads.filter(item => item.action === 'edit')[1].text, '回车提交')
await wait(`!document.querySelector('.cr-user-message-editor')`, 'editor closed after enter submit')
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer. chat:send is stubbed in the test main process to record payloads because the sandbox has no model.',
sourceEvidence: ['ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3', 'ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f',
'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('USER_MESSAGE_EDIT_FAILURE ' + JSON.stringify(failure))
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ phase, error: String(error), stack: error.stack, checks, snapshots, rendererErrors }, null, 2))
console.error(error); if (main) await shot('failure').catch(() => undefined); app.exit(1)
})
}