feat(conversation): replicate WorkBuddy rewind-to-message action (TODO 05)
This commit is contained in:
1 parent
074523a501
commit
c7ab047d52
11 files changed
+293
-60
No files matched your search
@@ -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. 对话区域滚动条
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+3
-2
@@ -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!)
|
||||
|
||||
@@ -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<PermissionMode>('default-sandbox')
|
||||
const [confirmFullAccess, setConfirmFullAccess] = useState(true)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [revertTarget, setRevertTarget] = useState<ChatMessage | null>(null)
|
||||
const [revertError, setRevertError] = useState<string | null>(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<ChatMessage | null>(null)
|
||||
const [rewindError, setRewindError] = useState<string | null>(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<SourceHomeMode>('work')
|
||||
const [homeAction, setHomeAction] = useState<string | null>(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,
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{revertTarget && <div className="overlay" onClick={() => !reverting && setRevertTarget(null)}>
|
||||
<section className="dialog" role="dialog" aria-modal="true" aria-label="恢复到此版本" onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="dialog__title">恢复到此版本?</h2>
|
||||
<p>将恢复此消息之后由工具修改的文件,并退回对应的对话上下文。</p>
|
||||
{revertError && <p role="alert">{revertError}</p>}
|
||||
<div className="dialog__actions"><button className="btn" disabled={reverting} onClick={() => setRevertTarget(null)}>取消</button>
|
||||
<button className="btn btn--primary" disabled={reverting} onClick={() => {
|
||||
setReverting(true)
|
||||
void revert(revertTarget.id).then(() => setRevertTarget(null)).catch((error) => setRevertError(String(error instanceof Error ? error.message : error))).finally(() => setReverting(false))
|
||||
}}>{reverting ? '恢复中…' : '恢复'}</button></div>
|
||||
</section>
|
||||
</div>}
|
||||
{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 <div className="overlay" onClick={() => !rewinding && setRewindTarget(null)}>
|
||||
<section className="dialog" role="dialog" aria-modal="true" aria-label={title} onClick={(event) => event.stopPropagation()}>
|
||||
<h2 className="dialog__title">{title}</h2>
|
||||
<p>{description}</p>
|
||||
{preview.changes.length > 0 && <div className="rewind-artifacts">
|
||||
{preview.changes.map((change) => <div className="rewind-artifacts__row" key={change.path}>
|
||||
<span className="rewind-artifacts__name" title={change.path}>{change.path.split(/[\\/]/).pop()}</span>
|
||||
<span className="rewind-artifacts__status">{change.before === null ? rewindLabel('rewind.item.willDelete') : rewindLabel('rewind.item.restoreUnknown')}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
{rewindError && <p role="alert">{rewindError}</p>}
|
||||
<div className="dialog__actions"><button className="btn" disabled={rewinding} onClick={() => setRewindTarget(null)}>{rewindLabel('rewind.action.cancel')}</button>
|
||||
<button className="btn btn--primary" disabled={rewinding} aria-busy={rewinding} onClick={() => {
|
||||
setRewinding(true)
|
||||
void revert(rewindTarget.id).then(() => setRewindTarget(null)).catch((error) => setRewindError(String(error instanceof Error ? error.message : error))).finally(() => setRewinding(false))
|
||||
}}>{rewindLabel('rewind.action.rewind')}</button></div>
|
||||
</section>
|
||||
</div>
|
||||
})()}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Record<string, string | number>>): 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)
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
</button>
|
||||
)
|
||||
|
||||
const isUser = message.role === 'user'
|
||||
const regenAllowed = !isUser && canRegenerate && isSessionActive && isLastAssistant && !!requestId
|
||||
// 用户消息已由 UserMessageToolbar(copy / rewind / edit)承载;此工具条只渲染助手消息操作,
|
||||
// 原版的“重新发送”入口不对应任何助手按钮,故不再渲染任何用户消息按钮。
|
||||
const regenAllowed = canRegenerate && isSessionActive && isLastAssistant && !!requestId
|
||||
|
||||
return (
|
||||
<div className="wb-message-actions" role="toolbar" aria-label="消息操作">
|
||||
{button('复制', 'copy', copy, false, <CopyMessageIcon />)}
|
||||
{isUser && busyState === 'idle' && onEdit && button('编辑', 'edit', () => onEdit(message), false, <EditMessageIcon />)}
|
||||
{isUser && onRevert && button('恢复到此版本', 'revert', () => onRevert(message), !canRevert || rollbackPending, <SourceRevertIcon />)}
|
||||
{isUser && onResend && button('重新发送', 'resend', () => onResend(message), busyState !== 'idle', <SourceResendIcon />)}
|
||||
{!isUser && onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? <SourceThumbsUpSolidIcon /> : <SourceThumbsUpIcon />)}
|
||||
{!isUser && onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? <SourceThumbsDownSolidIcon /> : <SourceThumbsDownIcon />)}
|
||||
{onFeedback && button('赞同', 'like', () => onFeedback(message, 'like'), false, message.feedback === 'like' ? <SourceThumbsUpSolidIcon /> : <SourceThumbsUpIcon />)}
|
||||
{onFeedback && button('不赞同', 'dislike', () => onFeedback(message, 'dislike'), false, message.feedback === 'dislike' ? <SourceThumbsDownSolidIcon /> : <SourceThumbsDownIcon />)}
|
||||
{regenAllowed && onRegenerate && button('重新生成', 'regenerate', () => onRegenerate(requestId!, message), false, <RuntimeRegenerateIcon />)}
|
||||
{pendingAction && <span className="wb-message-actions__pending" role="status">处理中</span>}
|
||||
{error && <span className="wb-message-actions__error" role="alert">{error}</span>}
|
||||
|
||||
@@ -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
|
||||
<div className="cr-toolbar" data-message-id={message.id} role="toolbar">
|
||||
{time ? <span className="cr-toolbar__time">{time}</span> : null}
|
||||
{iconButton('copy', sourceConversationLabel(copied ? 'markdown.copied' : 'markdown.copy'), <CopyMessageIcon className="cr-icon-button__icon" />, () => void copy(), { hideTooltipOnClick: false })}
|
||||
{onRevert ? iconButton('revert', sourceConversationLabel('userMessage.action.revert'), <RevertToBeforeIcon className="cr-icon-button__icon" />, () => void onRevert(message), { disabled: revertDisabled }) : null}
|
||||
{/* 原版桌面端用 revertLabel 覆盖库默认标签:rewind.entry.tooltip(ConversationTimelineImpl.userMessage.actions)。 */}
|
||||
{onRevert ? iconButton('revert', rewindLabel('rewind.entry.tooltip'), <RevertToBeforeIcon className="cr-icon-button__icon" />, () => void onRevert(message), { disabled: revertDisabled }) : null}
|
||||
{canEdit && onEdit ? iconButton('edit', sourceConversationLabel('userMessage.action.edit'), <EditMessageIcon className="cr-icon-button__icon" />, () => void onEdit(message)) : null}
|
||||
{error ? <span className="wb-message-actions__error" role="alert">{error}</span> : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user