docs(B01): report confirmed mode chip and task button styling gaps

This commit is contained in:
xiaohan.yuan committed 2026-10-21 12:30:57 +08:00
1 parent eaf0ed293a
commit ddd677bae3
9 files changed
+548

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
# B01 补充反馈 01:确认按钮与计划/仅问答样式
日期:2026-10-21(Asia/Shanghai)。
用户在最新 dev 中连续反馈“删除对话和归档对话的弹窗,按钮不对”“计划和仅回答的样式也不对”。验收端已在隔离 Windows Electron 中复现,**A05、A06 仍待开发**。
这两项已属于 [B01 需求](B01-需求.md) 的 YC-003,不是本次 YC-001 新增回归,也不应记为已修复。保留 [验收 01](B01-验收-01.md) 对 YC-001/F01 的结论;本反馈不覆盖或改写原交付记录。开发端接续界面修复时,应优先处理用户再次指出的这两个表面。
## 1. 版本与验证方式
- 用户当前 dev:`eaf0ed293a2234e46f0ec48740cb02dd748b239b`,renderer 已确认加载新 disclosure 模块。
- 独立窗口受测:`51531dcae8d8ade85db9e203bc8c0f712805b2ae`,真实 main/preload/renderer、专用临时 userData、home、workspace 和测试消息;未操作用户实际会话、调用真实模型或读取原版实现。
- 已用 Git diff 确认上述两个提交之间无产品代码差异;ModeChip 和 TaskPopconfirm 对应 TSX/CSS 从 `37ddb2c` 到当前版本也未修改。此次交付范围原本只有 A01/A03/A04,不能误以为整份方案已经实施。
- 菜单按钮在浮层透明度到 1 后额外等待 400ms 再测量和截图,排除淡入截图时机。
## 2. FB01 / A06:归档、删除按钮变成普通文字
Windows 实际结果,归档确认、删除确认以及两处取消按钮相同:
| 属性 | 实际值 |
| --- | --- |
| 背景 | `rgba(0, 0, 0, 0)` |
| 文字 | `rgb(0, 0, 0)` |
| 边框/圆角 | `0px none`/`0px` |
| 最小宽度 | `52px` |
| 内边距/高度 | `0px 8px`/`28px` |
因此主按钮、次按钮及删除危险变体没有区分,不是原版预期按钮。
定位:
- `src/renderer/src/components/TaskPopconfirm.tsx` 仍使用裸 `<button>` 加 `wb-button--secondary/primary/danger` 类;本 portal 的实际样式作用域没有使这些变体生效。
- `src/renderer/src/components/task-popconfirm.css:30` 只提供 52px 最小宽度、高度与 8px 水平 padding。
- 现有 `src/renderer/src/ui/Button.tsx` 提供加载自身 CSS 的 `yc-button` 原语,开发端可以核对后复用;不能以类名看起来相同代替实际样式验证。
开发要求延用 AC3.2/AC3.3:恢复已确认的主/次/危险变体和主题样式、66px 最小宽度、19px 水平 padding;同时处理 A06 已记录的浮层 16px 圆角、600 标题及归档说明差异。保持 240px 右侧锚定、焦点、点击吞掉、忙状态、失败与真实归档/删除行为。
测试盲区再次确认:原有菜单测试本轮仍全绿,真实测试数据上的归档/删除也能执行,但没有检查这些变体的计算样式。必须新增背景、边框、圆角、尺寸与危险语义断言,并验证浅深主题;不要刷新期望值去接受当前错误外观。运行中的“停止并删除”仍须补对应状态验证,不能从本次 idle 结果推导通过。
证据:[计算样式](evidence/B01-feedback-01/button-styles.json)、[归档截图](evidence/B01-feedback-01/archive-confirm.png)、[删除截图](evidence/B01-feedback-01/delete-confirm.png)、[独立菜单探针](evidence/B01-feedback-01/task-buttons-probe.cjs)。
## 3. FB02 / A05:模式标签的默认、聚焦外观不符
实际“计划”和“仅问答”均为:
- 灰底 `rgb(235, 235, 235)`,8px 圆角,主文字色 `rgba(0, 0, 0, 0.9)`。
- 32px 高、13px/500/20px 文字、8px 水平 padding、16px 图标。这些尺寸已符合现有记录,不应重复当作错误。
- 聚焦计划标签后,模式图标仍可见,右上角另外出现“×”;没有在同一个图标槽位替换。源码把清除按钮绝对定位到 `right/top`,不是槽内切换。
- 图标源码仍标注为本地几何,未完成同版本 REA SVG/像素验收。
定位:`src/renderer/src/workbuddy/composer/ModeChip.tsx` 与 `mode-chip.css`。
开发要求延用 AC3.1:默认透明背景、pill 圆角、次级文字;hover/focus 时原图标槽位替换为 X,不额外占位或在右上角漂浮。恢复既有证据中的图标与布局,缺少证据时明确未验证。保持 craft 隐藏、plan/ask 模式选择和退出、键盘/焦点、禁用、权限偏好与草稿语义;本次探针不证明所有模式业务行为通过。
证据:[默认与聚焦计算样式](evidence/B01-feedback-01/mode-styles.json)、[计划聚焦截图](evidence/B01-feedback-01/plan-focus.png)、[仅问答截图](evidence/B01-feedback-01/ask.png)、[窗口探针](evidence/B01-feedback-01/mode-visual-probe.cjs)。窗口探针也采集其他区域,但本反馈只对上述模式与按钮作结论,不把其他截图自动纳入本轮验收。
## 4. 交付与交接
1. 当前验收端只新增文档和证据,未修改业务代码。开发端沿用 B01/YC-003 的编号与 AC,明确何时接单,修复后提交交付记录和完整代码 SHA。
2. 不将本反馈合并进 YC-001 的完成声明,不重复实现已通过的 A01/A04。若先修 F01,可单独交付;模式/按钮可作为后续独立交付范围,报告清楚即可。
3. 复测应同时覆盖“归档/删除的取消与确认”“计划/仅问答的默认、hover、focus、退出”,保留真实计算样式和稳定截图,不能只验 class 或纯 SSR。
4. 本文推送是用户明确补充反馈的一次文档交接;推送成功后窗口立即交回开发端。没有开启自动轮询。
复现探针已随 Git 保存:在固定受测代码检出完成独立构建后,将两份 `.cjs` 复制到该检出的 `.tmp/` 再运行。它们分别创建隔离数据目录,打印证据位置;不要用用户正式数据手动执行删除来验证按钮样式。
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

