feat(conversation): replicate in-place user message editing (TODO 03)
This commit is contained in:
1 parent
ed31a7c1b4
commit
ee51208931
18 files changed
+991
-66
No files matched your search
@@ -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. 用户消息悬停时间
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { ComposerControls } from '../src/renderer/src/workbuddy/composer/Compose
|
||||
import { encodePcmWav, downsamplePcm, VOICE_MAX_DURATION_MS } from '../src/renderer/src/workbuddy/composer/VoiceInputControl'
|
||||
import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentNames, parseResourcePath, readReferenceUris } from '../src/renderer/src/workbuddy/composer/ComposerControls'
|
||||
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
|
||||
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
|
||||
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
|
||||
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
|
||||
import type { ChatMessage } from '../src/shared/types'
|
||||
|
||||
@@ -132,11 +134,34 @@ const assistantMarkup = renderToStaticMarkup(<MessageActionToolbar message={assi
|
||||
assert.match(assistantMarkup, /复制/)
|
||||
assert.match(assistantMarkup, /重新生成/)
|
||||
assert.doesNotMatch(assistantMarkup, /编辑/)
|
||||
const userMarkup = renderToStaticMarkup(<MessageActionToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onEdit={() => undefined} onRevert={() => undefined} onResend={() => undefined} />)
|
||||
assert.match(userMarkup, /编辑/)
|
||||
assert.match(userMarkup, /重新发送/)
|
||||
assert.doesNotMatch(userMarkup, /重新生成/)
|
||||
assert.doesNotMatch(userMarkup, /赞同|不赞同/, 'feedback actions are assistant-only')
|
||||
// 用户消息工具条已替换为原版 .cr-toolbar(时间 + 复制 + 恢复此版本 + 编辑,不含重新发送)。
|
||||
const userMarkup = renderToStaticMarkup(<UserMessageToolbar message={{ id: 'user', role: 'user', content: 'hi', createdAt: Date.now() }} canEdit onEdit={() => undefined} onRevert={() => undefined} />)
|
||||
assert.match(userMarkup, /class="cr-toolbar"/)
|
||||
assert.match(userMarkup, /cr-toolbar__time/)
|
||||
assert.match(userMarkup, /data-action="copy"/)
|
||||
assert.match(userMarkup, /data-action="revert"/)
|
||||
assert.match(userMarkup, /data-action="edit"/)
|
||||
assert.match(userMarkup, /cr-icon-button__icon/)
|
||||
assert.doesNotMatch(userMarkup, /重新发送|重新生成|赞同|不赞同/)
|
||||
assert.doesNotMatch(userMarkup, /wb-message-action__label/, '工具栏只保留图标,不再渲染文字标签')
|
||||
|
||||
const editorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: 'hi', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
||||
assert.match(editorMarkup, /class="cr-user-message-editor"/)
|
||||
assert.match(editorMarkup, /cr-input-container-wrapper/)
|
||||
assert.match(editorMarkup, /cr-input-editor-host/)
|
||||
assert.match(editorMarkup, /data-slate-editor/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__hint-icon/)
|
||||
assert.match(editorMarkup, /编辑后将从此处重新开始对话,产物也将回退至此信息前版本/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__cancel/)
|
||||
assert.match(editorMarkup, /cr-user-message-editor__send/)
|
||||
assert.match(editorMarkup, />取消</)
|
||||
assert.match(editorMarkup, />发送</)
|
||||
assert.doesNotMatch(editorMarkup, /role="alert"/, '有内容时不显示错误')
|
||||
|
||||
const emptyEditorMarkup = renderToStaticMarkup(<UserMessageEditor message={{ id: 'user', role: 'user', content: '', createdAt: 1 }} onSubmit={async () => undefined} onCancel={() => undefined} />)
|
||||
assert.match(emptyEditorMarkup, /消息内容不能为空/)
|
||||
assert.match(emptyEditorMarkup, /role="alert"/)
|
||||
assert.match(emptyEditorMarkup, /cr-user-message-editor__send" disabled/)
|
||||
|
||||
const composerMarkup = renderToStaticMarkup(
|
||||
<ComposerControls value="draft" onChange={() => undefined} onSend={() => undefined} onModeChange={() => undefined} modelSlot={<span>Model slot</span>} workspaceFooter={<span>Workspace slot</span>} sending />
|
||||
|
||||
@@ -355,6 +355,60 @@ async function testActionBranchFailure(bundle: TestBundle, state: TestState): Pr
|
||||
'failed action branch should restore the prior marker')
|
||||
}
|
||||
|
||||
async function testEditArtifactRollback(bundle: TestBundle, state: TestState, workspace: string): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
// 有产物:编辑提交先回退文件,再从该消息处重新开始对话(原版 userMessage.edit.hint)。
|
||||
const rolledPath = join(workspace, 'edit-rollback.txt')
|
||||
const rolledSession = await seedWriteHistory(bundle, state, 'edit-rollback', rolledPath, 'edit-rollback-native-target', 'after')
|
||||
await invoke('chat:send', { sessionId: rolledSession, text: 'edited prompt', action: 'edit', fromMessageId: 'edit-rollback-user' })
|
||||
const rolledMessages = await waitForAssistant(invoke, rolledSession)
|
||||
assert.equal(await readFile(rolledPath, 'utf8'), 'before',
|
||||
'edit submit must roll the artifact back to its version before the message')
|
||||
assert.equal(rolledMessages.length, 2, 'edit keeps the prefix before the edited message and appends the replacement')
|
||||
assert.equal(rolledMessages[0].content, 'edited prompt')
|
||||
assert.ok(rolledMessages[0].nativeEntryId?.startsWith('native-prompt-'), 'replacement turn should receive the new native entryId')
|
||||
|
||||
// 分支失败:撤销文件回退,保持会话不变。
|
||||
const failedPath = join(workspace, 'edit-rollback-branch-failure.txt')
|
||||
const failedSession = await seedWriteHistory(bundle, state, 'edit-rollback-failed', failedPath, 'edit-rollback-failed-native', 'after')
|
||||
const failedBefore = await bundle.sessions.getMessages(failedSession)
|
||||
state.failBranchIds.add('edit-rollback-failed-native')
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: failedSession, text: 'edited failed', action: 'edit', fromMessageId: 'edit-rollback-failed-user'
|
||||
}), /branch failure/)
|
||||
state.failBranchIds.delete('edit-rollback-failed-native')
|
||||
assert.equal(await readFile(failedPath, 'utf8'), 'after', 'failed native branch must undo the artifact rollback')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(failedSession), failedBefore,
|
||||
'failed edit must leave the persisted conversation untouched')
|
||||
|
||||
// 无产物改动:直接重开对话,不因缺少文件记录而报错。
|
||||
const plainSession = await invoke('session:create', 'edit-plain') as { id: string }
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-user', 'user', 'plain prompt', { nativeEntryId: 'edit-plain-native' }))
|
||||
await bundle.sessions.appendMessage(plainSession.id, message('edit-plain-assistant', 'assistant', 'plain reply'))
|
||||
seedNativeHistory(state, plainSession.id, [['edit-plain-native', 'plain prompt']])
|
||||
await invoke('chat:send', { sessionId: plainSession.id, text: 'edited plain', action: 'edit', fromMessageId: 'edit-plain-user' })
|
||||
const plainMessages = await waitForAssistant(invoke, plainSession.id)
|
||||
assert.equal(plainMessages.length, 2)
|
||||
assert.equal(plainMessages[0].content, 'edited plain')
|
||||
|
||||
// 记录不完整(不可回滚工具):拒绝编辑,会话与文件都不变。
|
||||
const blockedPath = join(workspace, 'edit-blocked.txt')
|
||||
await writeFile(blockedPath, 'after', 'utf8')
|
||||
const blockedSession = await invoke('session:create', 'edit-blocked') as { id: string }
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-user', 'user', 'blocked prompt', { nativeEntryId: 'edit-blocked-native' }))
|
||||
await bundle.sessions.appendMessage(blockedSession.id, message('edit-blocked-assistant', 'assistant', 'ran shell', {
|
||||
tools: [{ id: 'shell-tool', name: 'run_command', args: {}, argsRaw: '{}', status: 'ok' }]
|
||||
}))
|
||||
seedNativeHistory(state, blockedSession.id, [['edit-blocked-native', 'blocked prompt']])
|
||||
const blockedBefore = await bundle.sessions.getMessages(blockedSession.id)
|
||||
await assert.rejects(invoke('chat:send', {
|
||||
sessionId: blockedSession.id, text: 'edited blocked', action: 'edit', fromMessageId: 'edit-blocked-user'
|
||||
}), /无法完整回滚/)
|
||||
assert.equal(await readFile(blockedPath, 'utf8'), 'after', 'rejected edit must not touch artifacts')
|
||||
assert.deepEqual(await bundle.sessions.getMessages(blockedSession.id), blockedBefore,
|
||||
'rejected edit must leave history untouched')
|
||||
}
|
||||
|
||||
async function testInterruptedResend(bundle: TestBundle, state: TestState): Promise<void> {
|
||||
const invoke = invokeFor(state)
|
||||
for (const nativeAccepted of [false, true]) {
|
||||
@@ -684,6 +738,7 @@ async function main(): Promise<void> {
|
||||
await testConcurrency(bundle, state, invoke)
|
||||
for (const action of ['edit', 'resend', 'regenerate'] as const) await testActionBranch(bundle, state, action)
|
||||
await testActionBranchFailure(bundle, state)
|
||||
await testEditArtifactRollback(bundle, state, workspace)
|
||||
await testInterruptedResend(bundle, state)
|
||||
await testAttachments(bundle, state, tempRoot)
|
||||
await testSpeech(bundle, state)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble",
|
||||
".cr-frame--right",
|
||||
".cr-toolbar"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "open-first-space-task", "selector": ".conversation-item", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "conversation-loaded", "duration_ms": 1500, "frames": 1 },
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 400, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-frame--right",
|
||||
".cr-toolbar",
|
||||
".cr-toolbar__time",
|
||||
".cr-toolbar .cr-icon-button[data-action='copy']",
|
||||
".cr-toolbar .cr-icon-button[data-action='revert']",
|
||||
".cr-toolbar .cr-icon-button[data-action='edit']",
|
||||
".cr-self-bubble--editing",
|
||||
".cr-user-message-editor",
|
||||
".cr-user-message-editor .cr-input-editor-host",
|
||||
".cr-user-message-editor__hint",
|
||||
".cr-user-message-editor__cancel",
|
||||
".cr-user-message-editor__send"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 1, "interval_ms": 50 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 350, "frames": 1 },
|
||||
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-visible", "duration_ms": 700, "frames": 1, "interval_ms": 50 },
|
||||
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": false,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble",
|
||||
".cr-toolbar",
|
||||
".cr-user-message-editor"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "click", "step_id": "cancel-editor", "selector": ".cr-user-message-editor__cancel", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-closed", "duration_ms": 500, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"include_fonts": false,
|
||||
"include_message_content": true,
|
||||
"track_selectors": [
|
||||
".cr-self-bubble--editing",
|
||||
".cr-user-message-editor",
|
||||
".cr-user-message-editor .cr-input-container",
|
||||
".cr-user-message-editor .cr-input-editor-host",
|
||||
".cr-user-message-editor__error",
|
||||
".cr-user-message-editor__hint",
|
||||
".cr-user-message-editor__hint-icon",
|
||||
".cr-user-message-editor__actions",
|
||||
".cr-user-message-editor__cancel",
|
||||
".cr-user-message-editor__send",
|
||||
".cr-user-message-editor__resume",
|
||||
".cr-user-message-editor__send svg"
|
||||
],
|
||||
"actions": [
|
||||
{ "kind": "hover", "step_id": "hover-user-message", "selector": ".cr-self-bubble", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "toolbar-visible", "duration_ms": 300, "frames": 0 },
|
||||
{ "kind": "click", "step_id": "open-editor", "selector": ".cr-toolbar .cr-icon-button[data-action='edit']", "index": 0, "frames": 0 },
|
||||
{ "kind": "wait", "step_id": "editor-open", "duration_ms": 800, "frames": 1 }
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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,6 +563,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
|
||||
}
|
||||
const target = history[targetIndex]
|
||||
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 (target.nativeSessionPath && target.nativeSessionPath !== ompSessionPath(sessionId)) throw new Error('这条历史消息没有当前 OMP 会话的分支记录')
|
||||
let entryId = target.nativeEntryId
|
||||
@@ -589,6 +592,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)
|
||||
}
|
||||
const runId = randomUUID()
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>) | undefined> {
|
||||
const changes = getRevertChanges(messages, userMessageId)
|
||||
if (!changes.length) return undefined
|
||||
return restoreWorkspaceFiles(workspace, changes)
|
||||
}
|
||||
@@ -111,6 +111,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
useEffect(() => { setSkillPicker(null); setExpertPicker(null) }, [draftKey, hidden])
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('default-sandbox')
|
||||
const [confirmFullAccess, setConfirmFullAccess] = useState(true)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [revertTarget, setRevertTarget] = useState<ChatMessage | null>(null)
|
||||
const [revertError, setRevertError] = useState<string | null>(null)
|
||||
const [reverting, setReverting] = useState(false)
|
||||
@@ -205,17 +206,23 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
|
||||
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
|
||||
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<HTMLTextAreaElement>('.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<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 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 && <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 ?? []}
|
||||
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} />
|
||||
))}
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
@@ -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<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>
|
||||
sessionId?: string | null
|
||||
onBuildPlan?(plan: PlanContent): Promise<void>
|
||||
@@ -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<string | null>(null)
|
||||
const [liveEventsExpanded, setLiveEventsExpanded] = useState(false)
|
||||
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) }))
|
||||
// 用户消息:右侧灰色气泡
|
||||
if (message.role === 'user') {
|
||||
const editing = editingMessageId === message.id && !!onSubmitEdit
|
||||
return (
|
||||
<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="bubble cr-self-bubble">
|
||||
{!!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
|
||||
<div className={`bubble cr-self-bubble${editing ? ' cr-self-bubble--editing' : ''}`}>
|
||||
{!editing && !!message.quotes?.length && <div className="cr-context-chips" aria-label="引用的内容">
|
||||
{message.quotes.map((quote) => <MessageQuoteTag key={quote.id} quote={quote} readOnly />)}
|
||||
</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>
|
||||
{!!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.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.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>}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 { 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) : []
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user