diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 6844418..a6f9732 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -102,11 +102,25 @@ ### 04. 用户消息悬停时间 -- [ ] 为已发送用户消息补齐悬停显示时间。 -- [ ] 对齐出现位置、间距、字体、颜色和与操作图标的关系。 -- [ ] 复用三档时间格式;验证当天、同年异日、跨年、历史重载。 +- [x] 为已发送用户消息补齐悬停显示时间。 +- [x] 对齐出现位置、间距、字体、颜色和与操作图标的关系。 +- [x] 复用三档时间格式;验证当天、同年异日、跨年、历史重载。 -状态:待 REA 对照;时间格式已确定。 +状态:随第 03 项的工具条一并实现并通过真实窗口回归;原版“昨天”分支差异待用户确认。 + +原版证据: +- 源码(REA 回收 `.tmp/wb-src-DbE1_mgM.js`):`ToolbarRenderer` 的 `span.cr-toolbar__time` 取 `formatMessageTime(props.createTime, locale, t('message.time.yesterday'))`;工具条整体由 `.cr-frame--right:hover` 控制显隐。 +- 样式:回收表 `src-CyXHA0IC.css` 中 `.cr-toolbar__time{width:max-content;color:var(--cr-text-tertiary,#00000080);white-space:nowrap;font-size:14px;font-weight:400;line-height:20px}` 与 `.cr-toolbar{gap:8px;padding-top:4px;transition:opacity .2s ease-in-out .15s}`、`.cr-frame--right:hover .cr-toolbar{opacity:1;pointer-events:auto;transition-delay:0s}`。 +- 运行时:`ev_245ea1740cde21869500348e47cf9663a1ea72943ccfe79c163a6838a153bfd3`(时间 33.23×20,位于复制按钮左侧,工具条 gap 8)、`ev_ee345b160e5f520ed866c1059b7c72e77b34e49bf1ce407cd266ea886aa86405`(时间计算样式 14px/20px `rgba(0,0,0,0.5)`)。 +- 观察范围:桌面浅色主题、当天消息;同年/跨年分支来自源码 `formatMessageTime`,原版“昨天”分支未采用(以用户此前确认的三档格式为准)。 + +实现:`workbuddy/message-actions/UserMessageToolbar.tsx` 渲染 `span.cr-toolbar__time` 并复用共享 `formatMessageTime`(`components/AssistantMessageDetails.tsx`):当天 `HH:mm`、同年异日 `M月D日 HH:mm`、跨年 `YYYY年M月D日 HH:mm`;位置、间距、字体、颜色与“时间在图标左侧”的关系直接由回收的 `.cr-toolbar` / `.cr-toolbar__time` 规则提供。 + +验证:`node scripts/verify-user-message-edit.cjs` 57 项断言全 PASS,其中包含时间字号/行高/颜色、当天 `06:32`(HH:mm)、同年异日 `1月2日 09:05`、跨年 `2024年12月31日 09:05`、切换会话后只显示当前会话的时间、历史重载后三档时间保持一致;`npm run test:assistant-footer` 覆盖 `formatMessageTime` 的三档与无效输入。报告:`.tmp/edit-iAc4zi/`。 + +遗留: +1. 原版 `formatMessageTime` 含“昨天 HH:mm”分支,与已确认的三档规则不同;当前按用户确认的三档渲染,差异待确认。 +2. 工具条淡入的延迟切换(默认 `.15s` 延迟、hover 时 `0s`)未逐帧采集。 ### 05. 用户消息回退操作与图标 diff --git a/scripts/verify-user-message-edit.cjs b/scripts/verify-user-message-edit.cjs index 42dc1ab..3cb9df3 100644 --- a/scripts/verify-user-message-edit.cjs +++ b/scripts/verify-user-message-edit.cjs @@ -16,6 +16,12 @@ if (!process.versions.electron) { await fs.mkdir(data); await fs.mkdir(home); await fs.mkdir(cwd) const crypto = require('node:crypto') const now = Date.now() + // 悬停时间三档夹具:当天(既有消息)、同年异日、跨年。 + const currentYear = new Date(now).getFullYear() + const todayKey = new Date(now).toDateString() + const sameYearDay = [2, 3, 4, 5].map((day) => new Date(currentYear, 0, day, 9, 5).getTime()) + .find((value) => new Date(value).toDateString() !== todayKey) + const otherYear = new Date(currentYear - 2, 11, 31, 9, 5).getTime() const firstUserId = crypto.randomUUID() const secondUserId = crypto.randomUUID() // 产物回退夹具:目标消息之后的助手回合写入了一个文件,编辑提交后该文件应回退到旧内容。 @@ -24,15 +30,22 @@ if (!process.versions.electron) { const editedFileAfter = '新内容:编辑后写入' await fs.writeFile(editedFilePath, editedFileAfter, 'utf8') const sessions = [{ id: crypto.randomUUID(), title: 'Edit Live', cwd, createdAt: now, updatedAt: now + 1, messages: [ + { id: crypto.randomUUID(), role: 'user', content: '跨年历史消息', createdAt: otherYear, status: 'done' }, + { id: crypto.randomUUID(), role: 'assistant', content: '跨年回复。', createdAt: otherYear + 1000, status: 'done' }, + { id: crypto.randomUUID(), role: 'user', content: '同年历史消息', createdAt: sameYearDay, status: 'done' }, + { id: crypto.randomUUID(), role: 'assistant', content: '同年回复。', createdAt: sameYearDay + 1000, status: 'done' }, { id: firstUserId, role: 'user', content: '第一条历史消息', createdAt: now - 4000, status: 'done' }, { id: crypto.randomUUID(), role: 'assistant', content: '收到第一条。', createdAt: now - 3000, status: 'done' }, { id: secondUserId, role: 'user', content: 'workbuddy是否有目标模式', createdAt: now - 2000, status: 'done' }, { id: crypto.randomUUID(), role: 'assistant', content: '本地文档里没有直接命中。', createdAt: now - 1000, status: 'done', tools: [ { id: 'tool-write-1', name: 'write_file', status: 'ok', args: { path: editedFilePath }, result: { path: editedFilePath, oldContent: editedFileBefore, newContent: editedFileAfter } } ] } + ] }, { id: crypto.randomUUID(), title: 'Edit Other', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [ + { id: crypto.randomUUID(), role: 'user', content: '另一个会话', createdAt: now - 9000, status: 'done' } ] }] await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) - await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter })) + await fs.writeFile(path.join(directory, 'expected.json'), JSON.stringify({ firstUserId, secondUserId, editedFilePath, editedFileBefore, editedFileAfter, + sameYearDay, otherYear, userTimes: [otherYear, sameYearDay, now - 4000, now - 2000] })) const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL @@ -86,6 +99,16 @@ if (!process.versions.electron) { } const lastFrame = `[...document.querySelectorAll('.cr-frame--right')].pop()` + const timesSource = `[...document.querySelectorAll('.cr-frame--right')].map(frame => frame.querySelector('.cr-toolbar__time')?.textContent ?? null)` + // 与实现相同的三档规则(当天 HH:mm;同年 M月D日 HH:mm;跨年 YYYY年M月D日 HH:mm),独立实现用于校验渲染结果。 + function expectedTimeText(time) { + const date = new Date(time) + const now = new Date() + const hoursMinutes = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` + if (date.toDateString() === now.toDateString()) return hoursMinutes + const year = date.getFullYear() === now.getFullYear() ? '' : `${date.getFullYear()}年` + return `${year}${date.getMonth() + 1}月${date.getDate()}日 ${hoursMinutes}` + } 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})()` @@ -124,7 +147,7 @@ if (!process.versions.electron) { 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('fixture renders four user messages', userBubbles, 4) 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)。 @@ -149,6 +172,21 @@ if (!process.versions.electron) { check('hovered toolbar accepts pointer events', await read(`getComputedStyle(${lastFrame}.querySelector('.cr-toolbar')).pointerEvents`), 'auto') await shot('toolbar-hover') + await step('hover-time-tiers') + const times = await read(timesSource) + snapshots.times = times + check('every user message owns a hover time', times.length, expected.userTimes.length) + check('hover time follows the confirmed three-tier format', times, expected.userTimes.map(expectedTimeText)) + check('same-year non-today tier renders M月D日 HH:mm', times[1], expectedTimeText(expected.sameYearDay)) + check('cross-year tier renders YYYY年M月D日 HH:mm', times[0], expectedTimeText(expected.otherYear)) + check('today tier renders HH:mm', times[3], expectedTimeText(expected.userTimes[3])) + await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Edit Other'));if(!row)return false;row.click();return true})()`) + await pause(500) + check('switching sessions shows the other session time only', await read(`document.querySelectorAll('.cr-frame--right .cr-toolbar__time').length`), 1) + 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 pause(600) + check('history reload keeps the three-tier hover times', await read(timesSource), expected.userTimes.map(expectedTimeText)) + 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')