From ee512089313770c2d80e5acea9600a4a86d5440e Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Sat, 17 Oct 2026 06:31:37 +0800 Subject: [PATCH] feat(conversation): replicate in-place user message editing (TODO 03) --- docs/复刻待办清单-2026-10-17.md | 32 ++- scripts/composer-regression.tsx | 35 ++- scripts/conversation-ipc-regression.ts | 55 ++++ ...obe-open-target-conversation-20261017.json | 15 + .../rea/probe-user-message-edit-20261017.json | 26 ++ ...obe-user-message-edit-cancel-20261017.json | 13 + ...probe-user-message-edit-open-20261017.json | 24 ++ scripts/verify-user-message-edit.cjs | 259 ++++++++++++++++++ src/main/ipc.ts | 11 +- src/main/workspace-revert.ts | 13 + src/renderer/src/components/ChatPane.tsx | 23 +- src/renderer/src/components/MessageBubble.tsx | 25 +- .../conversation/MessageEditorIcons.tsx | 40 +++ .../conversation/UserMessageEditor.tsx | 160 +++++++++++ .../conversation/message-edit-source.css | 143 ++++++++++ .../message-actions/MessageActionToolbar.tsx | 43 +-- .../message-actions/UserMessageToolbar.tsx | 90 ++++++ .../workbuddy/message-actions/message-copy.ts | 52 ++++ 18 files changed, 992 insertions(+), 67 deletions(-) create mode 100644 scripts/rea/probe-open-target-conversation-20261017.json create mode 100644 scripts/rea/probe-user-message-edit-20261017.json create mode 100644 scripts/rea/probe-user-message-edit-cancel-20261017.json create mode 100644 scripts/rea/probe-user-message-edit-open-20261017.json create mode 100644 scripts/verify-user-message-edit.cjs create mode 100644 src/renderer/src/workbuddy/conversation/MessageEditorIcons.tsx create mode 100644 src/renderer/src/workbuddy/conversation/UserMessageEditor.tsx create mode 100644 src/renderer/src/workbuddy/conversation/message-edit-source.css create mode 100644 src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx create mode 100644 src/renderer/src/workbuddy/message-actions/message-copy.ts diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 2b78dec..6844418 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -70,11 +70,35 @@ ### 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. 用户消息悬停时间 diff --git a/scripts/composer-regression.tsx b/scripts/composer-regression.tsx index 2c0ed84..4799717 100644 --- a/scripts/composer-regression.tsx +++ b/scripts/composer-regression.tsx @@ -4,6 +4,8 @@ import { ComposerControls } from '../src/renderer/src/workbuddy/composer/Compose import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl' import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls' import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar' +import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar' +import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor' import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message' import type { ChatMessage } from '../src/shared/types' @@ -132,11 +134,34 @@ const assistantMarkup = renderToStaticMarkup( undefined} onRevert={() => undefined} onResend={() => undefined} />) -assert.match(userMarkup, /编辑/) -assert.match(userMarkup, /重新发送/) -assert.doesNotMatch(userMarkup, /重新生成/) -assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only') +// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。 +const userMarkup = renderToStaticMarkup( undefined} onRevert={() => undefined} />) +assert.match(userMarkup, /class="cr-toolbar"/) +assert.match(userMarkup, /cr-toolbar__time/) +assert.match(userMarkup, /data-action="copy"/) +assert.match(userMarkup, /data-action="revert"/) +assert.match(userMarkup, /data-action="edit"/) +assert.match(userMarkup, /cr-icon-button__icon/) +assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/) +assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签') + +const editorMarkup = renderToStaticMarkup( 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, />取消发送 undefined} onCancel={() => undefined} />) +assert.match(emptyEditorMarkup, /消息内容不能为空/) +assert.match(emptyEditorMarkup, /role="alert"/) +assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/) const composerMarkup = renderToStaticMarkup( undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={Model slot} workspaceFooter={Workspace slot} sending /> diff --git a/scripts/conversation-ipc-regression.ts b/scripts/conversation-ipc-regression.ts index 8bf0624..ea5b37f 100644 --- a/scripts/conversation-ipc-regression.ts +++ b/scripts/conversation-ipc-regression.ts @@ -355,6 +355,60 @@ async function testActionBranchFailure(bundle: TestBundle, state: TestState): Pr 'failed action branch should restore the prior marker') } +async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise { + 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 { const invoke = invokeFor(state) for (const nativeAccepted of [false, true]) { @@ -684,6 +738,7 @@ async function main(): Promise { await testConcurrency(bundle, state, invoke) for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action) await testActionBranchFailure(bundle, state) + await testEditArtifactRollback(bundle, state, workspace) await testInterruptedResend(bundle, state) await testAttachments(bundle, state, tempRoot) await testSpeech(bundle, state) diff --git a/scripts/rea/probe-open-target-conversation-20261017.json b/scripts/rea/probe-open-target-conversation-20261017.json new file mode 100644 index 0000000..a0f88bf --- /dev/null +++ b/scripts/rea/probe-open-target-conversation-20261017.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-user-message-edit-20261017.json b/scripts/rea/probe-user-message-edit-20261017.json new file mode 100644 index 0000000..e9a0f73 --- /dev/null +++ b/scripts/rea/probe-user-message-edit-20261017.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-user-message-edit-cancel-20261017.json b/scripts/rea/probe-user-message-edit-cancel-20261017.json new file mode 100644 index 0000000..19033dc --- /dev/null +++ b/scripts/rea/probe-user-message-edit-cancel-20261017.json @@ -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 } + ] +} diff --git a/scripts/rea/probe-user-message-edit-open-20261017.json b/scripts/rea/probe-user-message-edit-open-20261017.json new file mode 100644 index 0000000..f356d5f --- /dev/null +++ b/scripts/rea/probe-user-message-edit-open-20261017.json @@ -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 } + ] +} diff --git a/scripts/verify-user-message-edit.cjs b/scripts/verify-user-message-edit.cjs new file mode 100644 index 0000000..42dc1ab --- /dev/null +++ b/scripts/verify-user-message-edit.cjs @@ -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) + }) +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 5154709..12df8c0 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -29,7 +29,7 @@ import { createInteractionBroker, respondToInteraction } from './interaction-bro import { attachmentPrompt, commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments' import { transcribeAudio } from './speech' import { getRevertChanges } from '@shared/revert' -import { restoreWorkspaceFiles } from './workspace-revert' +import { restoreWorkspaceFiles, rollbackEditedArtifacts } from './workspace-revert' import { assistantWireHistory } from './agent/history' import * as models from './models' import * as workspaces from './workspaces' @@ -563,7 +563,10 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { } const target = history[targetIndex] if (!target || target.role !== 'user') throw new Error('未找到对应用户消息') - if (kernel) { + // 原版编辑语义(userMessage.edit.hint):产物回退至此消息前版本,再从此处重新开始对话。 + const editUndo = payload.action === 'edit' ? await rollbackEditedArtifacts(workspaceRoot, history, payload.fromMessageId!) : undefined + try { + if (kernel) { if (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录') let entryId = target.nativeEntryId if (!entryId) { @@ -588,6 +591,10 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { } const entry = ompKernels.get(sessionId) if (entry && entryId) entry.appliedBranchId = entryId + } + } catch (error) { + if (editUndo) await editUndo().catch(() => undefined) + throw error } history = history.slice(0, targetIndex) } diff --git a/src/main/workspace-revert.ts b/src/main/workspace-revert.ts index 39b71ba..169779c 100644 --- a/src/main/workspace-revert.ts +++ b/src/main/workspace-revert.ts @@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs' import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path' import { randomUUID } from 'node:crypto' 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 { 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 } return undo } + +/** + * 编辑提交前的产物回退(原版 userMessage.edit.hint:产物回退至此消息前版本)。 + * 返回撤销函数;该消息之后没有可回退的写入时返回 undefined。 + * 记录不完整时由 getRevertChanges 抛错,调用方应中止提交而不是静默保留文件修改。 + */ +export async function rollbackEditedArtifacts(workspace: string, messages: ChatMessage[], userMessageId: string): Promise<(() => Promise) | undefined> { + const changes = getRevertChanges(messages, userMessageId) + if (!changes.length) return undefined + return restoreWorkspaceFiles(workspace, changes) +} diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 86635be..7478f80 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -111,6 +111,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden]) const [permissionMode, setPermissionMode] = useState('default-sandbox') const [confirmFullAccess, setConfirmFullAccess] = useState(true) + const [editingMessageId, setEditingMessageId] = useState(null) const [revertTarget, setRevertTarget] = useState(null) const [revertError, setRevertError] = useState(null) const [reverting, setReverting] = useState(false) @@ -205,17 +206,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise => { if ((!text && !attachments.length && !quotes.length) || running || sending) return 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 draftMoves.current.delete(draftKey) setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) } - const edit = (message: ChatMessage): void => { - setDrafts((all) => ({ ...all, [draftKey]: { text: message.content, attachments: message.attachments ?? [], resources: message.resources ?? [], quotes: message.quotes, skills: message.skills, editingId: message.id } })) - document.querySelector('.wb-composer__input')?.focus() + /** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */ + const startEdit = (message: ChatMessage): void => { + if (running || sending) return + setEditingMessageId(message.id) + } + const cancelEdit = (): void => setEditingMessageId(null) + const submitEdit = async (message: ChatMessage, text: string): Promise => { + 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 => 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 => { 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() @@ -255,7 +262,6 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, /** 输入框(首页与会话内共用;首页编辑区更高,由 CSS 的 data-variant 区分) */ const composer = ( <> - {entry.editingId &&
编辑消息
} { 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 })} 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), canRegenerate: !running && !sending, isSessionActive: true, isLastAssistant: message.id === lastAssistantId, 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, - onFeedback: (target, value) => feedback(target.id, value) }} /> + onFeedback: (target, value) => feedback(target.id, value) }} + editingMessageId={editingMessageId} onStartEdit={startEdit} onCancelEdit={cancelEdit} onSubmitEdit={submitEdit} /> ))} diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index 3e00d34..1e4c5c7 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -13,6 +13,8 @@ import { computeSourceFoldSummary } from '../workbuddy/conversation/recovered-fo import { Fragment, useState } from 'react' import type { MessageActionToolbarProps } 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 { conversationLabel } from '../workbuddy/conversation/labels' import { downloadInHost, openPathInHost } from '../workbuddy/host' @@ -32,6 +34,11 @@ interface Props { quotedSourceIds?: readonly string[] darkMode?: boolean actions?: Omit + /** 正在就地编辑的消息 id;命中时用编辑容器替换气泡内容。 */ + editingMessageId?: string | null + onStartEdit?(message: ChatMessage): void + onCancelEdit?(): void + onSubmitEdit?(message: ChatMessage, text: string): Promise onRespond?(id: string, response: InteractionResponse): Promise sessionId?: string | null onBuildPlan?(plan: PlanContent): Promise @@ -40,7 +47,7 @@ interface Props { 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(null) const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) const perform = (action: () => Promise): 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) })) // 用户消息:右侧灰色气泡 if (message.role === 'user') { + const editing = editingMessageId === message.id && !!onSubmitEdit return (
-
- {!!message.quotes?.length &&
+
+ {!editing && !!message.quotes?.length &&
{message.quotes.map((quote) => )}
} -
{message.content.split('\n').map((line, index) =>

{line ||
}

)}
+ {editing ? onSubmitEdit!(message, text)} onCancel={() => onCancelEdit?.()} /> + :
{message.content.split('\n').map((line, index) =>

{line ||
}

)}
}
- {!!message.skills?.length &&
{message.skills.map(skill => {skill.name})}
} - {message.attachments?.map((attachment) =>
+ {!editing && !!message.skills?.length &&
{message.skills.map(skill => {skill.name})}
} + {!editing && message.attachments?.map((attachment) =>
{attachment.previewUrl ? : {attachment.name}}
)}
- + {!editing && }
) } diff --git a/src/renderer/src/workbuddy/conversation/MessageEditorIcons.tsx b/src/renderer/src/workbuddy/conversation/MessageEditorIcons.tsx new file mode 100644 index 0000000..e965667 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/MessageEditorIcons.tsx @@ -0,0 +1,40 @@ +import type { SVGProps } from 'react' + +type IconProps = SVGProps + +/** + * 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 ( + + + + + ) +} + +/** + * 编辑框提示图标(InfoIcon)。REA ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405 + * 运行时 svg_nodes:14×14,viewBox 0 0 16 16。 + */ +export function EditorInfoIcon({ className, ...props }: IconProps): React.JSX.Element { + return ( + + + + + + ) +} + +/** WorkBuddy 5.8.3 渲染器 LoadingSpinnerIcon(size=1 → 12px,cr-input-spin 0.8s 线性无限旋转)。 */ +export function EditorSpinnerIcon(): React.JSX.Element { + return ( + + + + + ) +} diff --git a/src/renderer/src/workbuddy/conversation/UserMessageEditor.tsx b/src/renderer/src/workbuddy/conversation/UserMessageEditor.tsx new file mode 100644 index 0000000..bd35d98 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/UserMessageEditor.tsx @@ -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 + 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(null) + const editableRef = useRef(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(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): void => { + if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return + event.preventDefault() + submit() + } + + return ( +
+
+
+
+
setText(readEditableText(event.currentTarget))} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + onKeyDown={handleKeyDown} + /> +
+
+
+ {error ?
{error}
: null} +
+
+ + {sourceConversationLabel('userMessage.edit.hint')} +
+
+ + +
+
+
+ ) +} + +export default UserMessageEditor diff --git a/src/renderer/src/workbuddy/conversation/message-edit-source.css b/src/renderer/src/workbuddy/conversation/message-edit-source.css new file mode 100644 index 0000000..2f16724 --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/message-edit-source.css @@ -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); + } +} diff --git a/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx b/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx index 1867991..1eab3e4 100644 --- a/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx +++ b/src/renderer/src/workbuddy/message-actions/MessageActionToolbar.tsx @@ -3,8 +3,8 @@ import type { ChatMessage } from '@shared/types' import { CopyMessageIcon, EditMessageIcon } from './RecoveredActionIcons' import { SourceThumbsUpIcon, SourceThumbsDownIcon, SourceThumbsUpSolidIcon, SourceThumbsDownSolidIcon, SourceResendIcon, SourceRevertIcon } from './SourceActionIcons' import { RuntimeRegenerateIcon } from './RuntimeIcons' +import { copyUserMessage } from './message-copy' import { - buildUserMessageClipboard, buildCopyMessagePayload, getSortedRequestMessages, projectSafeMessage, @@ -77,46 +77,7 @@ export function MessageActionToolbar({ await navigator.clipboard.writeText(text) } if (message.role === 'user') { - 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) + await copyUserMessage(message, onCopy) return } const row = requestId && requestMessages ? getSortedRequestMessages(requestMessages, requestId) : [] diff --git a/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx b/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx new file mode 100644 index 0000000..8dcda91 --- /dev/null +++ b/src/renderer/src/workbuddy/message-actions/UserMessageToolbar.tsx @@ -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 + onCopy?: MessageCopyWriter + onRevert?(message: ChatMessage): void | Promise + 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(null) + const timerRef = useRef | null>(null) + + useEffect(() => () => { + if (timerRef.current) clearTimeout(timerRef.current) + }, []) + + const time = formatMessageTime(message.createdAt) + if (!time && !onEdit && !onRevert) return null + + const copy = async (): Promise => { + 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 => ( + + + + ) + + return ( +
+ {time ? {time} : null} + {iconButton('copy', sourceConversationLabel(copied ? 'markdown.copied' : 'markdown.copy'), , () => void copy(), { hideTooltipOnClick: false })} + {onRevert ? iconButton('revert', sourceConversationLabel('userMessage.action.revert'), , () => void onRevert(message), { disabled: revertDisabled }) : null} + {canEdit && onEdit ? iconButton('edit', sourceConversationLabel('userMessage.action.edit'), , () => void onEdit(message)) : null} + {error ? {error} : null} +
+ ) +} + +export default UserMessageToolbar diff --git a/src/renderer/src/workbuddy/message-actions/message-copy.ts b/src/renderer/src/workbuddy/message-actions/message-copy.ts new file mode 100644 index 0000000..3fbcbf1 --- /dev/null +++ b/src/renderer/src/workbuddy/message-actions/message-copy.ts @@ -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 + +/** + * 复制用户消息:优先交给宿主 onCopy,随后按浏览器剪贴板能力逐级回退。 + * 逻辑来自原版工具栏的 copy 分支(YCode MessageActionToolbar.copy),抽成共享实现, + * 供旧工具条与新用户消息工具条共用。 + */ +export async function copyUserMessage(message: ChatMessage, onCopy?: MessageCopyWriter): Promise { + 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) +}