diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index a6f9732..5bf121e 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -124,12 +124,36 @@ ### 05. 用户消息回退操作与图标 -- [ ] 移除已发送用户消息上的“重新发送”按钮。 -- [ ] 替换为“将对话及产物回退到此消息前”操作,图标、提示及交互通过 REA 对照。 -- [ ] 明确回退边界:该消息之前,而非该消息之后;对话记录与对应产物修改一起处理。 -- [ ] 验收:多轮对话、文件创建/修改、无产物、运行中禁用、取消确认、回退失败和重启后状态;不得只删除可见消息而保留应回退的文件修改。 +- [x] 移除已发送用户消息上的“重新发送”按钮。 +- [x] 替换为“将对话及产物回退到此消息前”操作,图标、提示及交互通过 REA 对照。 +- [x] 明确回退边界:该消息之前,而非该消息之后;对话记录与对应产物修改一起处理。 +- [x] 验收:多轮对话、文件创建/修改、无产物、运行中禁用、取消确认、回退失败和重启后状态;不得只删除可见消息而保留应回退的文件修改。 -状态:待 REA 对照与现有回退链路审查。 +状态:主体已实现,并通过真实窗口与真实 IPC 回归;弹窗逐像素几何、动画与失败 toast 尚未与原版比对。 + +原版证据: +- 桌面端接线(REA Evidence `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`,脚本 SHA-256 `5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b`,item 295):`ConversationTimelineImpl` 组装 `userMessage.actions = { showDefaultRevert: !isSessionActive && transport !== 'cloud', revertLabel: t('rewind.entry.tooltip'), revertEntersEdit: false, hasRevision: e => resolveRevertEntryVisible({ requestId: e.requestId, firstLoadedUserRequestId, hasMoreRequests }), onAction }`;`onAction('revert')` 先校验 `resolveRevertEntryVisible`,再 `M.confirm('rollback', requestId)`,仅当结果不是 `cancel` 才执行 `M.runRewind(requestId)`;`resolveRevertEntryVisible = requestId && (hasMoreRequests || !firstLoadedUserRequestId || requestId !== firstLoadedUserRequestId)`。 +- 文案(REA Evidence `ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90`,同一脚本):`rewind.entry.tooltip`=将对话及产物回退到此消息前、`rewind.dialog.plain.title/desc`、`rewind.dialog.artifacts.title`=将对话及以下产物版本回退到此消息前?、`rewind.dialog.artifacts.desc`=回退暂不支持部分类型文件、`rewind.dialog.noVersion.title/desc`、`rewind.item.restoreAt/restoreUnknown/willDelete/noEarlierVersion`、`rewind.action.cancel/rewind/skipRewind`、`rewind.error.rollbackFailed`=对话回退失败,请稍后重试。 +- 工具条运行时几何:`ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84`(3 个 24×24 按钮 x=1093/1125/1157;回退图标 14×14 `viewBox 0 0 14 14`、两条填充 path,与 `RevertToBeforeIcon` 一致)、`ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3`(无产物写入会话只有 copy + edit,含 `write_file` 记录的会话出现第三个按钮)。 +- 另一表面(云端/远程会话的 `useUserMessageActions`)使用 `userMessageActions.revertToThisVersion`(“回退到此版本”)并打开 checkpoint 弹窗;本地桌面任务会话(YCode 目标表面)使用上面的 rewind 文案与流程,本项按本地表面复刻。 +- 观察范围:桌面端浅色主题、本地传输会话、含 `write_file` 记录的会话与只读会话。未覆盖:云端 checkpoint 弹窗、`rewind.dialog.artifacts` 逐产物 fallback 状态(`no-earlier-version`、`versionDeleted` 等)的真实数据形态、弹窗逐像素几何与动画。 + +实现: +- `workbuddy/conversation/rewind-labels.ts`:按 zh-CN 逐字回收 `rewind.*` 文案(含 `{time}` 占位替换),并记录两条 Evidence。 +- `workbuddy/message-actions/UserMessageToolbar.tsx`:回退按钮的 aria-label/tooltip 由库默认 `userMessage.action.revert`(恢复此版本)改为桌面端 `revertLabel` 的 `rewind.entry.tooltip`。 +- `components/ChatPane.tsx`:新增 `rewindEntryVisible`(`requestId` 存在、非首条用户消息、会话不在运行/发送中)与 `rewindPreview`(`artifacts`/`no-version`/`plain` 三变体);回退弹窗按变体渲染标题与说明,产物清单显示文件名、完整路径 tooltip 与状态(将被删除 / 回退至历史版本),按钮为“取消/回退”,提交中禁用并置 `aria-busy`;取消不触发任何 IPC。 +- `workbuddy/message-actions/MessageActionToolbar.tsx`:删除用户消息的编辑/恢复/重新发送死分支,用户消息操作只由 `UserMessageToolbar` 承载,界面上不再可能出现“重新发送”。 +- `main/ipc.ts`:`chat:revert` 允许无产物版本的“仅回退对话”(对应原版 no-version 变体,产物保持不变),并在消息不存在时明确报错;有产物时仍先回退文件、失败时撤销并保留会话。 +- `styles/app.css`:`.rewind-artifacts` 产物清单样式(`max-height:200px`、13px/20px 行、名称省略与完整路径 tooltip、状态右对齐)。 + +验证:`node scripts/verify-user-message-edit.cjs` 在真实隔离 Electron(真实 preload/IPC/渲染进程)中 67 项断言全 PASS,覆盖按钮集合与标签(复制 / 将对话及产物回退到此消息前 / 编辑)、首条用户消息隐藏回退入口、其余消息显示、会话活动期间回退与编辑入口一并隐藏、弹窗 `artifacts` 变体标题/描述/按钮/产物行(`edited-artifact.txt` · 回退至历史版本)、取消不触发 IPC、确认以 `sessionId` + `messageId` 触发 `chat:revert` 并按回退结果替换消息前缀;报告与截图:`.tmp/edit-kYHO92/report.json`、`.tmp/edit-kYHO92/rewind-dialog.png`。`npm run test:conversation-ipc` 改写 read-only 用例为“无产物版本回退仅回退对话且历史持久化”,连同产物回退、外部修改冲突、原生分支失败撤销、运行中拒绝等主进程语义用例全部通过。`npm run typecheck`、`npm run test:conversation`、`npm run test:recovered`、`npm run test:chat`、`npm run test:assistant-footer`、`npm run build` 均通过。 + +遗留: +1. 回退弹窗沿用 YCode 现有 modal 样式,未采集原版弹窗(宿主 Modal:标题/正文/产物清单/底部按钮)的几何与动画;待后续按原版逐像素对齐。 +2. 原版失败路径以 toast 呈现 `rewind.error.rollbackFailed`(对话回退失败,请稍后重试。);YCode 当前在弹窗内联展示原始错误文本。 +3. 原版逐产物 fallback 状态(回退至 {time} 版本 / 历史版本已被删除,将保持当前版本 / 该产物无历史版本,回退后文件仍会保留在目录中)依赖服务端产物版本历史;YCode 只有工具写入前后快照,暂映射“将被删除 / 回退至历史版本”。 +4. 原版编辑提交时会先弹 `rewind.edit.*` 询问是否回退产物历史版本(回退 / 不回退),YCode 编辑提交固定回退产物(`rollbackEditedArtifacts`);差异记入 TODO 03 追补。 +5. 原版在 `hasMoreRequests`(分页历史未全部加载)时首条已加载消息也可显示回退入口;YCode 全量加载历史,按 `requestId !== 首条用户消息 requestId` 判定。 ### 06. 对话区域滚动条 diff --git a/scripts/conversation-ipc-regression.ts b/scripts/conversation-ipc-regression.ts index ea5b37f..9ad85a6 100644 --- a/scripts/conversation-ipc-regression.ts +++ b/scripts/conversation-ipc-regression.ts @@ -494,11 +494,15 @@ async function testFeedbackAndRevert(bundle: TestBundle, state: TestState, works assert.equal(await bundle.sessions.getPendingNativeBranch(rejectedId), undefined) const readOnlyId = await invoke('session:create', 'read-only-revert') as { id: string } - await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only')) + await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-user', 'user', 'read only', { nativeEntryId: 'native-read-only-target' })) await bundle.sessions.appendMessage(readOnlyId.id, message('read-only-assistant', 'assistant', 'read', { tools: [{ id: 'read-tool', name: 'read_file', args: {}, argsRaw: '{}', status: 'ok' }] })) - await assert.rejects(invoke('chat:revert', readOnlyId.id, 'read-only-user'), /没有可恢复/) + seedNativeHistory(state, readOnlyId.id, [['native-read-only-target', 'read only']]) + // 原版 rewind 的 no-version 变体:没有产物版本时只回退对话内容,产物保持不变。 + const readOnlyResult = await invoke('chat:revert', readOnlyId.id, 'read-only-user') as { messages: unknown[] } + assert.deepEqual(readOnlyResult.messages, [], 'conversation-only rewind should truncate to before the message') + assert.deepEqual(await bundle.sessions.getMessages(readOnlyId.id), [], 'conversation-only rewind should persist the truncated prefix') const unknownPath = join(workspace, 'unknown-write.txt') await writeFile(unknownPath, 'after', 'utf8') diff --git a/scripts/rea/probe-user-message-revert-20261017.json b/scripts/rea/probe-user-message-revert-20261017.json new file mode 100644 index 0000000..c1538c7 --- /dev/null +++ b/scripts/rea/probe-user-message-revert-20261017.json @@ -0,0 +1,17 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".cr-frame--right", + ".cr-toolbar", + ".cr-toolbar__time" + ], + "actions": [ + { "kind": "click", "step_id": "open-research-task", "selector": ".conversation-item", "index": 2, "frames": 0 }, + { "kind": "wait", "step_id": "loaded", "duration_ms": 1500, "frames": 1 }, + { "kind": "hover", "step_id": "hover-user-0", "selector": ".cr-self-bubble", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "toolbar-0", "duration_ms": 350, "frames": 1 }, + { "kind": "hover", "step_id": "hover-user-1", "selector": ".cr-self-bubble", "index": 1, "frames": 1 }, + { "kind": "wait", "step_id": "toolbar-1", "duration_ms": 350, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-user-message-revert-hover-20261017.json b/scripts/rea/probe-user-message-revert-hover-20261017.json new file mode 100644 index 0000000..9ecf29d --- /dev/null +++ b/scripts/rea/probe-user-message-revert-hover-20261017.json @@ -0,0 +1,23 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".cr-message-list", + ".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-tooltip", + ".wb-tooltip" + ], + "actions": [ + { "kind": "viewport", "step_id": "tall-viewport", "width": 1336, "height": 2160, "frames": 1 }, + { "kind": "wait", "step_id": "relayout", "duration_ms": 500, "frames": 1 }, + { "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 1, "frames": 1 }, + { "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 350, "frames": 0 }, + { "kind": "hover", "step_id": "hover-revert-button", "selector": ".cr-toolbar .cr-icon-button[data-action='revert']", "index": 0, "frames": 1 }, + { "kind": "wait", "step_id": "tooltip-visible", "duration_ms": 700, "frames": 1 } + ] +} diff --git a/scripts/verify-user-message-edit.cjs b/scripts/verify-user-message-edit.cjs index 3cb9df3..47228c4 100644 --- a/scripts/verify-user-message-edit.cjs +++ b/scripts/verify-user-message-edit.cjs @@ -22,6 +22,8 @@ if (!process.versions.electron) { const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime()) .find((value) => new Date(value).toDateString() !== todayKey) const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime() + const crossYearUserId = crypto.randomUUID() + const sameYearUserId = crypto.randomUUID() const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() // 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。 @@ -30,21 +32,21 @@ if (!process.versions.electron) { const editedFileAfter = '新内容:编辑后写入' await fs.writeFile(editedFilePath, editedFileAfter, 'utf8') const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [ - { id: crypto.randomUUID(), role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done' }, - { id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done' }, - { id: crypto.randomUUID(), role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done' }, - { id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done' }, - { 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: crossYearUserId, role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done', requestId: 'req-cross-year' }, + { id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done', requestId: 'req-cross-year' }, + { id: sameYearUserId, role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done', requestId: 'req-same-year' }, + { id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done', requestId: 'req-same-year' }, + { id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done', requestId: 'req-first' }, + { id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done', requestId: 'req-first' }, + { id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done', requestId: 'req-second' }, + { id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', requestId: 'req-second', tools: [ { id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } } ] } ] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [ { id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' } ] }] 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, + await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ crossYearUserId, sameYearUserId, firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter, sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] })) 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' } @@ -74,6 +76,7 @@ if (!process.versions.electron) { let main, phase = 'boot' const checks = [], snapshots = {}, rendererErrors = [] const sentPayloads = [] + const revertCalls = [] let sendDelayMs = 0 const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) async function read(source) { @@ -117,6 +120,13 @@ if (!process.versions.electron) { await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href)) await app.whenReady() // 记录编辑提交负载并返回合成分支:隔离环境没有可用模型,但提交链路必须真实经过 IPC。 + // 回退走真实渲染链路与真实 preload/IPC;主进程侧换成记录器, + // 真实文件与历史语义由 npm run test:conversation-ipc 覆盖。 + ipcMain.removeHandler('chat:revert') + ipcMain.handle('chat:revert', async (_event, sessionId, messageId) => { + revertCalls.push({ sessionId, messageId }) + return { messages: [] } + }) ipcMain.removeHandler('chat:send') ipcMain.handle('chat:send', async (_event, payload) => { if (sendDelayMs) await pause(sendDelayMs) @@ -157,12 +167,13 @@ if (!process.versions.electron) { 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)。 + // 原版桌面端回退入口标签来自 rewind.entry.tooltip(ConversationTimelineImpl.revertLabel), + // 该消息之后存在产物写入,因此弹窗走 artifacts 变体。 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(','), '复制,恢复此版本,编辑') + check('buttons expose the recovered rewind tooltip', 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)}})()`)) @@ -254,6 +265,9 @@ if (!process.versions.electron) { 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) + // 原版 showDefaultRevert/showDefaultEdit = !isSessionActive:会话活动期间回退与编辑入口都不渲染。 + const busyEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))})).filter(item => item.messageId)`) + check('active session hides rewind and edit entries like showDefaultRevert/showDefaultEdit', busyEntries.every(item => item.actions.join(',') === 'copy'), 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) @@ -279,12 +293,57 @@ if (!process.versions.electron) { 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') + + // 编辑提交会以桩返回替换内存消息;重新选中会话以从真实 sessions 存储恢复夹具, + // 再验证回退入口(resolveRevertEntryVisible)、弹窗三态与确认链路。 + await step('rewind-reload-fixture') + await read(`window.location.reload()`) + await wait(`!!document.querySelector('#root')`, 'renderer reloaded') + await pause(1500) + await wait(`!!document.querySelector('[data-session-id]')`, 'sidebar rows after reload') + 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(`document.querySelectorAll('.cr-frame--right').length === 4`, 'fixture restored') + + await step('rewind-entry') + const rewindEntries = await read(`[...document.querySelectorAll('.cr-frame--right')].map(frame => ({messageId: frame.querySelector('.cr-toolbar')?.getAttribute('data-message-id') ?? null, actions: [...(frame.querySelector('.cr-toolbar')?.querySelectorAll('.cr-icon-button') ?? [])].map(button => button.getAttribute('data-action'))}))`) + snapshots.rewindEntries = rewindEntries + const entryOf = id => rewindEntries.find(item => item.messageId === id)?.actions ?? null + check('first user message hides the rewind entry like resolveRevertEntryVisible', entryOf(expected.crossYearUserId), ['copy', 'edit']) + check('later user messages render copy + rewind + edit', [expected.sameYearUserId, expected.firstUserId, expected.secondUserId].map(entryOf).map(actions => actions?.join(',')).join('|'), + 'copy,revert,edit|copy,revert,edit|copy,revert,edit') + + const openRewind = async () => { + await read(`(()=>{const frame=[...document.querySelectorAll('.cr-frame--right')].find(item => item.querySelector('.cr-toolbar')?.getAttribute('data-message-id') === ${JSON.stringify(expected.secondUserId)});const button=frame?.querySelector('.cr-toolbar .cr-icon-button[data-action="revert"]');if(!button)return false;button.click();return true})()`) + await wait(`!!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog') + } + await step('rewind-dialog-cancel') + await openRewind() + const dialog = await read(`(()=>{const dialog=document.querySelector('.dialog[role="dialog"]');if(!dialog)return null;const rows=[...dialog.querySelectorAll('.rewind-artifacts__row')].map(row=>({name:row.querySelector('.rewind-artifacts__name')?.textContent??null,title:row.querySelector('.rewind-artifacts__name')?.getAttribute('title')??null,status:row.querySelector('.rewind-artifacts__status')?.textContent??null}));const actions=[...dialog.querySelectorAll('.dialog__actions button')];return {title:dialog.querySelector('.dialog__title')?.textContent??null,description:dialog.querySelector('p')?.textContent??null,aria:dialog.getAttribute('aria-label'),labels:actions.map(button=>button.textContent),rows}})()`) + snapshots.rewindDialog = dialog + check('rewind dialog uses the recovered artifacts variant title', dialog.title, '将对话及以下产物版本回退到此消息前?') + check('rewind dialog carries the recovered description', dialog.description, '回退暂不支持部分类型文件') + check('rewind dialog actions are 取消/回退', dialog.labels.join(','), '取消,回退') + check('rewind dialog lists the pending artifact with its status', dialog.rows.length === 1 && dialog.rows[0].name === 'edited-artifact.txt' && dialog.rows[0].status === '回退至历史版本' && dialog.rows[0].title === expected.editedFilePath, true) + await shot('rewind-dialog') + await read(`document.querySelector('.dialog[role="dialog"] .dialog__actions button').click()`) + await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed') + check('cancel keeps the conversation and never reaches chat:revert', revertCalls.length, 0) + + await step('rewind-confirm') + await openRewind() + await read(`(()=>{const actions=[...document.querySelectorAll('.dialog[role="dialog"] .dialog__actions button')];actions[actions.length-1].click();return true})()`) + for (let index = 0; index < 60 && revertCalls.length === 0; index++) await pause(50) + check('confirm reaches chat:revert with the session and message id', revertCalls.length === 1 && revertCalls[0].messageId === expected.secondUserId && typeof revertCalls[0].sessionId === 'string', true) + await wait(`!document.querySelector('.dialog[role="dialog"]')`, 'rewind dialog closed after confirm') + check('rewind replaces the conversation with the rewound prefix', await read(`document.querySelectorAll('.cr-frame--right').length`), 0) 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'], + 'ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe', + 'ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90(rewind.* zh-CN 文案)', + 'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb(ConversationTimelineImpl rewind 接线,脚本 SHA-256 5f35b748…)'], snapshots, checks, rendererErrors } await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) console.log('USER_MESSAGE_EDIT_' + report.result + ' ' + directory) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 12df8c0..5d3fbad 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -509,12 +509,13 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('请先停止当前运行') const history = [...await sessions.getMessages(sessionId)] const index = history.findIndex((item) => item.id === messageId && item.role === 'user') + if (index < 0) throw new Error('未找到用户消息') const changes = getRevertChanges(history, messageId) - if (!changes.length) throw new Error('此版本没有可恢复的文件修改') const workspace = await sessionRoot(sessionId) const kernel = await getOmpKernel(sessionId, workspace, await models.resolveModel((await sessions.getSessionContext(sessionId)).modelId)) if (kernel && !history[index].nativeEntryId) throw new Error('缺少原生会话分支,无法同时恢复上下文') - const undo = await restoreWorkspaceFiles(workspace, changes) + // 原版 rewind 的 no-version 变体:没有产物版本时仅回退对话内容,不触碰文件。 + const undo = changes.length ? await restoreWorkspaceFiles(workspace, changes) : async () => undefined try { if (kernel) { const response = await kernel.branch(history[index].nativeEntryId!) diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 7478f80..265a0e8 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -22,7 +22,8 @@ import { ComposerControls, type ComposerSendPayload } from '../workbuddy/compose import { QuestionFloating } from '../workbuddy/conversation' import type { AgentMode, ChatAttachment, MessageQuote, PermissionMode } from '@shared/conversation' import type { ChatMessage } from '@shared/types' -import { getRevertChanges } from '@shared/revert' +import { getRevertChanges, type RevertChange } from '@shared/revert' +import { rewindLabel } from '../workbuddy/conversation/rewind-labels' import { collectWorkbenchArtifacts } from '../workbuddy/workbench/workbench-state' interface Props { @@ -112,12 +113,26 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const [permissionMode, setPermissionMode] = useState('default-sandbox') const [confirmFullAccess, setConfirmFullAccess] = useState(true) const [editingMessageId, setEditingMessageId] = useState(null) - const [revertTarget, setRevertTarget] = useState(null) - const [revertError, setRevertError] = useState(null) - const [reverting, setReverting] = useState(false) - const canRevert = (message: ChatMessage): boolean => { - if (message.role !== 'user' || running || sending) return false - try { return getRevertChanges(messages, message.id).length > 0 } catch { return false } + const [rewindTarget, setRewindTarget] = useState(null) + const [rewindError, setRewindError] = useState(null) + const [rewinding, setRewinding] = useState(false) + /** + * 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision): + * 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口; + * 会话运行中 showDefaultRevert = !isSessionActive 为假,同样不显示。 + * 可见性与文件改动无关:没有产物版本时弹窗走 no-version 变体,仅回退对话。 + */ + const firstUserRequestId = messages.find((message) => message.role === 'user')?.requestId + const rewindEntryVisible = (message: ChatMessage): boolean => { + if (message.role !== 'user' || running || sending || !message.requestId) return false + return !firstUserRequestId || message.requestId !== firstUserRequestId + } + /** 原版 M.confirm('rollback') 的三种变体:artifacts / no-version / plain(回退预览不可用)。 */ + const rewindPreview = (message: ChatMessage): { variant: 'artifacts' | 'no-version' | 'plain'; changes: RevertChange[] } => { + try { + const changes = getRevertChanges(messages, message.id) + return { variant: changes.length ? 'artifacts' : 'no-version', changes } + } catch { return { variant: 'plain', changes: [] } } } const [activeSegment, setActiveSegment] = useState('work') const [homeAction, setHomeAction] = useState(null) @@ -196,8 +211,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }, [messages, running]) useEffect(() => { - setRevertTarget(null) - setRevertError(null) + setRewindTarget(null) + setRewindError(null) followBottom.current = true const el = scrollRef.current if (el) el.scrollTop = el.scrollHeight @@ -378,11 +393,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, onShowArtifacts={() => window.dispatchEvent(new CustomEvent('ycode:open-artifacts', { detail: { sessionId: currentId } }))} buildingPlanId={running ? messages.find((item) => item.role === 'user' && item.requestId === running.runId)?.planId : undefined} onBuildPlan={async (plan) => { if (running || sending) throw new Error('请等待当前运行结束'); await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { permissionMode, planId: plan.id }) }} - actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: canRevert(message), + actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding, + rollbackPending: rewinding, canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId, requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined, onRegenerate: regenerate, - onRevert: canRevert(message) ? () => { setRevertError(null); setRevertTarget(message) } : undefined, + onRevert: rewindEntryVisible(message) && !rewinding ? () => { setRewindError(null); setRewindTarget(message) } : undefined, onFeedback: (target, value) => feedback(target.id, value) }} editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} /> ))} @@ -398,18 +414,31 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, )} - {revertTarget &&
!reverting && setRevertTarget(null)}> -
event.stopPropagation()}> -