@@ -0,0 +1,49 @@
{
"codeSha": "51531dcae8d8ade85db9e203bc8c0f712805b2ae",
"archive": {
"cancel": {
"text": "取消",
"background": "rgba(0, 0, 0, 0)",
"color": "rgb(0, 0, 0)",
"border": "0px none rgb(0, 0, 0)",
"borderRadius": "0px",
"minWidth": "52px",
"padding": "0px 8px",
"height": "28px"
},
"confirm": {
"text": "确认归档",
"background": "rgba(0, 0, 0, 0)",
"color": "rgb(0, 0, 0)",
"border": "0px none rgb(0, 0, 0)",
"borderRadius": "0px",
"minWidth": "52px",
"padding": "0px 8px",
"height": "28px"
}
},
"delete": {
"cancel": {
"text": "取消",
"background": "rgba(0, 0, 0, 0)",
"color": "rgb(0, 0, 0)",
"border": "0px none rgb(0, 0, 0)",
"borderRadius": "0px",
"minWidth": "52px",
"padding": "0px 8px",
"height": "28px"
},
"confirm": {
"text": "确认",
"background": "rgba(0, 0, 0, 0)",
"color": "rgb(0, 0, 0)",
"border": "0px none rgb(0, 0, 0)",
"borderRadius": "0px",
"minWidth": "52px",
"padding": "0px 8px",
"height": "28px"
}
},
"rendererErrors": [],
"scope": "Isolated Windows Electron; test sessions only, no user data, no model requests"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -0,0 +1,118 @@
{
"head": "51531dcae8d8ade85db9e203bc8c0f712805b2ae",
"scope": "Real isolated YCode main/preload/renderer; delayed local IPC and injected approval snapshot; fixture model only; no model calls or original app access.",
"mode": [
{
"selector": ".yc-mode-chip",
"text": "计划×",
"width": 62,
"height": 32,
"fontSize": "13px",
"fontWeight": "500",
"lineHeight": "20px",
"padding": "0px 8px",
"borderRadius": "8px",
"background": "rgb(235, 235, 235)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "flex",
"opacity": "1"
},
{
"selector": ".yc-mode-chip > svg",
"text": "",
"width": 16,
"height": 16,
"fontSize": "13px",
"fontWeight": "500",
"lineHeight": "20px",
"padding": "0px",
"borderRadius": "0px",
"background": "rgba(0, 0, 0, 0)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "block",
"opacity": "1"
},
{
"selector": ".yc-mode-chip__clear",
"text": "×",
"width": 16,
"height": 16,
"fontSize": "16px",
"fontWeight": "400",
"lineHeight": "normal",
"padding": "0px",
"borderRadius": "50%",
"background": "rgb(235, 235, 235)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "flex",
"opacity": "0"
}
],
"focusedMode": [
{
"selector": ".yc-mode-chip",
"text": "计划×",
"width": 62,
"height": 32,
"fontSize": "13px",
"fontWeight": "500",
"lineHeight": "20px",
"padding": "0px 8px",
"borderRadius": "8px",
"background": "rgb(235, 235, 235)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "flex",
"opacity": "1"
},
{
"selector": ".yc-mode-chip > svg",
"text": "",
"width": 16,
"height": 16,
"fontSize": "13px",
"fontWeight": "500",
"lineHeight": "20px",
"padding": "0px",
"borderRadius": "0px",
"background": "rgba(0, 0, 0, 0)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "block",
"opacity": "1"
},
{
"selector": ".yc-mode-chip__clear",
"text": "×",
"width": 16,
"height": 16,
"fontSize": "16px",
"fontWeight": "400",
"lineHeight": "normal",
"padding": "0px",
"borderRadius": "50%",
"background": "rgb(235, 235, 235)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "flex",
"opacity": "1"
}
],
"ask": [
{
"selector": ".yc-mode-chip",
"text": "仅问答×",
"width": 75,
"height": 32,
"fontSize": "13px",
"fontWeight": "500",
"lineHeight": "20px",
"padding": "0px 8px",
"borderRadius": "8px",
"background": "rgb(235, 235, 235)",
"color": "rgba(0, 0, 0, 0.9)",
"display": "flex",
"opacity": "1"
},
null,
null
],
"errors": []
}
@@ -0,0 +1,95 @@
const fs = require('node:fs/promises')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
if (!process.versions.electron) {
;(async () => {
const dir = await fs.mkdtemp(path.resolve('.tmp/alignment-window-'))
const data = path.join(dir, 'data'), home = path.join(dir, 'home'), cwd = path.join(dir, 'workspace')
for (const folder of [data, home, cwd]) await fs.mkdir(folder)
const now = Date.now()
const model = { id:'audit-model', name:'Audit Model', providerId:'custom', model:'fixture', baseUrl:'http://127.0.0.1:1', authMode:'none', protocol:'anthropic', supportsToolCall:true }
await fs.writeFile(path.join(data,'models.json'), JSON.stringify({version:1, models:[model], selectedModelId:model.id}))
await fs.writeFile(path.join(data,'workspaces.json'),JSON.stringify({selectedWorkspaceId:'audit-space',workspaces:[{id:'audit-space',name:'Audit Workspace',root:cwd,createdAt:now}]}))
const question = { id:'q1', question:'Which target should be inspected?', answers:['Local fixture'] }
const messages = [
{id:'u1',role:'user',content:'Inspect the local fixture.',createdAt:now-10000,status:'done'},
{id:'a1',role:'assistant',content:'The local fixture is ready.',createdAt:now-9000,status:'done',turnStartedAt:now-10000,turnFinishedAt:now,turnDurationMs:10000,
tools:[{id:'q-tool',name:'AskUserQuestion',args:{questions:[question]},argsRaw:'{}',status:'ok',output:'ANSWER_RAW_SENTINEL',metadata:{questionAnswer:{questions:[question]}}}],
parts:[{type:'reasoning',id:'r1',text:'I need the target.',finished:true},{type:'tool',toolId:'q-tool'},{type:'reasoning',id:'r2',text:'The target is selected.',finished:true},{type:'text',text:'The local fixture is ready.'}]}
]
const session = (id,agentMode,ms) => ({id,title:`Audit ${agentMode}`,cwd,workspaceId:'audit-space',modelId:model.id,agentMode,permissionMode:'full-access',createdAt:ms,updatedAt:ms,messages})
await fs.writeFile(path.join(data,'sessions.json'),JSON.stringify({sessions:[session('audit-plan','plan',now+1),session('audit-ask','ask',now)]}))
const env={...process.env,YCODE_USER_DATA_DIR:data,YCODE_QA_DIRECTORY:dir,YCODE_QA_OFFSCREEN:'1',YCODE_DISABLE_GPU:'1',YCODE_DISABLE_SYSTEM_INTEGRATION:'1',AUDIT_WINDOW_DIR:dir}
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 log='';child.stdout.on('data',x=>{log+=x});child.stderr.on('data',x=>{log+=x})
const timeout=setTimeout(()=>child.kill(),90000)
child.on('exit',async code=>{clearTimeout(timeout);await fs.writeFile(path.join(dir,'process.log'),log);console.log(JSON.stringify({directory:dir,exitCode:code}));if(code)console.error(log.slice(-7000));process.exitCode=code??1})
})().catch(e=>{console.error(e);process.exitCode=1})
} else {
const {app,BrowserWindow,ipcMain}=require('electron')
const dir=process.env.AUDIT_WINDOW_DIR
app.disableHardwareAcceleration();app.setPath('home',path.join(dir,'home'))
const originalHandle=ipcMain.handle.bind(ipcMain)
ipcMain.handle=(channel,listener)=>originalHandle(channel,async(...args)=>{if(channel==='session:messages')await new Promise(r=>setTimeout(r,1200));return listener(...args)})
let win
const pause=ms=>new Promise(r=>setTimeout(r,ms))
const read=code=>win.webContents.executeJavaScript(code)
async function wait(code){for(let i=0;i<250;i++){const x=await read(code);if(x)return x;await pause(40)}throw new Error('Timeout '+code)}
async function shot(name){await read('document.fonts.ready');await pause(80);await fs.writeFile(path.join(dir,name+'.png'),(await win.webContents.capturePage()).toPNG())}
const styleProbe=`(selector)=>{const e=document.querySelector(selector);if(!e)return null;const c=getComputedStyle(e),r=e.getBoundingClientRect();return {selector,text:e.textContent,width:r.width,height:r.height,fontSize:c.fontSize,fontWeight:c.fontWeight,lineHeight:c.lineHeight,padding:c.padding,borderRadius:c.borderRadius,background:c.backgroundColor,color:c.color,display:c.display,opacity:c.opacity}}`
const styles=selectors=>read(`(()=>{const p=${styleProbe};return ${JSON.stringify(selectors)}.map(p)})()`)
;(async()=>{
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href);await app.whenReady();win=BrowserWindow.getAllWindows()[0];win.setSize(1336,902);win.showInactive()
const errors=[];win.webContents.on('console-message',(_e,level,message)=>{if(level>=3)errors.push(message)})
await wait('!!document.querySelector(".yc-session-skeleton")')
const skeleton=await styles(['.yc-session-skeleton','.yc-session-skeleton__avatar','.yc-session-skeleton__name','.yc-session-skeleton__user','.yc-session-skeleton__line','.yc-session-skeleton__line--short'])
await shot('loading')
await wait('!!document.querySelector(".yc-mode-chip") && !!document.querySelector(".msg--assistant")')
await pause(350)
const mode=await styles(['.yc-mode-chip','.yc-mode-chip > svg','.yc-mode-chip__clear'])
await shot('plan')
await read('document.querySelector(".yc-mode-chip").focus()')
const focusedMode=await styles(['.yc-mode-chip','.yc-mode-chip > svg','.yc-mode-chip__clear'])
await shot('plan-focus')
await read('document.querySelector(".cr-collapse--completed > header").click()')
await wait('!!document.querySelector(".ask-user-question--completed")')
await read('document.querySelector(".ask-user-question__header .cr-tool-head__main").click()')
await wait('!!document.querySelector(".question-answer-transcript__question")')
const question=await styles(['.ask-user-question--completed','.ask-user-question__header','.ask-user-question__content','.question-answer-transcript__question','.question-answer-transcript__answer'])
await shot('answered-expanded')
await read('document.querySelector("[data-session-id=\\"audit-ask\\"]").click()')
await wait('!!document.querySelector(".yc-session-skeleton")')
const switching=await read('({hasHome:!!document.querySelector(".wb-home-route"),hasSkeleton:!!document.querySelector(".yc-session-skeleton"),hasComposer:!!document.querySelector(".wb-composer"),hasHeader:!!document.querySelector(".source-chat-header"),text:document.querySelector(".yc-mode-chip")?.textContent})')
await shot('switch-loading')
await wait('!!document.querySelector(".msg--assistant") && !document.querySelector(".yc-session-skeleton")')
const ask=await styles(['.yc-mode-chip','[data-permission-trigger]','.yc-source-permission button'])
await shot('ask')
await read('document.querySelector("[data-session-id=\\"audit-ask\\"] .agent-card-more-button").click()')
await wait('!!document.querySelector(".cb-context-menu-item")')
await read('[...document.querySelectorAll(".cb-context-menu-item")].find(e=>e.textContent.includes("归档")).click()')
await wait('!!document.querySelector(".task-popconfirm") && Number(getComputedStyle(document.querySelector(".task-popconfirm")).opacity)>=0.99')
await pause(500)
const popconfirm=await styles(['.task-popconfirm','.task-popconfirm__title','.task-popconfirm__description','.task-popconfirm__confirm','.task-popconfirm__cancel'])
const popconfirmAncestors=await read('(()=>{let e=document.querySelector(".task-popconfirm"),a=[];while(e){const c=getComputedStyle(e);a.push({className:e.className,opacity:c.opacity,visibility:c.visibility});e=e.parentElement}return a})()')
await shot('archive-settled')
await read('document.querySelector(".task-popconfirm__cancel").click()')
await read('document.querySelector(".yc-mode-chip__clear").click()')
await wait('!document.querySelector(".yc-mode-chip")')
const fixtureTime=Date.now(), runId='audit-approval-run'
const userMessage={id:'audit-approval-user',requestId:runId,role:'user',content:'Render a local approval fixture.',createdAt:fixtureTime,status:'done'}
const approval={id:'audit-approval',requestId:'audit-approval',runId,toolCallId:'audit-command',kind:'approval',title:'执行命令命中询问规则,需要确认。',status:'pending',options:[{optionId:'once',name:'允许',kind:'allow_once'},{optionId:'session',name:'本次会话内始终允许',kind:'allow_always'},{optionId:'deny',name:'拒绝',kind:'reject_once'}],details:{requestKind:'rule-intercept',toolName:'Bash',command:'echo audit',reason:'fixture rule',rule:{id:'fixture-command-rule',description:'Local fixture only'},allowScope:'session'}}
const assistant={id:'audit-approval-assistant',requestId:runId,role:'assistant',content:'',createdAt:fixtureTime,status:'streaming',turnStartedAt:fixtureTime,parts:[],tools:[],interactions:[approval],runtimeProgress:{source:'agent',phase:'waiting-approval',toolName:'Bash'}}
win.webContents.send('chat:run-started',{sessionId:'audit-ask',runId,assistantMessageId:assistant.id,userMessage,modelId:'audit-model',modelName:'Audit Model',sequence:0,messages:[userMessage,assistant]})
await wait('!!document.querySelector(".conversation-intercept-card")')
await pause(150)
const permission=await styles(['.conversation-intercept-card','.conversation-intercept-card__title','.conversation-intercept-card__rule-action','.conversation-intercept-card__description','.conversation-intercept-card__option','.conversation-intercept-card__option-num'])
await shot('approval')
await read('document.querySelector(".conversation-intercept-card__rule-action").click()')
const permissionRule=await styles(['.conversation-intercept-card__rule'])
await shot('approval-rule')
await fs.writeFile(path.join(dir,'report.json'),JSON.stringify({head:'51531dcae8d8ade85db9e203bc8c0f712805b2ae',scope:'Real isolated YCode main/preload/renderer; delayed local IPC and injected approval snapshot; fixture model only; no model calls or original app access.',skeleton,mode,focusedMode,question,switching,ask,popconfirm,popconfirmAncestors,permission,permissionRule,errors},null,2))
console.log('WINDOW_AUDIT_DONE');app.exit(0)
})().catch(async e=>{console.error(e);if(win)await shot('failure').catch(()=>{});await fs.writeFile(path.join(dir,'failure.txt'),String(e));app.exit(1)})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

@@ -0,0 +1,222 @@
// 真实隔离 YCode App + 真实 preload/IPC/文件系统,验证侧栏任务操作菜单。
// 原版期望值来自固定 REA 归档(ev_f5e9d71a…:WorkBuddy 5.8.3 任务菜单打开状态),不从本次运行的 YCode 反推。
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/task-menu-'))
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)
await fs.writeFile(path.join(cwd, 'TASK_MENU_SENTINEL.txt'), 'TASK_MENU_NATIVE_SENTINEL')
const now = Date.now()
const crypto = require('node:crypto')
const sessions = ['Menu Alpha', 'Menu Beta'].map((title, index) => ({
id: crypto.randomUUID(), title, cwd, createdAt: now + index, updatedAt: now + index,
messages: [{ id: crypto.randomUUID(), role: 'user', content: title, createdAt: now + index, status: 'done' }]
}))
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, 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'),
[__filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 150000)
child.once('exit', async (code, signal) => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code, signal }))
if (code !== 0) console.error(output)
process.exitCode = code === 0 ? 0 : 1
})
})()
} else {
const { app, BrowserWindow } = require('electron')
app.disableHardwareAcceleration()
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('task-menu-'))
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 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 openMenu = `(()=>{const row=document.querySelector('[data-session-id]');if(!row)return null;row.classList.add('sidebar__item--menu-open');const button=row.querySelector('.agent-card-more-button');if(!button)return null;button.click();return true})()`
const menuSource = `(()=>{const menu=document.querySelector('.conversation-context-menu');if(!menu)return null;const style=getComputedStyle(menu);const rect=menu.getBoundingClientRect();return {rect:[rect.x,rect.y,rect.width,rect.height],background:style.backgroundColor,borderRadius:style.borderRadius,padding:style.padding,items:[...menu.querySelectorAll('.cb-context-menu-item')].map(button=>{const box=button.getBoundingClientRect();const icon=button.querySelector('.cb-context-menu-item-icon svg');const label=button.querySelector('.cb-context-menu-item-label');const itemStyle=getComputedStyle(button);return {label:label?label.textContent:'',width:box.width,height:box.height,fontSize:itemStyle.fontSize,lineHeight:itemStyle.lineHeight,borderRadius:itemStyle.borderRadius,color:itemStyle.color,icon:icon?[icon.getBoundingClientRect().width,icon.getBoundingClientRect().height]:null}}),dividers:[...menu.querySelectorAll('.cb-context-menu-divider')].map(divider=>{const box=divider.getBoundingClientRect();const dividerStyle=getComputedStyle(divider);return {width:box.width,height:box.height,margin:dividerStyle.margin}})}})()`
const clickItemByLabel = label => `(()=>{const items=[...document.querySelectorAll('.cb-context-menu-item')];const match=items.find(item=>item.textContent.includes(${JSON.stringify(label)}));if(!match)return false;match.click();return true})()`
;(async () => {
// 关闭“打开文件夹”的系统副作用:拦截 shell.openPath,记录真实传入路径。
const { shell } = require('electron')
let openedFolder = null
shell.openPath = async (target) => { openedFolder = target; return '' }
const { pathToFileURL } = require('node:url')
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady()
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 wait('!!document.querySelector("[data-session-id]")', 'sidebar rows')
await step('menu-open')
await read(openMenu)
const menu = await wait(menuSource, 'task context menu')
snapshots.menu = menu
check('menu labels match the implemented subset', menu.items.map(item => item.label), ['置顶', '重命名', '在独立窗口中打开', '打开文件夹', '归档', '删除任务'])
check('menu uses two dividers like the original', menu.dividers.length, 2)
check('items are 32 high like the original', menu.items.every(item => Math.round(item.height) === 32), true)
check('item font is 14/22 like the original', menu.items.every(item => item.fontSize === '14px' && item.lineHeight === '22px'), true)
check('item radius is 8 like the original', menu.items.every(item => item.borderRadius === '8px'), true)
check('icon box is 16x16 like the original', menu.items.every(item => item.icon && Math.round(item.icon[0]) === 16 && Math.round(item.icon[1]) === 16), true)
check('menu padding is 8 like the original', menu.padding, '8px')
check('menu radius is 16 like the original', menu.borderRadius, '16px')
check('danger item keeps the original danger colour', menu.items.at(-1).color, 'rgb(180, 44, 63)')
check('normal items keep the original text colour', menu.items[0].color, 'rgba(0, 0, 0, 0.9)')
check('menu minimum width is 160 like the original', menu.rect[2] >= 160, true)
check('menu sits on the original anchor offset (button left - 17, bottom + 4)', await read(`(()=>{const row=document.querySelector('[data-session-id]');const button=row.querySelector('.agent-card-more-button');const menu=document.querySelector('.conversation-context-menu');const b=button.getBoundingClientRect(),m=menu.getBoundingClientRect();return [Math.round(b.left-17-m.left),Math.round(b.bottom+4-m.top)]})()`), [0, 0])
check('divider height is 1 like the original', menu.dividers.every(divider => Math.round(divider.height) === 1), true)
await shot('menu-open')
await step('menu-dismiss')
await read(`(()=>{const overlay=document.querySelector('.conversation-context-menu-overlay');if(!overlay)return false;overlay.click();return true})()`)
await pause(200)
check('overlay click closes the menu', await read('!!document.querySelector(".conversation-context-menu")'), false)
await step('context-menu-same-as-more')
const contextClicked = await read(`(()=>{const rows=[...document.querySelectorAll('[data-session-id]')];const row=rows[1];if(!row)return null;const ids=rows.map(item=>item.dataset.sessionId);const selected=document.querySelector('[data-selected="true"]')?.dataset.sessionId;row.dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,cancelable:true,button:2,clientX:220,clientY:420}));return {ids,selected}})()`)
const contextMenu = await wait(menuSource, 'task context menu from row right-click')
check('right-click opens the same implemented menu', contextMenu.items.map(item => item.label), snapshots.menu.items.map(item => item.label))
check('right-click opens the menu for the row under the pointer', await read(`document.querySelector('[data-session-id].sidebar__item--menu-open')?.dataset.sessionId`), contextClicked.ids[1])
check('right-click does not switch the active conversation', await read(`document.querySelector('[data-selected="true"]')?.dataset.sessionId`), contextClicked.selected)
check('right-click anchors the menu at the pointer', [Math.round(contextMenu.rect[0]), Math.round(contextMenu.rect[1])], [220, 420])
await read(`document.querySelector('.conversation-context-menu-overlay').click()`)
await wait(`!document.querySelector('.conversation-context-menu')`, 'right-click menu closes')
await step('pin-and-unpin')
await read(openMenu); await wait(menuSource, 'menu for pin')
await read(clickItemByLabel('置顶')); await pause(400)
check('pin adds the original pinned-item class', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), true)
check('pin shows a persistent pin badge', await read(`!!document.querySelector('[data-session-id] .sidebar__item-pin')`), true)
check('pin persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.pinnedAt!==undefined))`), true)
await read(openMenu); await wait(menuSource, 'menu after pin')
check('pinned row offers 取消置顶 first', menu.items.map(item => item.label).length > 0 && (await read(`document.querySelector('.cb-context-menu-item .cb-context-menu-item-label').textContent`)) === '取消置顶', true)
await read(clickItemByLabel('取消置顶')); await pause(400)
check('unpin clears the pinned marker', await read(`document.querySelector('[data-session-id]').classList.contains('conversation-pinned-item')`), false)
check('unpin persists', await read(`window.ycode.session.list().then(list=>list.every(item=>item.pinnedAt===undefined))`), true)
await step('rename')
await read(openMenu); await wait(menuSource, 'menu for rename')
await read(clickItemByLabel('重命名')); await pause(200)
check('menu exposes an inline rename input', await read(`!!document.querySelector('[data-session-id] .sidebar__item-rename')`), true)
await read(`(()=>{const input=document.querySelector('.sidebar__item-rename');const setter=Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,'value').set;setter.call(input,'Menu Renamed');input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,key:'Enter'}));return true})()`)
await pause(400)
check('rename input closes after Enter', await read('!!document.querySelector(".sidebar__item-rename")'), false)
check('rename updates the row title', await read(`document.querySelector('[data-session-id] .sidebar__item-title').textContent`), 'Menu Renamed')
check('rename persists to the session store', await read(`window.ycode.session.list().then(list=>list.some(item=>item.title==='Menu Renamed'))`), true)
await step('open-folder')
const expectedCwd = await read(`window.ycode.session.list().then(list=>list[0].cwd)`)
await read(openMenu); await wait(menuSource, 'menu for open folder')
await read(clickItemByLabel('打开文件夹')); await pause(400)
check('open folder targets the real session cwd', openedFolder ? path.resolve(openedFolder) : null, path.resolve(expectedCwd))
await step('open-window')
const windowCount = BrowserWindow.getAllWindows().length
await read(openMenu); await wait(menuSource, 'menu for open window')
await read(clickItemByLabel('在独立窗口中打开')); await pause(1500)
const windows = BrowserWindow.getAllWindows()
const extraWindow = windows.find(window => window !== main)
check('open window creates exactly one extra window', windows.length, windowCount + 1)
check('open window loads the task query for that session', extraWindow ? await extraWindow.webContents.executeJavaScript('location.search').catch(() => null) : null, '?window=task&taskId=' + (await read(`window.ycode.session.list().then(list=>list[0].id)`)))
for (const window of windows) if (window !== main) window.destroy()
await pause(200)
await step('archive-confirm')
await read(openMenu); await wait(menuSource, 'menu for archive')
await read(clickItemByLabel('归档'))
await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="archive"]')`, 'archive confirmation')
await wait(`Number(getComputedStyle(document.querySelector(".task-popconfirm")).opacity)>=0.99`, "stable popconfirm");
await pause(400);
snapshots.archiveButtons = await read("(()=>{const p=s=>{const e=document.querySelector(s),c=getComputedStyle(e);return {text:e.textContent,background:c.backgroundColor,color:c.color,border:c.border,borderRadius:c.borderRadius,minWidth:c.minWidth,padding:c.padding,height:c.height}};return {cancel:p('.task-popconfirm__cancel'),confirm:p('.task-popconfirm__confirm')}})()");
const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`)
check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务')
check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。')
check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true)
check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档'])
check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px'])
await shot('archive-confirm')
await read(`document.querySelector('.task-popconfirm__cancel').click()`)
await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes')
check('cancelling archive keeps the task visible', await read(`document.querySelectorAll('[data-session-id]').length`), 2)
await read(openMenu); await wait(menuSource, 'menu for archive confirm')
await read(clickItemByLabel('归档'))
await wait(`!!document.querySelector('.task-popconfirm__confirm')`, 'archive confirmation confirm')
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation')
const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`)
check('archive hides exactly one row', visibleAfterArchive, 1)
check('archive keeps both sessions in the archived listing', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 2)
check('archived session keeps its archivedAt marker', await read(`window.ycode.session.listArchived().then(list=>list.filter(item=>item.archivedAt!==undefined).length)`), 1)
await step('delete-confirm')
const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`)
await read(openMenu); await wait(menuSource, 'menu for delete')
await read(clickItemByLabel('删除任务'))
await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="delete"]')`, 'delete confirmation')
await wait(`Number(getComputedStyle(document.querySelector(".task-popconfirm")).opacity)>=0.99`, "stable popconfirm");
await pause(400);
snapshots.deleteButtons = await read("(()=>{const p=s=>{const e=document.querySelector(s),c=getComputedStyle(e);return {text:e.textContent,background:c.backgroundColor,color:c.color,border:c.border,borderRadius:c.borderRadius,minWidth:c.minWidth,padding:c.padding,height:c.height}};return {cancel:p('.task-popconfirm__cancel'),confirm:p('.task-popconfirm__confirm')}})()");
const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');return {title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,running:dialog.querySelector('[data-task-confirm-kind]').getAttribute('data-task-confirm-running')}})()`)
check('delete uses the idle confirmation title', deleteConfirm.title, '删除任务?')
check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。')
check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认'])
check('idle delete is not marked as running', deleteConfirm.running, null)
await shot('delete-confirm')
await read(`document.querySelector('.task-popconfirm__confirm').click()`)
await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation')
check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false)
check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/native filesystem. No upstream calls. Original expectations come from fixed REA archive ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2.',
sourceEvidence: ['ev_f5e9d71a676c453daab40928acfb33aeabae68ba57ffda342380cead7f1a1fe2'], snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('TASK_MENU_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('TASK_MENU_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)
})
}