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
+991 -66

No files matched your search

+28 -4
View File
@@ -70,11 +70,35 @@
### 03. 编辑已发送消息 ### 03. 编辑已发送消息
- [ ] 拆解原版编辑入口、编辑容器、文本排版、按钮、焦点与键盘交互。 - [x] 拆解原版编辑入口、编辑容器、文本排版、按钮、焦点与键盘交互。
- [ ] 对齐进入编辑、修改、取消、提交及失败状态。 - [x] 对齐进入编辑、修改、取消、提交及失败状态。
- [ ] 验收:编辑正确消息;取消恢复原内容;提交后的消息分支与产物处理符合已确认行为。 - [x] 验收:编辑正确消息;取消恢复原内容;提交后的消息分支与产物处理符合已确认行为。
状态:待 REA 对照。 状态:主体已实现,并通过真实窗口回归与真实 IPC 回归;工具条淡入/提示出现时序未逐帧采集。
原版证据:
- 渲染器实现(REA 回收 `.tmp/wb-src-DbE1_mgM.js`):`UserMessageEditProvider`、`UserMessageEditor`/`UserMessageEditorContent`(校验规则 `empty`/`too-long`,上限 `1e4`;`message.edit.submit`;错误码映射 `userMessage.edit.error.retryLater`;`RenderNoHandlerError` → `sendFailed`;外部 mousedown 关闭规则与豁免选择器)、`ToolbarRenderer`(`cr-toolbar` = 时间 + 复制 + 恢复此版本 + 编辑;编辑中或已丢弃时不渲染)、`IconButton`、`RevertToBeforeIcon`、`LoadingSpinnerIcon`、`formatMessageTime`。
- 样式:REA 回收样式表 `src-CyXHA0IC.css`(SHA-256 `a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe`)中 21 条 `.cr-user-message-editor*` / `.cr-self-bubble--editing` 规则与 13 条 `.cr-toolbar*` 规则。
- 文案:REA Evidence `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb` 中 `userMessage.edit.*`、`userMessage.action.*`、`markdown.copy`/`markdown.copied` 的 zh-CN 与 en-US。
- 运行时几何与样式:`ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3`(工具条 97.23×28、时间 33.23×20、复制/编辑按钮 24×24、图标 14×14;无产物会话不显示恢复按钮;编辑态气泡 535×110、编辑器 511×94、输入容器 50、页脚 44、提示 369×18、取消 62×32、发送 60×30)、`ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f`(编辑器保持打开)、`ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405`(编辑态布局快照与计算样式:容器透明/无边框/无阴影/`min-height:50px`、宿主 `min-height:64px`、可编辑区 15px/1.75、提示 13/18 `rgba(0,0,0,.5)`、取消边框 `1px rgb(235,235,235)`、发送 `rgb(26,26,26)`/白字、InfoIcon path)、`ev_308764954de03fa28e4a980d609d33eabf794c44ce6733bfe1b83cec36ef562a`(取消关闭并恢复原气泡)。
- 观察范围:桌面浅色主题、单条用户消息;覆盖无产物写入会话(工具条无恢复入口)与含 `write_file` 记录会话(出现恢复入口)。未覆盖:工具条淡入与 tooltip 出现的逐帧时序、未发送/被丢弃消息的“恢复到此版本”状态。
实现:
- `workbuddy/conversation/UserMessageEditor.tsx`:就地编辑器,原版 DOM(`cr-user-message-editor` > `cr-input-container-wrapper`/`cr-input-container`/`cr-input-editor-host` + `[data-slate-editor]` 可编辑区、`__error`、`__footer`/`__hint`/`__hint-icon`/`__actions`/`__cancel`/`__send`);空内容与 10000 字符校验、Enter 提交(Shift+Enter 换行)、提交中禁用按钮并显示 spinner 与 `aria-busy`、失败文案按 `error.retryLater`/`sendFailed` 映射、外部 mousedown 仅在没有改动且未提交时取消。
- `workbuddy/conversation/MessageEditorIcons.tsx`:`RevertToBeforeIcon`、`EditorInfoIcon`、`EditorSpinnerIcon` 按回收源码与运行时 `svg_nodes` 原样复刻。
- `workbuddy/conversation/message-edit-source.css`:补齐 18 条缺失回收规则(含编辑容器 `min-height:50px` 与 `:focus-within` 去阴影以覆盖宿主适配规则);`.cr-self-bubble--editing` 与 `:has(.cr-user-message-editor)` 复用 `source-styles.css` 已有声明。
- `workbuddy/message-actions/UserMessageToolbar.tsx`:`div.cr-toolbar[data-message-id][role=toolbar]`,时间按三档格式显示;复制按钮 2000ms 内切换“已复制”且 tooltip 不因点击隐藏;恢复此版本仅在存在可回退产物时渲染并透传禁用态;编辑按钮受会话忙状态约束;图标按钮为 `button.cr-icon-button[data-action][aria-label]` + `svg.cr-icon-button__icon`。
- `workbuddy/message-actions/message-copy.ts`:抽出用户消息复制回退链,旧 `MessageActionToolbar` 复用同一实现。
- `components/MessageBubble.tsx` / `components/ChatPane.tsx`:新增 `editingMessageId` 与 `startEdit`/`cancelEdit`/`submitEdit`;编辑态用编辑器替换气泡内容并卸载工具条;提交走 `send(..., { fromMessageId, action:'edit' })`;移除底部输入框“编辑消息”横幅与用户消息的“重新发送”入口。
- `main/workspace-revert.ts` 新增 `rollbackEditedArtifacts`;`main/ipc.ts` 的 `chat:send` 在 `action==='edit'` 时先回退产物(无写入记录则跳过;记录不完整则拒绝提交),原生分支失败时撤销文件回退。
验证:`node scripts/verify-user-message-edit.cjs` 在真实隔离 Electron(真实 preload/IPC/渲染进程,`chat:send` 桩记录载荷)中 50 项断言全部通过,覆盖工具条几何/配色/时间格式/按钮集合与图标(copy/revert/edit、14×14、viewBox 16/14)、悬停显隐、进入编辑、编辑器几何与排版(容器 50/宿主 64/可编辑 15px·1.75/提示与按钮尺寸)、取消恢复原内容、有改动时外部 mousedown 不关闭、空内容校验阻断提交、提交中禁用 + spinner + `aria-busy`、`action:'edit'` + `fromMessageId` 真实 IPC 载荷、Enter 提交、无渲染器报错;报告与截图:`.tmp/edit-O1kT3A/`(早期 `.tmp/edit-2MoqPF/`、`.tmp/edit-624Sw3/`)。`npm run test:conversation-ipc` 新增 `testEditArtifactRollback`,覆盖真实主进程语义:产物回退到消息前版本、历史前缀保留并追加替换消息、原生分支失败时撤销文件回退且会话不变、无产物改动可直接编辑、含不可回滚工具时拒绝编辑且文件与会话不变。`npm run typecheck`、`npm run test:conversation`、`npm run test:recovered`、`npm run test:chat`、`npm run build` 均通过。
遗留:
1. 工具条淡入(`opacity .2s`,hover 后 `transition-delay:0s`)与 tooltip 出现时序未逐帧采集,当前按回收 CSS 声明实现,未做像素级时序比对。
2. 原版提交失败的错误码集合(`resend-settle-timeout`、`cancel-barrier`、`send-in-progress` 等)仅有映射表证据;YCode 目前只区分“上一轮未结束”和“其它失败”两类文案。
3. 原版 `userMessage.edit.resume`(恢复到此版本)依赖未发送/被丢弃消息状态,YCode 暂无对应状态,未渲染该按钮(DOM 结构与 CSS 规则已保留)。
4. 原版 `formatMessageTime` 含“昨天 HH:mm”分支,与此前用户确认的三档格式不同;当前沿用用户确认的三档规则,差异待确认。
### 04. 用户消息悬停时间 ### 04. 用户消息悬停时间
+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 { 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 { 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 { 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 { 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' import type { ChatMessage } from '../src/shared/types'
@@ -132,11 +134,34 @@ const assistantMarkup = renderToStaticMarkup(<MessageActionToolbar message={assi
assert.match(assistantMarkup, /复制/) assert.match(assistantMarkup, /复制/)
assert.match(assistantMarkup, /重新生成/) assert.match(assistantMarkup, /重新生成/)
assert.doesNotMatch(assistantMarkup, /编辑/) assert.doesNotMatch(assistantMarkup, /编辑/)
const userMarkup = renderToStaticMarkup(<MessageActionToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onEdit={() => undefined} onRevert={() => undefined} onResend={() => undefined} />) // 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
assert.match(userMarkup, /编辑/) const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => undefined} onRevert={() => undefined} />)
assert.match(userMarkup, /重新发送/) assert.match(userMarkup, /class="cr-toolbar"/)
assert.doesNotMatch(userMarkup, /重新生成/) assert.match(userMarkup, /cr-toolbar__time/)
assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only') 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( const composerMarkup = renderToStaticMarkup(
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending /> <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') '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> { async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
const invoke = invokeFor(state) const invoke = invokeFor(state)
for (const nativeAccepted of [false, true]) { for (const nativeAccepted of [false, true]) {
@@ -684,6 +738,7 @@ async function main(): Promise<void> {
await testConcurrency(bundle, state, invoke) await testConcurrency(bundle, state, invoke)
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action) for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
await testActionBranchFailure(bundle, state) await testActionBranchFailure(bundle, state)
await testEditArtifactRollback(bundle, state, workspace)
await testInterruptedResend(bundle, state) await testInterruptedResend(bundle, state)
await testAttachments(bundle, state, tempRoot) await testAttachments(bundle, state, tempRoot)
await testSpeech(bundle, state) 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)
})
}
+8 -1
View File
@@ -29,7 +29,7 @@ import { createInteractionBroker, respondToInteraction } from './interaction-bro
import { attachmentPrompt, commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments' import { attachmentPrompt, commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { transcribeAudio } from './speech' import { transcribeAudio } from './speech'
import { getRevertChanges } from '@shared/revert' import { getRevertChanges } from '@shared/revert'
import { restoreWorkspaceFiles } from './workspace-revert' import { restoreWorkspaceFiles, rollbackEditedArtifacts } from './workspace-revert'
import { assistantWireHistory } from './agent/history' import { assistantWireHistory } from './agent/history'
import * as models from './models' import * as models from './models'
import * as workspaces from './workspaces' import * as workspaces from './workspaces'
@@ -563,6 +563,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
} }
const target = history[targetIndex] const target = history[targetIndex]
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息') if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
// 原版编辑语义(userMessage.edit.hint):产物回退至此消息前版本,再从此处重新开始对话。
const editUndo = payload.action === 'edit' ? await rollbackEditedArtifacts(workspaceRoot, history, payload.fromMessageId!) : undefined
try {
if (kernel) { if (kernel) {
if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录') if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
let entryId = target.nativeEntryId let entryId = target.nativeEntryId
@@ -589,6 +592,10 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
const entry = ompKernels.get(sessionId) const entry = ompKernels.get(sessionId)
if (entry && entryId) entry.appliedBranchId = entryId if (entry && entryId) entry.appliedBranchId = entryId
} }
} catch (error) {
if (editUndo) await editUndo().catch(() => undefined)
throw error
}
history = history.slice(0, targetIndex) history = history.slice(0, targetIndex)
} }
const runId = randomUUID() const runId = randomUUID()
+13
View File
@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path' import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path'
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import type { RevertChange } from '@shared/revert' import type { RevertChange } from '@shared/revert'
import { getRevertChanges } from '@shared/revert'
import type { ChatMessage } from '@shared/types'
async function replaceFile(path: string, content: string | null): Promise<void> { async function replaceFile(path: string, content: string | null): Promise<void> {
if (content === null) { await fs.unlink(path); return } if (content === null) { await fs.unlink(path); return }
@@ -33,3 +35,14 @@ export async function restoreWorkspaceFiles(workspace: string, changes: RevertCh
} catch (error) { await undo(); throw error } } catch (error) { await undo(); throw error }
return undo return undo
} }
/**
* 编辑提交前的产物回退(原版 userMessage.edit.hint:产物回退至此消息前版本)。
* 返回撤销函数;该消息之后没有可回退的写入时返回 undefined。
* 记录不完整时由 getRevertChanges 抛错,调用方应中止提交而不是静默保留文件修改。
*/
export async function rollbackEditedArtifacts(workspace: string, messages: ChatMessage[], userMessageId: string): Promise<(() => Promise<void>) | undefined> {
const changes = getRevertChanges(messages, userMessageId)
if (!changes.length) return undefined
return restoreWorkspaceFiles(workspace, changes)
}
+15 -8
View File
@@ -111,6 +111,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden]) useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden])
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox') const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox')
const [confirmFullAccess, setConfirmFullAccess] = useState(true) const [confirmFullAccess, setConfirmFullAccess] = useState(true)
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
const [revertTarget, setRevertTarget] = useState<ChatMessage | null>(null) const [revertTarget, setRevertTarget] = useState<ChatMessage | null>(null)
const [revertError, setRevertError] = useState<string | null>(null) const [revertError, setRevertError] = useState<string | null>(null)
const [reverting, setReverting] = useState(false) const [reverting, setReverting] = useState(false)
@@ -205,17 +206,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => { const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
if ((!text && !attachments.length && !quotes.length) || running || sending) return if ((!text && !attachments.length && !quotes.length) || running || sending) return
followBottom.current = true followBottom.current = true
await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode, ...(entry.editingId ? { fromMessageId: entry.editingId, action: 'edit' as const } : {}) }) await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
const destination = draftMoves.current.get(draftKey) ?? draftKey const destination = draftMoves.current.get(draftKey) ?? draftKey
draftMoves.current.delete(draftKey) draftMoves.current.delete(draftKey)
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } }))
} }
const edit = (message: ChatMessage): void => { /** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
setDrafts((all) => ({ ...all, [draftKey]: { text: message.content, attachments: message.attachments ?? [], resources: message.resources ?? [], quotes: message.quotes, skills: message.skills, editingId: message.id } })) const startEdit = (message: ChatMessage): void => {
document.querySelector<HTMLTextAreaElement>('.wb-composer__input')?.focus() if (running || sending) return
setEditingMessageId(message.id)
}
const cancelEdit = (): void => setEditingMessageId(null)
const submitEdit = async (message: ChatMessage, text: string): Promise<void> => {
await send(text, { attachments: message.attachments, resources: message.resources, quotes: message.quotes,
skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'edit' })
setEditingMessageId(null)
} }
const resend = (message: ChatMessage): Promise<void> => send(message.content, { attachments: message.attachments, resources: message.resources, quotes: message.quotes, skillIds: message.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'resend' })
const regenerate = (_requestId: string, message: ChatMessage): Promise<void> => { const regenerate = (_requestId: string, message: ChatMessage): Promise<void> => {
const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user') const preceding = messages.slice(0, messages.findIndex((item) => item.id === message.id)).findLast((item) => item.role === 'user')
return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, skillIds: preceding.skills?.map(item => item.id), permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve()
@@ -255,7 +262,6 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
/** 输入框(首页与会话内共用;首页编辑区更高,由 CSS 的 data-variant 区分) */ /** 输入框(首页与会话内共用;首页编辑区更高,由 CSS 的 data-variant 区分) */
const composer = ( const composer = (
<> <>
{entry.editingId && <div className="composer__edit-banner">编辑消息<button type="button" onClick={() => setDrafts((all) => ({ ...all, [draftKey]: { ...entry, editingId: undefined } }))}>取消</button></div>}
<ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={value => { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []} <ComposerControls key={draftKey} className={isHome ? 'wb-composer--home' : undefined} sessionId={currentId} value={draft} onChange={value => { setDraft(value); if (value === '/') setSkillPicker({ owner: draftKey, anchor: document.querySelector('.wb-composer__add-button') }) }} attachments={entry.attachments} resources={entry.resources ?? []}
onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })} onOpenSkills={anchor => setSkillPicker({ owner: draftKey, anchor })}
onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }} onOpenExperts={anchor => { setSkillPicker(null); setExpertPicker({ owner: draftKey, anchor }); void window.ycode.experts.list().then(setLocalExperts).catch(error => setExpertError(String(error))) }}
@@ -375,9 +381,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: canRevert(message), actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: canRevert(message),
canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId, canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId,
requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined, requestId: message.requestId, requestMessages: message.requestId ? messages.filter((item) => item.requestId === message.requestId) : undefined,
onEdit: edit, onResend: resend, onRegenerate: regenerate, onRegenerate: regenerate,
onRevert: canRevert(message) ? () => { setRevertError(null); setRevertTarget(message) } : undefined, onRevert: canRevert(message) ? () => { setRevertError(null); setRevertTarget(message) } : undefined,
onFeedback: (target, value) => feedback(target.id, value) }} /> onFeedback: (target, value) => feedback(target.id, value) }}
editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} />
))} ))}
</div></div> </div></div>
</div> </div>
+18 -7
View File
@@ -13,6 +13,8 @@ import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fo
import { Fragment, useState } from 'react' import { Fragment, useState } from 'react'
import type { MessageActionToolbarProps } from '../workbuddy/message-actions/MessageActionToolbar' import type { MessageActionToolbarProps } from '../workbuddy/message-actions/MessageActionToolbar'
import { MessageActionToolbar } from '../workbuddy/message-actions/MessageActionToolbar' import { MessageActionToolbar } from '../workbuddy/message-actions/MessageActionToolbar'
import { UserMessageToolbar } from '../workbuddy/message-actions/UserMessageToolbar'
import { UserMessageEditor } from '../workbuddy/conversation/UserMessageEditor'
import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateBlock, TaskBlock, ToolCallRenderer, type ConversationToolCall } from '../workbuddy/conversation' import { PermissionConfirm, QuestionAnswerTranscript, ThinkingBlock, PlanCreateBlock, TaskBlock, ToolCallRenderer, type ConversationToolCall } from '../workbuddy/conversation'
import { conversationLabel } from '../workbuddy/conversation/labels' import { conversationLabel } from '../workbuddy/conversation/labels'
import { downloadInHost, openPathInHost } from '../workbuddy/host' import { downloadInHost, openPathInHost } from '../workbuddy/host'
@@ -32,6 +34,11 @@ interface Props {
quotedSourceIds?: readonly string[] quotedSourceIds?: readonly string[]
darkMode?: boolean darkMode?: boolean
actions?: Omit<MessageActionToolbarProps, 'message'> actions?: Omit<MessageActionToolbarProps, 'message'>
/** 正在就地编辑的消息 id;命中时用编辑容器替换气泡内容。 */
editingMessageId?: string | null
onStartEdit?(message: ChatMessage): void
onCancelEdit?(): void
onSubmitEdit?(message: ChatMessage, text: string): Promise<void>
onRespond?(id: string, response: InteractionResponse): Promise<void> onRespond?(id: string, response: InteractionResponse): Promise<void>
sessionId?: string | null sessionId?: string | null
onBuildPlan?(plan: PlanContent): Promise<void> onBuildPlan?(plan: PlanContent): Promise<void>
@@ -40,7 +47,7 @@ interface Props {
onShowArtifacts?(): void onShowArtifacts?(): void
} }
export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { export default function MessageBubble({ message, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, onRespond, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element {
const [actionError, setActionError] = useState<string | null>(null) const [actionError, setActionError] = useState<string | null>(null)
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
const perform = (action: () => Promise<unknown>): void => { const perform = (action: () => Promise<unknown>): void => {
@@ -52,21 +59,25 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM
const downloadPlan = (plan: PlanContent): void => perform(() => downloadInHost({ filename: `${plan.title.replace(/[<>:"/\\|?*]/g, '_') || 'plan'}.md`, mimeType: 'text/markdown', encoding: 'utf8', data: planMarkdown(plan) })) const downloadPlan = (plan: PlanContent): void => perform(() => downloadInHost({ filename: `${plan.title.replace(/[<>:"/\\|?*]/g, '_') || 'plan'}.md`, mimeType: 'text/markdown', encoding: 'utf8', data: planMarkdown(plan) }))
// 用户消息:右侧灰色气泡 // 用户消息:右侧灰色气泡
if (message.role === 'user') { if (message.role === 'user') {
const editing = editingMessageId === message.id && !!onSubmitEdit
return ( return (
<div className="msg--user cr-frame cr-frame--right"><div className="cr-frame__content"> <div className="msg--user cr-frame cr-frame--right"><div className="cr-frame__content">
<div className="cr-self-message"><div className="cr-self-message__content"> <div className="cr-self-message"><div className="cr-self-message__content">
<div className="bubble cr-self-bubble"> <div className={`bubble cr-self-bubble${editing ? ' cr-self-bubble--editing' : ''}`}>
{!!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容"> {!editing && !!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
{message.quotes.map((quote) => <MessageQuoteTag key={quote.id} quote={quote} readOnly />)} {message.quotes.map((quote) => <MessageQuoteTag key={quote.id} quote={quote} readOnly />)}
</div>} </div>}
<div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div> {editing ? <UserMessageEditor message={message} onSubmit={(text) => onSubmitEdit!(message, text)} onCancel={() => onCancelEdit?.()} />
: <div className="cr-user-content"><div className="yc-user-editable">{message.content.split('\n').map((line, index) => <p key={index}><span className="yc-user-leaf"><span>{line || <br />}</span></span></p>)}</div></div>}
</div> </div>
{!!message.skills?.length && <div className="wb-composer__resources" aria-label="使用的技能">{message.skills.map(skill => <span className="wb-composer__resource" data-skill-uri={skill.uri} key={skill.id}>{skill.name}</span>)}</div>} {!editing && !!message.skills?.length && <div className="wb-composer__resources" aria-label="使用的技能">{message.skills.map(skill => <span className="wb-composer__resource" data-skill-uri={skill.uri} key={skill.id}>{skill.name}</span>)}</div>}
{message.attachments?.map((attachment) => <div className="msg__attachment" key={attachment.id}> {!editing && message.attachments?.map((attachment) => <div className="msg__attachment" key={attachment.id}>
{attachment.previewUrl ? <MarkdownContent content={`![${attachment.name.replace(/[\[\]]/g, '')}](${attachment.previewUrl})`} messageId={attachment.id} darkMode={darkMode} /> : <span>{attachment.name}</span>} {attachment.previewUrl ? <MarkdownContent content={`![${attachment.name.replace(/[\[\]]/g, '')}](${attachment.previewUrl})`} messageId={attachment.id} darkMode={darkMode} /> : <span>{attachment.name}</span>}
</div>)} </div>)}
</div></div> </div></div>
<MessageActionToolbar message={message} {...actions} /> {!editing && <UserMessageToolbar message={message} canEdit={actions?.busyState === 'idle' && !!onStartEdit} onEdit={onStartEdit}
onCopy={actions?.onCopy} onRevert={actions?.onRevert}
revertDisabled={actions?.canRevert === false || actions?.rollbackPending} />}
</div></div> </div></div>
) )
} }
@@ -0,0 +1,40 @@
import type { SVGProps } from 'react'
type IconProps = SVGProps<SVGSVGElement>
/**
* WorkBuddy 5.8.3 渲染器 RevertToBeforeIcon(回收源码 .tmp/wb-src-DbE1_mgM.js)。
* 用于用户消息工具条的“恢复此版本”入口(userMessage.action.revert)。
*/
export function RevertToBeforeIcon({ className, style, ...props }: IconProps): React.JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none" className={className} style={style} {...props}>
<path d="M2.0917 2.9167L-0.4125 5.4209Q-0.5904 5.5888 -0.5833 5.8333Q-0.5904 6.0779 -0.4125 6.2458Q-0.2445 6.4237 0 6.4167Q0.2445 6.4237 0.4125 6.2458L3.3291 3.3291Q3.507 3.1612 3.5 2.9167Q3.507 2.6721 3.3291 2.5042L0.4125 -0.4125Q0.2445 -0.5904 0 -0.5833Q-0.2445 -0.5904 -0.4125 -0.4125Q-0.5904 -0.2445 -0.5833 0Q-0.5904 0.2445 -0.4125 0.4125L2.0917 2.9167Z" fill="currentColor" fillRule="evenodd" transform="matrix(-1 0 0 1 5.25 2.33301)" />
<path d="M3.2083 0.5833L9.3333 0.5833Q9.5779 0.5904 9.7458 0.4125Q9.9237 0.2445 9.9167 0Q9.9237 -0.2445 9.7458 -0.4125Q9.5779 -0.5904 9.3333 -0.5833L3.2083 -0.5833Q1.6378 -0.5833 0.5272 0.5272Q-0.5833 1.6378 -0.5833 3.2083Q-0.5833 4.7789 0.5272 5.8894Q1.6378 7 3.2083 7L5.25 7Q5.4945 7.007 5.6625 6.8291Q5.8404 6.6612 5.8333 6.4167Q5.8404 6.1721 5.6625 6.0042Q5.4945 5.8263 5.25 5.8333L3.2083 5.8333Q2.121 5.8333 1.3522 5.0645Q0.5833 4.2956 0.5833 3.2083Q0.5833 2.121 1.3522 1.3522Q2.121 0.5833 3.2083 0.5833Z" fill="currentColor" fillRule="evenodd" transform="matrix(-1 0 0 1 11.6665 5.25)" />
</svg>
)
}
/**
* 编辑框提示图标(InfoIcon)。REA ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405
* 运行时 svg_nodes:14×14,viewBox 0 0 16 16。
*/
export function EditorInfoIcon({ className, ...props }: IconProps): React.JSX.Element {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 16 16" fill="none" className={className} {...props}>
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M8 7V11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<circle cx="8" cy="5" r="0.75" fill="currentColor" />
</svg>
)
}
/** WorkBuddy 5.8.3 渲染器 LoadingSpinnerIcon(size=1 → 12px,cr-input-spin 0.8s 线性无限旋转)。 */
export function EditorSpinnerIcon(): React.JSX.Element {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={{ animation: 'cr-input-spin 0.8s linear infinite' }}>
<circle cx="6" cy="6" r="5" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.5" />
<path d="M11 6a5 5 0 0 0-5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ChatMessage } from '@shared/types'
import { EditorInfoIcon, EditorSpinnerIcon } from './MessageEditorIcons'
import { sourceConversationLabel } from './source-labels'
import './message-edit-source.css'
/** WorkBuddy 5.8.3 `vJ = 1e4`:编辑内容上限 10000 字符。 */
export const USER_MESSAGE_EDIT_MAX_LENGTH = 10_000
/** 原版 `shouldDismissEditOnOutsideMouseDown` 的豁免选择器。 */
const DISMISS_SAFE_SELECTORS = [
'.cr-trigger-search-panel',
'.cr-popover',
'.cr-sub-menu__overlay',
'.cr-tooltip',
'.wb-tooltip',
'.cb-context-menu'
].join(',')
interface Props {
message: ChatMessage
onSubmit(text: string): Promise<void>
onCancel(): void
}
function readEditableText(editable: HTMLDivElement | null): string {
if (!editable) return ''
const raw = editable.innerText.replace(/\r\n/g, '\n')
return raw.endsWith('\n') ? raw.slice(0, -1) : raw
}
function hasEditableContent(text: string): boolean {
return text.split('\n').some((line) => line.trim().length > 0)
}
/**
* 用户消息就地编辑容器。
* 结构、校验、按钮与失焦规则来自 WorkBuddy 5.8.3 渲染器 UserMessageEditorContent
* (REA 回收 .tmp/wb-src-DbE1_mgM.js)与运行时采集 ev_5dd78e06…/ev_ee345b16…。
*/
export function UserMessageEditor({ message, onSubmit, onCancel }: Props): React.JSX.Element {
const rootRef = useRef<HTMLDivElement>(null)
const editableRef = useRef<HTMLDivElement>(null)
const submittingRef = useRef(false)
const [text, setText] = useState(message.content)
const [focused, setFocused] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
// 非受控写入初始内容,并把光标放到末尾(原版 prepareEditDraft + 自动聚焦)。
useEffect(() => {
const editable = editableRef.current
if (!editable) return
editable.innerText = message.content
editable.focus()
const range = document.createRange()
range.selectNodeContents(editable)
range.collapse(false)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}, [message.id, message.content])
const validation = useMemo<'empty' | 'too-long' | null>(() => {
if (!hasEditableContent(text)) return 'empty'
if (text.length > USER_MESSAGE_EDIT_MAX_LENGTH) return 'too-long'
return null
}, [text])
const validationMessage = validation === 'empty'
? sourceConversationLabel('userMessage.edit.emptyError')
: validation === 'too-long'
? sourceConversationLabel('userMessage.edit.maxLengthError', { maxLength: USER_MESSAGE_EDIT_MAX_LENGTH.toLocaleString() })
: null
const error = validationMessage ?? submitError
const hasChanges = text !== message.content
const submit = useCallback((): void => {
if (submittingRef.current) return
const current = readEditableText(editableRef.current)
if (!hasEditableContent(current) || current.length > USER_MESSAGE_EDIT_MAX_LENGTH) return
submittingRef.current = true
setSubmitting(true)
setSubmitError(null)
void onSubmit(current)
.catch((reason: unknown) => {
const detail = reason instanceof Error ? reason.message : String(reason)
setSubmitError(/正在运行|尚未结束|in progress/i.test(detail)
? sourceConversationLabel('userMessage.edit.error.retryLater')
: sourceConversationLabel('userMessage.edit.sendFailed'))
})
.finally(() => {
submittingRef.current = false
setSubmitting(false)
})
}, [onSubmit])
// 原版:提交中或有改动时不因外部 mousedown 关闭。
useEffect(() => {
const handleMouseDown = (event: MouseEvent): void => {
if (submittingRef.current || hasChanges) return
const target = event.target instanceof Element ? event.target : null
if (!target) return
if (rootRef.current?.contains(target)) return
if (target.closest(DISMISS_SAFE_SELECTORS)) return
onCancel()
}
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
}, [hasChanges, onCancel])
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
submit()
}
return (
<div className="cr-user-message-editor" ref={rootRef}>
<div className="cr-input-container-wrapper">
<div className={`cr-input-container${focused ? ' cr-input-container--focused' : ''}`}>
<div className="cr-input-editor-host">
<div
ref={editableRef}
className="cr-input-editor-content"
contentEditable
suppressContentEditableWarning
data-slate-editor="true"
role="textbox"
aria-multiline="true"
aria-label={sourceConversationLabel('userMessage.action.edit')}
onInput={(event) => setText(readEditableText(event.currentTarget))}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onKeyDown={handleKeyDown}
/>
</div>
</div>
</div>
{error ? <div className="cr-user-message-editor__error" role="alert">{error}</div> : null}
<div className="cr-user-message-editor__footer">
<div className="cr-user-message-editor__hint">
<EditorInfoIcon className="cr-user-message-editor__hint-icon" />
<span>{sourceConversationLabel('userMessage.edit.hint')}</span>
</div>
<div className="cr-user-message-editor__actions">
<button type="button" className="cr-user-message-editor__cancel" disabled={submitting} onClick={onCancel}>
{sourceConversationLabel('userMessage.edit.cancel')}
</button>
<button type="button" className="cr-user-message-editor__send" disabled={submitting || !!validationMessage} aria-busy={submitting} onClick={submit}>
{submitting ? <EditorSpinnerIcon /> : null}
<span>{sourceConversationLabel('userMessage.edit.send')}</span>
</button>
</div>
</div>
</div>
)
}
export default UserMessageEditor
@@ -0,0 +1,143 @@
/*
* 用户消息就地编辑:WorkBuddy 5.8.3 回收样式表 src-CyXHA0IC.css
* sha256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe
* REA 证据:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3(打开/取消编辑)、
* ev_5dd78e06e75b3a29b656d328c2efc2a820ea197e8204a39840207188a6496a0f(保持编辑器打开)、
* ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405(编辑态布局快照)。
* 说明:`.cr-self-bubble--editing` 与 `.cr-frame--right .cr-frame__content:has(.cr-user-message-editor)`
* 已在 source-styles.css 中,这里不重复。
*/
.cr-user-message-editor .cr-input-container {
box-shadow: none;
background: 0 0;
border: 0;
border-radius: 0;
padding-top: 0;
/* 原版 InputBox minHeight=50:编辑容器单行时高 50(ev_ee345b16 计算样式 min-height:50px)。 */
min-height: 50px;
}
.cr-user-message-editor .cr-input-container--focused,
.cr-user-message-editor .cr-input-container:focus-within {
box-shadow: none;
}
.cr-user-message-editor .cr-input-editor-host {
max-height: 310px;
padding: 0;
}
.cr-user-message-editor .cr-input-editor-host [contenteditable='true'] {
min-height: 50px !important;
}
.cr-user-message-editor .cr-input-editor-host [data-slate-editor] {
font-size: 15px;
line-height: 1.75 !important;
}
.cr-user-message-editor .cr-input-editor-host [data-slate-editor] * {
line-height: 1.75 !important;
}
.cr-user-message-editor__error {
color: var(--cr-text-error);
padding-top: 6px;
font-size: 13px;
line-height: 18px;
}
.cr-user-message-editor__footer {
justify-content: space-between;
align-items: center;
gap: 12px;
padding-top: 12px;
display: flex;
}
.cr-user-message-editor__hint {
min-width: 0;
color: var(--cr-text-tertiary);
flex: 1;
align-items: center;
gap: 4px;
font-size: 13px;
line-height: 18px;
display: flex;
}
.cr-user-message-editor__hint-icon {
color: var(--cr-text-tertiary);
flex-shrink: 0;
}
.cr-user-message-editor__actions {
flex-shrink: 0;
align-items: center;
gap: 8px;
display: flex;
}
.cr-user-message-editor__cancel,
.cr-user-message-editor__send,
.cr-user-message-editor__resume {
box-sizing: border-box;
cursor: pointer;
border-radius: 8px;
padding: 5px 16px;
font-size: 14px;
line-height: 20px;
}
.cr-user-message-editor__cancel:disabled,
.cr-user-message-editor__send:disabled,
.cr-user-message-editor__resume:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.cr-user-message-editor__cancel,
.cr-user-message-editor__resume {
border: 1px solid var(--cr-border-default);
color: var(--cr-text-secondary);
background: 0 0;
transition: background-color 0.15s, color 0.15s;
}
.cr-user-message-editor__cancel:not(:disabled):hover,
.cr-user-message-editor__resume:not(:disabled):hover {
background: var(--cr-bg-hover);
color: var(--cr-text-primary);
}
.cr-user-message-editor__send {
background: var(--cr-bg-contrast);
color: var(--cr-color-on-accent);
border: none;
justify-content: center;
align-items: center;
gap: 6px;
transition: opacity 0.15s;
display: inline-flex;
}
.cr-user-message-editor__send:not(:disabled):hover {
opacity: 0.85;
}
.cr-user-message-editor__resume {
align-self: flex-start;
margin-top: 8px;
}
/* 与输入框共用;此处保留一份,避免编辑器组件依赖其它按需加载的样式表。 */
@keyframes cr-input-spin {
0% {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@@ -3,8 +3,8 @@ import type { ChatMessage } from '@shared/types'
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons' import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons' import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons'
import { RuntimeRegenerateIcon } from './RuntimeIcons' import { RuntimeRegenerateIcon } from './RuntimeIcons'
import { copyUserMessage } from './message-copy'
import { import {
buildUserMessageClipboard,
buildCopyMessagePayload, buildCopyMessagePayload,
getSortedRequestMessages, getSortedRequestMessages,
projectSafeMessage, projectSafeMessage,
@@ -77,46 +77,7 @@ export function MessageActionToolbar({
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
} }
if (message.role === 'user') { if (message.role === 'user') {
const clipboard = buildUserMessageClipboard(message) await copyUserMessage(message, onCopy)
if (onCopy) {
try {
await onCopy(clipboard.plainText, clipboard)
return
} catch {
// Continue through the browser clipboard fallbacks.
}
}
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
const handler = (event: ClipboardEvent): void => {
event.preventDefault()
event.clipboardData?.setData('text/plain', clipboard.plainText)
event.clipboardData?.setData('text/html', clipboard.html)
event.clipboardData?.setData(clipboard.customMimeType, clipboard.customData)
}
document.addEventListener('copy', handler)
let copied = false
try {
copied = document.execCommand('copy')
} catch {
copied = false
} finally {
document.removeEventListener('copy', handler)
}
if (copied) return
}
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
try {
await navigator.clipboard.write([new ClipboardItem({
'text/plain': new Blob([clipboard.plainText], { type: 'text/plain' }),
'text/html': new Blob([clipboard.html], { type: 'text/html' })
})])
return
} catch {
// The plain-text API below is the final supported fallback.
}
}
if (!navigator.clipboard?.writeText) throw new Error('剪贴板不可用')
await navigator.clipboard.writeText(clipboard.plainText)
return return
} }
const row = requestId && requestMessages ? getSortedRequestMessages(requestMessages, requestId) : [] const row = requestId && requestMessages ? getSortedRequestMessages(requestMessages, requestId) : []
@@ -0,0 +1,90 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { ChatMessage } from '@shared/types'
import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons'
import { RevertToBeforeIcon } from '../conversation/MessageEditorIcons'
import { sourceConversationLabel } from '../conversation/source-labels'
import { formatMessageTime } from '../../components/AssistantMessageDetails'
import { RecoveredSelectTooltip } from '../../components/source-select/RecoveredSelectTooltip'
import { copyUserMessage, type MessageCopyWriter } from './message-copy'
/**
* 原版 IconButton 用 Tooltip$1(content / placement=bottom / showArrow / hideOnClick)。
* 回收实现由 JSX 推导属性,这里显式声明用到的子集。
*/
type RecoveredTooltipProps = {
content: string
children: React.ReactElement
placement?: 'top' | 'bottom' | 'left' | 'right'
hideOnClick?: boolean
showArrow?: boolean
}
const RecoveredTooltip = RecoveredSelectTooltip as unknown as (props: RecoveredTooltipProps) => React.JSX.Element
export interface UserMessageToolbarProps {
message: ChatMessage
/** 会话忙时禁止进入编辑(原版 A = resend || canHandle('message.edit.submit'))。 */
canEdit?: boolean
onEdit?(message: ChatMessage): void | Promise<void>
onCopy?: MessageCopyWriter
onRevert?(message: ChatMessage): void | Promise<void>
revertDisabled?: boolean
}
/**
* 用户消息悬停工具条。
* 结构与内容来自 WorkBuddy 5.8.3 渲染器 ToolbarRenderer(REA 回收 .tmp/wb-src-DbE1_mgM.js):
* `div.cr-toolbar[data-message-id][role=toolbar]` + `span.cr-toolbar__time` + 图标按钮(复制/恢复/编辑)。
* 几何与配色证据:ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3、
* ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405。
*/
export function UserMessageToolbar({ message, canEdit = false, onEdit, onCopy, onRevert, revertDisabled = false }: UserMessageToolbarProps): React.JSX.Element | null {
const [copied, setCopied] = useState(false)
const [error, setError] = useState<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
}, [])
const time = formatMessageTime(message.createdAt)
if (!time && !onEdit && !onRevert) return null
const copy = async (): Promise<void> => {
setError(null)
try {
await copyUserMessage(message, onCopy)
setCopied(true)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => setCopied(false), 2000)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const iconButton = (action: string, label: string, icon: ReactNode, onClick: () => void, options: { disabled?: boolean; hideTooltipOnClick?: boolean } = {}): React.JSX.Element => (
<RecoveredTooltip content={label} placement="bottom" hideOnClick={options.hideTooltipOnClick ?? true}>
<button
type="button"
className="cr-icon-button"
data-action={action}
aria-label={label}
disabled={options.disabled}
onClick={onClick}
>
{icon}
</button>
</RecoveredTooltip>
)
return (
<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}
{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>
)
}
export default UserMessageToolbar
@@ -0,0 +1,52 @@
import type { ChatMessage } from '@shared/types'
import { buildUserMessageClipboard } from './project-safe-message'
export type MessageCopyWriter = (text: string, payload: unknown) => void | Promise<void>
/**
* 复制用户消息:优先交给宿主 onCopy,随后按浏览器剪贴板能力逐级回退。
* 逻辑来自原版工具栏的 copy 分支(YCode MessageActionToolbar.copy),抽成共享实现,
* 供旧工具条与新用户消息工具条共用。
*/
export async function copyUserMessage(message: ChatMessage, onCopy?: MessageCopyWriter): Promise<void> {
const clipboard = buildUserMessageClipboard(message)
if (onCopy) {
try {
await onCopy(clipboard.plainText, clipboard)
return
} catch {
// Continue through the browser clipboard fallbacks.
}
}
if (typeof document !== 'undefined' && typeof document.execCommand === 'function') {
const handler = (event: ClipboardEvent): void => {
event.preventDefault()
event.clipboardData?.setData('text/plain', clipboard.plainText)
event.clipboardData?.setData('text/html', clipboard.html)
event.clipboardData?.setData(clipboard.customMimeType, clipboard.customData)
}
document.addEventListener('copy', handler)
let copied = false
try {
copied = document.execCommand('copy')
} catch {
copied = false
} finally {
document.removeEventListener('copy', handler)
}
if (copied) return
}
if (typeof ClipboardItem !== 'undefined' && navigator.clipboard?.write) {
try {
await navigator.clipboard.write([new ClipboardItem({
'text/plain': new Blob([clipboard.plainText], { type: 'text/plain' }),
'text/html': new Blob([clipboard.html], { type: 'text/html' })
})])
return
} catch {
// The plain-text API below is the final supported fallback.
}
}
if (!navigator.clipboard?.writeText) throw new Error('剪贴板不可用')
await navigator.clipboard.writeText(clipboard.plainText)
}