恢复到此版本?

-

将恢复此消息之后由工具修改的文件,并退回对应的对话上下文。

- {revertError &&

{revertError}

} -
-
-
-
} + {rewindTarget && (() => { + const preview = rewindPreview(rewindTarget) + const title = preview.variant === 'artifacts' ? rewindLabel('rewind.dialog.artifacts.title') + : preview.variant === 'no-version' ? rewindLabel('rewind.dialog.noVersion.title') : rewindLabel('rewind.dialog.plain.title') + const description = preview.variant === 'artifacts' ? rewindLabel('rewind.dialog.artifacts.desc') + : preview.variant === 'no-version' ? rewindLabel('rewind.dialog.noVersion.desc') : rewindLabel('rewind.dialog.plain.desc') + return
!rewinding && setRewindTarget(null)}> +
event.stopPropagation()}> +

{title}

+

{description}

+ {preview.changes.length > 0 &&
+ {preview.changes.map((change) =>
+ {change.path.split(/[\\/]/).pop()} + {change.before === null ? rewindLabel('rewind.item.willDelete') : rewindLabel('rewind.item.restoreUnknown')} +
)} +
} + {rewindError &&

{rewindError}

} +
+
+
+
+ })()} ) } diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 6eaee07..5fdd01d 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1381,6 +1381,39 @@ html[data-theme='dark'] .btn-stop { margin-top: var(--yc-space-6); } +/* 回退弹窗的产物清单:原版 RewindArtifactList(maxHeight 200 / 13px 行)。 */ +.rewind-artifacts { + display: flex; + flex-direction: column; + max-height: 200px; + overflow-y: auto; + margin-top: var(--yc-space-6); +} + +.rewind-artifacts__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--yc-space-3); + padding: var(--yc-space-3) 0; + font-size: var(--yc-text-caption-size, 13px); + line-height: var(--yc-line-height-5, 20px); +} + +.rewind-artifacts__name { + overflow: hidden; + min-width: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rewind-artifacts__status { + flex-shrink: 0; + margin-left: var(--yc-space-4); + color: var(--yc-text-secondary); + font-size: var(--yc-font-size-3, 12px); +} + .badge { display: inline-flex; align-items: center; diff --git a/src/renderer/src/workbuddy/conversation/rewind-labels.ts b/src/renderer/src/workbuddy/conversation/rewind-labels.ts new file mode 100644 index 0000000..09ac69d --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/rewind-labels.ts @@ -0,0 +1,49 @@ +/** + * 原版“回退”(rewind)文案。 + * + * 来源:WorkBuddy 5.8.3 桌面端渲染脚本(REA Evidence + * ev_e91787b1d4dea651582c6cf12827775b9068b0ee8b80c68ba08d4c1d87d94e90 / + * ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb, + * 脚本 SHA-256 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b) + * 中 zh-CN 文案表逐字回收;`rewind.entry.tooltip` 也经由桌面端 + * ConversationTimelineImpl 的 `revertLabel: t('rewind.entry.tooltip')` 接线确认。 + */ +const zhCN = { + 'rewind.entry.tooltip': '将对话及产物回退到此消息前', + 'rewind.dialog.plain.title': '将对话回退到此消息前?', + 'rewind.dialog.plain.desc': '回退将把会话内容回退到此消息前,请谨慎操作', + 'rewind.dialog.artifacts.title': '将对话及以下产物版本回退到此消息前?', + 'rewind.dialog.artifacts.desc': '回退暂不支持部分类型文件', + 'rewind.dialog.noVersion.title': '当前对话无产物版本,回退将仅回退对话内容', + 'rewind.dialog.noVersion.desc': '当前对话无产物版本,回退会话后产物版本不变,仅回退消息内容', + 'rewind.edit.artifacts.title': '是否回退产物历史版本?', + 'rewind.edit.artifacts.desc': '选择回退产物,将基于本条消息前的版本进行修改;不选择回退,则从最新版开始编辑', + 'rewind.edit.noHistory.title': '当前对话无产物历史版本,是否继续编辑?', + 'rewind.edit.noHistory.desc': '当前对话无产物历史版本,编辑会话后产物将基于最新版本开始生成', + 'rewind.item.restoreAt': '回退至 {time} 版本', + 'rewind.item.restoreUnknown': '回退至历史版本', + 'rewind.item.willDelete': '将被删除', + 'rewind.item.versionDeleted': '历史版本已被删除,将保持当前版本', + 'rewind.item.fallbackTooltip': '此消息前的版本已被删除,仅可回退至后面生成的版本', + 'rewind.item.noEarlierVersion': '不回退版本', + 'rewind.item.noEarlierVersionTooltip': '该产物无历史版本,回退后文件仍会保留在目录中', + 'rewind.action.cancel': '取消', + 'rewind.action.rewind': '回退', + 'rewind.action.skipRewind': '不回退', + 'rewind.action.edit': '编辑', + 'rewind.action.resend': '重发', + 'rewind.error.rollbackFailed': '对话回退失败,请稍后重试。', + 'rewind.error.editFailed': '对话编辑失败,请稍后重试。', + 'rewind.error.resendFailed': '对话重发失败,请稍后重试。' +} as const + +export type RewindLabelKey = keyof typeof zhCN + +export function rewindLabel(key: RewindLabelKey, params?: Readonly>): string { + const template: string = zhCN[key] + if (!params) return template + return template.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = params[name] + return value == null ? match : String(value) + }) +} diff --git a/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx b/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx index 1eab3e4..954105a 100644 --- a/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx +++ b/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx @@ -1,7 +1,7 @@ import { useRef, useState, type ReactNode } from 'react' import type { ChatMessage } from '@shared/types' -import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons' -import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons' +import { CopyMessageIcon } from './RecoveredActionIcons' +import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon } from './SourceActionIcons' import { RuntimeRegenerateIcon } from './RuntimeIcons' import { copyUserMessage } from './message-copy' import { @@ -35,16 +35,10 @@ export function MessageActionToolbar({ message, requestId = message.requestId, requestMessages, - busyState = 'idle', - canRevert = true, - rollbackPending = false, canRegenerate = false, isSessionActive = false, isLastAssistant = false, onCopy, - onEdit, - onRevert, - onResend, onRegenerate, onFeedback }: MessageActionToolbarProps): React.JSX.Element { @@ -112,17 +106,15 @@ export function MessageActionToolbar({ ) - const isUser = message.role === 'user' - const regenAllowed = !isUser && canRegenerate && isSessionActive && isLastAssistant && !!requestId + // 用户消息已由 UserMessageToolbar(copy / rewind / edit)承载;此工具条只渲染助手消息操作, + // 原版的“重新发送”入口不对应任何助手按钮,故不再渲染任何用户消息按钮。 + const regenAllowed = canRegenerate && isSessionActive && isLastAssistant && !!requestId return (
{button('复制', 'copy', copy, false, )} - {isUser && busyState === 'idle' && onEdit && button('编辑', 'edit', () => onEdit(message), false, )} - {isUser && onRevert && button('恢复到此版本', 'revert', () => onRevert(message), !canRevert || rollbackPending, )} - {isUser && onResend && button('重新发送', 'resend', () => onResend(message), busyState !== 'idle', )} - {!isUser && onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? : )} - {!isUser && onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? : )} + {onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? : )} + {onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? : )} {regenAllowed && onRegenerate && button('重新生成', 'regenerate', () => onRegenerate(requestId!, message), false, )} {pendingAction && 处理中} {error && {error}} diff --git a/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx b/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx index 8dcda91..32ebe16 100644 --- a/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx +++ b/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx @@ -3,6 +3,7 @@ import type { ChatMessage } from '@shared/types' import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons' import { RevertToBeforeIcon } from '../conversation/MessageEditorIcons' import { sourceConversationLabel } from '../conversation/source-labels' +import { rewindLabel } from '../conversation/rewind-labels' import { formatMessageTime } from '../../components/AssistantMessageDetails' import { RecoveredSelectTooltip } from '../../components/source-select/RecoveredSelectTooltip' import { copyUserMessage, type MessageCopyWriter } from './message-copy' @@ -80,7 +81,8 @@ export function UserMessageToolbar({ message, canEdit = false, onEdit, onCopy, o
{time ? {time} : null} {iconButton('copy', sourceConversationLabel(copied ? 'markdown.copied' : 'markdown.copy'), , () => void copy(), { hideTooltipOnClick: false })} - {onRevert ? iconButton('revert', sourceConversationLabel('userMessage.action.revert'), , () => void onRevert(message), { disabled: revertDisabled }) : null} + {/* 原版桌面端用 revertLabel 覆盖库默认标签:rewind.entry.tooltip(ConversationTimelineImpl.userMessage.actions)。 */} + {onRevert ? iconButton('revert', rewindLabel('rewind.entry.tooltip'), , () => void onRevert(message), { disabled: revertDisabled }) : null} {canEdit && onEdit ? iconButton('edit', sourceConversationLabel('userMessage.action.edit'), , () => void onEdit(message)) : null} {error ? {error} : null}