feat(conversation): replicate in-place user message editing (TODO 03)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 06:31:37 +08:00
1 parent ed31a7c1b4
commit ee51208931
18 files changed
+992 -67

No files matched your search

+9 -2
View File
@@ -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)
}
+13
View File
@@ -2,6 +2,8 @@ import { promises as fs } from 'node:fs'
import { dirname, isAbsolute, relative, resolve, sep, join } from 'node:path'
import { 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)
}
+15 -8
View File
@@ -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>
+18 -7
View File
@@ -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)
}