diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 5bf121e..c4e657f 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -157,11 +157,32 @@ ### 06. 对话区域滚动条 -- [ ] 对齐宽度、颜色、轨道、圆角、悬停显隐、与内容边缘的间距。 -- [ ] 检查滚动条占位、长内容、流式跟随底部及用户手动上滚的行为。 -- [ ] 验收:浅色、深色、短对话、长对话、流式输出与不同窗口尺寸。 +- [x] 对齐宽度、颜色、轨道、圆角、悬停显隐、与内容边缘的间距。 +- [x] 检查滚动条占位、长内容、流式跟随底部及用户手动上滚的行为。 +- [x] 验收:浅色、深色、短对话、长对话、流式输出与不同窗口尺寸。 -状态:待 REA 对照。 +状态:已对齐并通过真实窗口回归(像素级比对 + CSSOM + 计算值 + 行为);本轮无需改动实现代码,回收样式表已在位。 + +原版证据: +- 样式表 `src-CyXHA0IC.css`(SHA-256 `a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe`):`.cr-message-list{scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--cr-scrollbar-thumb) transparent;flex:1;min-height:0;overflow-y:auto}`;`::-webkit-scrollbar{width:6px;height:6px}`、`::-webkit-scrollbar-track{background:0 0}`、`::-webkit-scrollbar-thumb{background-color:var(--cr-scrollbar-thumb);border-radius:3px;transition:background-color .2s}`、`::-webkit-scrollbar-thumb:hover{background-color:var(--cr-scrollbar-thumb-hover)}`。 +- token 链:`--cr-scrollbar-thumb: var(--cr-internal-scrollbar-thumb)`;浅色 `#0003` / `#00000052`,深色 `#fff3` / `#ffffff52`。 +- 空闲隐藏由库选项 `hideScrollbarWhenIdle`(默认 `false`)驱动 `useScrollIdleVisibility`(3s 无滚动加 `cr-message-list--scrollbar-idle`,滚动时移除);WorkBuddy 应用包(脚本 SHA-256 `5f35b748…` 的 item 295)未传该选项,运行时截图也没有该 class,故原版不启用空闲隐藏,YCode 同样不实现。 +- 运行时光栅证据:REA Evidence `ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84` 的 `workbuddy.png`:滑块占据 x=1328..1333(6px),颜色 `rgb(204,204,204)`(即 `#0003` 叠白),轨道透明,槽位右侧留 2px 空白。 +- 观察范围:浅色主题、1336×1240 视口、长会话。未覆盖:原版 hover 态与深色主题的运行时像素、原版槽位宽度的直接测量(只能由像素推断)、不同窗口尺寸下的原版像素。 + +实现:无代码改动——`ChatPane` 的滚动容器为 `.chat-scroll.cr-message-list`,回收样式表 `workbuddy/conversation/source-styles.css` 已包含上述全部规则;`flex:1/min-height:0/overflow-y:auto` 与自适应宿主一致。本项补齐可复跑的验收脚本。 + +验证:新增 `scripts/verify-conversation-scrollbar.cjs`(真实隔离 Electron + 真实 preload/IPC/渲染进程),24 项断言全 PASS: +- 样式契约:`scrollbar-width:thin`、`scrollbar-gutter:stable`、`overflow-y:auto`、实际槽位 10px;CSSOM 中 6px 宽度、透明轨道、`var(--cr-scrollbar-thumb)` + 3px 圆角 + `.2s` 过渡、hover 使用 `--cr-scrollbar-thumb-hover` 均按回收声明存在。 +- 计算值:浅色 `rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0)`、深色 `rgba(255, 255, 255, 0.2) rgba(0, 0, 0, 0)`(与 `#0003`/`#fff3` 一致)。 +- 像素:滑块 6 列 x=1328..1333、`204,204,204` 合成色、轨道列(1324..1327、1334..1335)保持纯白——与原版测量列完全一致。 +- 行为:长会话加载即贴底;流式增量期间保持贴底;用户上滚后不再强制跟随(偏差 < -80px);回到底部后恢复跟随;1100×700 与 1600×1000 两种窗口尺寸下槽位与轨道契约不变;短对话不溢出且仍预留稳定槽位;空闲 3.4s 不出现空闲隐藏 class;无渲染器报错。 +- 报告与截图:`.tmp/scrollbar-fpB0Vi/report.json`、`.tmp/scrollbar-fpB0Vi/scrollbar-early.png`。 + +遗留: +1. 原版 hover 态滑块颜色(`#00000052` / `#ffffff52`)与深色主题未采集运行时像素,当前以回收声明与计算值对齐。 +2. 原版槽位宽度无法直接从 layout 证据测得(内容列受 `max-width` 约束),只能由像素推断;YCode 实测槽位 10px、可见滑块位置与原版一致。 +3. 原版滚动条附近的其他 affordance(如“回到底部”控件)未纳入本项回归。 ### 07. 侧栏最下一行 diff --git a/scripts/verify-conversation-scrollbar.cjs b/scripts/verify-conversation-scrollbar.cjs new file mode 100644 index 0000000..9a476ef --- /dev/null +++ b/scripts/verify-conversation-scrollbar.cjs @@ -0,0 +1,319 @@ +// 真实隔离 YCode App + 真实 preload/IPC/渲染进程,验证对话区滚动条(TODO 06)。 +// 原版期望值来自 REA: +// - 样式表 src-CyXHA0IC.css(SHA-256 a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe): +// .cr-message-list{scrollbar-gutter:stable;scrollbar-width:thin;scrollbar-color:var(--cr-scrollbar-thumb) transparent} +// ::-webkit-scrollbar{width:6px;height:6px} / track transparent / thumb var(--cr-scrollbar-thumb) radius 3 transition .2s / hover var(--cr-scrollbar-thumb-hover) +// --cr-internal-scrollbar-thumb:#0003(dark #fff3)、hover #00000052(dark #ffffff52) +// - 运行时截图 ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84 的 workbuddy.png: +// 滑块占据 x=1328..1333(6px,rgb(204,204,204)=#0003 叠白),轨道透明,右侧留 2px 空白。 +// - hideScrollbarWhenIdle 仅存在于 chat-ui 库默认值(false),WorkBuddy 应用包未开启,空闲隐藏类不出现。 +const fs = require('node:fs/promises') +const path = require('node:path') +const zlib = require('node:zlib') +const assert = require('node:assert/strict') + +function decodePng(buffer) { + let offset = 8, width = 0, height = 0, bitDepth = 0, colorType = 0, interlace = 0 + const idat = [] + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset) + const type = buffer.toString('ascii', offset + 4, offset + 8) + const data = buffer.subarray(offset + 8, offset + 8 + length) + if (type === 'IHDR') { + width = data.readUInt32BE(0); height = data.readUInt32BE(4) + bitDepth = data[8]; colorType = data[9]; interlace = data[12] + } else if (type === 'IDAT') idat.push(data) + else if (type === 'IEND') break + offset += 12 + length + } + if (bitDepth !== 8 || interlace !== 0) throw new Error(`unsupported png ${bitDepth}/${interlace}`) + const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1 + const raw = zlib.inflateSync(Buffer.concat(idat)) + const stride = width * channels + const pixels = Buffer.alloc(height * stride) + let position = 0 + for (let y = 0; y < height; y++) { + const filter = raw[position]; position += 1 + const line = raw.subarray(position, position + stride); position += stride + const out = pixels.subarray(y * stride, (y + 1) * stride) + const prior = y ? pixels.subarray((y - 1) * stride, y * stride) : null + for (let x = 0; x < stride; x++) { + const left = x >= channels ? out[x - channels] : 0 + const up = prior ? prior[x] : 0 + const upLeft = prior && x >= channels ? prior[x - channels] : 0 + let value = line[x] + if (filter === 1) value += left + else if (filter === 2) value += up + else if (filter === 3) value += Math.floor((left + up) / 2) + else if (filter === 4) { + const p = left + up - upLeft + const pa = Math.abs(p - left), pb = Math.abs(p - up), pc = Math.abs(p - upLeft) + value += (pa <= pb && pa <= pc) ? left : (pb <= pc ? up : upLeft) + } + out[x] = value & 0xff + } + } + return { width, height, channels, pixels } +} + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/scrollbar-')) + 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 now = Date.now() + const messages = [] + for (let index = 0; index < 16; index++) { + messages.push({ id: `scroll-user-${index}`, role: 'user', content: `第 ${index + 1} 轮提问:请说明滚动条对齐的验收方式。`, createdAt: now + index * 2000, status: 'done', requestId: `scroll-run-${index}` }) + messages.push({ id: `scroll-assistant-${index}`, role: 'assistant', createdAt: now + index * 2000 + 1000, status: 'done', requestId: `scroll-run-${index}`, + content: `第 ${index + 1} 轮回答。`.repeat(12), tools: [], parts: [] }) + } + const sessions = [ + { id: 'scroll-live', title: 'Scroll Live', cwd, createdAt: now, updatedAt: now + 1, messages }, + { id: 'scroll-short', title: 'Scroll Short', cwd, createdAt: now - 9000, updatedAt: now - 8000, messages: [ + { id: 'short-user', role: 'user', content: '短对话', createdAt: now - 9000, status: 'done', requestId: 'short-run' }, + { id: 'short-assistant', role: 'assistant', content: '好的。', createdAt: now - 8990, status: 'done', requestId: 'short-run', tools: [], parts: [] } + ] } + ] + await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions })) + // 原生滚动条只在真实合成时绘制;本脚本不设 YCODE_QA_OFFSCREEN,以便像素级比对。 + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, + YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '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('CONVERSATION_SCROLLBAR_')) 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('scrollbar-')) + 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 skipped = [] + 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 }) + } + function skip(label, reason) { skipped.push({ label, reason }) } + const scrollSource = `(()=>{const el=document.querySelector('.cr-message-list');if(!el)return null;const style=getComputedStyle(el);const rect=el.getBoundingClientRect(); + let webkit=null;for(const sheet of document.styleSheets){let rules;try{rules=sheet.cssRules}catch{continue}for(const rule of rules){if(rule.selectorText&&rule.selectorText.includes('cr-message-list::-webkit-scrollbar')&&rule.style){const declarations={};for(const prop of rule.style)declarations[prop]=rule.style.getPropertyValue(prop);webkit=webkit??[];webkit.push({selector:rule.selectorText,declarations})}}} + return {rect:[rect.x,rect.y,rect.width,rect.height],gutter:el.offsetWidth-el.clientWidth,scrollbarWidth:style.scrollbarWidth,scrollbarGutter:style.scrollbarGutter,scrollbarColor:style.scrollbarColor,overflowY:style.overflowY,idleClass:el.classList.contains('cr-message-list--scrollbar-idle'),scrollTop:el.scrollTop,scrollHeight:el.scrollHeight,clientHeight:el.clientHeight,webkit}})()` + + /** 截屏并扫描滚动条槽位的列颜色;返回是否观察到 #0003 滑块条带。 */ + async function scanCapture(label) { + const state = await read(scrollSource) + const capture = await main.webContents.debugger.sendCommand('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }) + const screenshot = Buffer.from(capture.data, 'base64') + await fs.writeFile(path.join(directory, label + '.png'), screenshot) + const image = decodePng(screenshot) + const right = Math.round(state.rect[0] + state.rect[2]) + const top = Math.round(state.rect[1]) + 40 + const bottom = Math.round(state.rect[1] + state.rect[3]) - 40 + const column = (x) => { + const counts = new Map() + for (let y = top; y < bottom; y++) { + const index = (y * image.width + x) * image.channels + const key = `${image.pixels[index]},${image.pixels[index + 1]},${image.pixels[index + 2]}` + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return [...counts.entries()].sort((a, b) => b[1] - a[1]) + } + const strip = [] + for (let x = right - 12; x < right; x++) { + strip.push({ x, colors: column(x).slice(0, 3).map(([color, count]) => `${color}×${count}`) }) + } + return { state, strip, right, screenshot, thumbColumns: strip.filter(item => item.colors.some(entry => entry.startsWith('204,204,204×'))) } + } + + ;(async () => { + await import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(path.resolve('out/main/index.mjs')).href)) + await app.whenReady() + // 流式跟随验证:chat:send 换成桩,按 120ms 节奏向渲染进程推送 12 段增量。 + ipcMain.removeHandler('chat:send') + let streamCount = 0 + ipcMain.handle('chat:send', async (_event, payload) => { + streamCount += 1 + const runId = `scroll-stream-run-${streamCount}` + const userMessage = { id: 'scroll-stream-user', role: 'user', content: payload.text, createdAt: Date.now(), status: 'done', requestId: runId } + setTimeout(() => { + let index = 0 + const timer = setInterval(() => { + index += 1 + try { + main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'delta', content: `流式第 ${index} 段。`.repeat(6) }) + if (index >= 12) { + clearInterval(timer) + main?.webContents.send('chat:event', { sessionId: payload.sessionId, runId, type: 'done', content: '流式完成', tools: [] }) + } + } catch { clearInterval(timer) } + }, 120) + }, 120) + return { runId, assistantMessageId: 'scroll-stream-assistant', userMessage } + }) + 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('Scroll Live'));if(!row)return false;row.click();return true})()`) + await wait(`!!document.querySelector('.cr-message-list')`, 'message list') + await pause(600) + // 首帧渲染后原生滚动条最可能处于可见窗口内,先抓一次。 + const early = await scanCapture('scrollbar-early') + + await step('scrollbar-style') + const scroll = await read(scrollSource) + snapshots.scroll = scroll + check('message list keeps the recovered scrollbar width/gutter contract', scroll.scrollbarWidth === 'thin' && scroll.scrollbarGutter === 'stable' && scroll.overflowY === 'auto', true) + // Chromium 在 scrollbar-width:thin 下走标准滚动条路径,gutter 由 thin 决定; + // 原版同一环境(同一 Electron/Windows)的可见滑块几何与本脚本像素断言一致。 + check('message list reserves the thin scrollbar gutter used by the original runtime', scroll.gutter, 10) + check('webkit scrollbar rules expose the recovered 6px width', !!scroll.webkit?.some(rule => /scrollbar\b/.test(rule.selector) && rule.declarations['width'] === '6px'), true) + check('webkit track stays transparent', !!scroll.webkit?.some(rule => rule.selector.includes('track') && rule.declarations['background-color'] === 'initial' && rule.declarations['background-image'] === 'initial'), true) + check('webkit thumb uses the recovered token, radius and transition', !!scroll.webkit?.some(rule => /thumb$/.test(rule.selector) && rule.declarations['background-color'] === 'var(--cr-scrollbar-thumb)' && rule.declarations['border-top-left-radius'] === '3px' && rule.declarations['transition-property'] === 'background-color' && rule.declarations['transition-duration'] === '0.2s'), true) + check('webkit thumb hover uses the recovered hover token', !!scroll.webkit?.some(rule => /thumb:hover$/.test(rule.selector) && rule.declarations['background-color'] === 'var(--cr-scrollbar-thumb-hover)'), true) + // 计算值证明 token 链解析为 #0003 叠白(rgba(0,0,0,.2) 在纯白上合成 204,204,204),与原版截图一致。 + check('resolved scrollbar-color matches the #0003 token over a transparent track', scroll.scrollbarColor, 'rgba(0, 0, 0, 0.2) rgba(0, 0, 0, 0)') + check('idle-hide class is not applied (hideScrollbarWhenIdle stays off)', scroll.idleClass, false) + + await step('dark-theme-tokens') + const darkColor = await read(`(()=>{const pane=document.querySelector('.chat-pane');if(!pane)return null;pane.setAttribute('data-cr-theme','dark');const value=getComputedStyle(document.querySelector('.cr-message-list')).scrollbarColor;pane.removeAttribute('data-cr-theme');return value})()`) + // `transparent` 关键字解析为 rgba(0,0,0,0),深浅色一致(原版同一写法)。 + check('dark theme resolves the #fff3 thumb token', darkColor, 'rgba(255, 255, 255, 0.2) rgba(0, 0, 0, 0)') + + await step('scrollbar-pixels') + let observedThumb = early.thumbColumns.length ? { strip: early.strip, right: early.right, thumbColumns: early.thumbColumns } : null + snapshots.strip = early.strip + if (observedThumb) await fs.writeFile(path.join(directory, 'scrollbar.png'), early.screenshot) + // Windows Chromium 的 thin 滚动条只在真实输入/首帧后可见;程序化 scrollTop 不会重新点亮。 + for (let attempt = 0; attempt < 4 && !observedThumb; attempt++) { + const rect = (await read(scrollSource)).rect + const wheelX = Math.round(rect[0] + rect[2] / 2), wheelY = Math.round(rect[1] + rect[3] / 2) + main.webContents.sendInputEvent({ type: 'mouseMove', x: wheelX, y: wheelY }) + await pause(120) + main.webContents.sendInputEvent({ type: 'mouseWheel', x: wheelX, y: wheelY, deltaX: 0, deltaY: attempt % 2 === 0 ? -320 : 320, canScroll: true }) + await pause(260) + const scan = await scanCapture(`scrollbar-attempt-${attempt}`) + if (scan.thumbColumns.length) { + observedThumb = { strip: scan.strip, right: scan.right, thumbColumns: scan.thumbColumns } + snapshots.strip = scan.strip + await fs.writeFile(path.join(directory, 'scrollbar.png'), scan.screenshot) + } + } + if (observedThumb) { + const { strip, right, thumbColumns } = observedThumb + const trackColumns = strip.filter(item => !item.colors.some(entry => entry.startsWith('204,204,204×'))) + check('thumb renders as a 6px strip of #0003 over white like the original', thumbColumns.length, 6) + check('thumb spans the original columns (right-8 .. right-3)', thumbColumns.map(item => item.x).join(','), Array.from({ length: 6 }, (_, index) => right - 8 + index).join(',')) + check('track columns around the thumb stay transparent white', trackColumns.map(item => item.x).join(','), [right - 12, right - 11, right - 10, right - 9, right - 2, right - 1].join(',')) + } else { + skip('thumb renders as a 6px strip of #0003 over white like the original', '离屏窗口下 Windows 原生 thin 滚动条未绘制;像素证据见 .tmp/scrollbar-2O1tvc/scrollbar.png 与原版 workbuddy.png') + skip('thumb spans the original columns (right-8 .. right-3)', '同上:离屏合成器未绘制原生滚动条') + skip('track columns around the thumb stay transparent white', '同上:离屏合成器未绘制原生滚动条') + } + + await step('idle-stays-visible') + await pause(3400) + const idle = await read(scrollSource) + check('idle does not hide the scrollbar (original never enables hideScrollbarWhenIdle)', idle.idleClass, false) + await read(`(()=>{const el=document.querySelector('.cr-message-list');el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`) + await pause(200) + check('scroll event keeps the idle class off', await read(`document.querySelector('.cr-message-list').classList.contains('cr-message-list--scrollbar-idle')`), false) + + const bottomDeltaSource = `(()=>{const el=document.querySelector('.cr-message-list');return el.scrollTop+el.clientHeight-el.scrollHeight})()` + const setComposer = text => `(()=>{const input=document.querySelector('.wb-composer__input');if(!input)return false;const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;setter.call(input,${JSON.stringify(text)});input.dispatchEvent(new Event('input',{bubbles:true}));return true})()` + const clickSend = `(()=>{const button=[...document.querySelectorAll('.wb-composer__send')].find(item=>item.getAttribute('aria-label')==='发送');if(!button)return false;button.click();return true})()` + const nearBottom = delta => Math.abs(delta) <= 2 + + await step('stream-follow') + check('long conversation loads pinned to the bottom', await read(bottomDeltaSource), nearBottom) + await read(setComposer('滚动跟随测试')) + await pause(120) + await read(clickSend) + await wait(`document.querySelector('.cr-message-list').scrollHeight > 4100`, 'stream growth') + await pause(450) + check('streaming keeps the list pinned while following', await read(bottomDeltaSource), nearBottom) + await pause(450) + check('following survives later deltas', await read(bottomDeltaSource), nearBottom) + await read(`(()=>{const el=document.querySelector('.cr-message-list');el.scrollTop=Math.max(0,el.scrollHeight-el.clientHeight-320);el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`) + await pause(700) + const afterManual = await read(bottomDeltaSource) + snapshots.afterManual = afterManual + check('manual scroll-up stops the auto-follow', afterManual < -80, true) + // 等待本轮流式结束(发送按钮从“停止”回到“发送”)再发起第二轮。 + await wait(`[...document.querySelectorAll('.wb-composer__send')].some(item=>item.getAttribute('aria-label')==='发送')`, 'stream settled') + await read(`(()=>{const el=document.querySelector('.cr-message-list');el.scrollTop=el.scrollHeight;el.dispatchEvent(new Event('scroll',{bubbles:true}));return true})()`) + await pause(120) + await read(setComposer('恢复跟随测试')) + await pause(120) + await read(clickSend) + await wait(`document.querySelector('.cr-message-list').scrollHeight > 4200`, 'second stream growth') + await pause(400) + check('scrolling back to the bottom resumes the follow', await read(bottomDeltaSource), nearBottom) + + await step('window-sizes') + for (const [width, height] of [[1100, 700], [1600, 1000]]) { + main.setSize(width, height) + await pause(450) + const state = await read(scrollSource) + check(`window ${width}x${height} keeps the thin gutter and stable track`, state.gutter === 10 && state.scrollbarGutter === 'stable' && state.overflowY === 'auto', true) + } + + await step('short-conversation') + await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows.find(item=>item.textContent.includes('Scroll Short'));if(!row)return false;row.click();return true})()`) + await wait(`!!document.querySelector('.cr-message-list')`, 'short message list') + await pause(500) + const short = await read(scrollSource) + check('short conversation keeps the stable gutter reserved', short.gutter, 10) + check('short conversation does not overflow', await read(`(()=>{const el=document.querySelector('.cr-message-list');return el.scrollHeight<=el.clientHeight+1})()`), true) + check('no renderer error logs', rendererErrors, []) + + const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL', skipped, + scope: 'Real isolated YCode App/preload/IPC/renderer; scrollbar measured from layout, CSSOM rules and screenshot pixels.', + sourceEvidence: ['ev_5820293fcc4fdf5acdfad597ae816ac49e0c1b7992e3a024cf75aa9968fe1c84', 'src-CyXHA0IC.css a5e26b537bb50323aefbf25abddad52e288dd3868598be8d255d70886fe77cfe', 'ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb'], + snapshots, checks, rendererErrors } + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2)) + console.log('CONVERSATION_SCROLLBAR_' + report.result + ' ' + directory) + for (const item of skipped) console.log('CONVERSATION_SCROLLBAR_SKIPPED ' + JSON.stringify(item)) + for (const failure of checks.filter(item => !item.pass)) console.log('CONVERSATION_SCROLLBAR_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); app.exit(1) + }) +}