feat(chat): switch to the conversation on send with a startup placeholder (TODO 08)

This commit is contained in:
xiaohan.yuan committed 2026-10-17 07:38:49 +08:00
1 parent 0b549ca794
commit 6173a77918
9 files changed
+404 -22

No files matched your search

@@ -0,0 +1,17 @@
{
"include_fonts": false,
"include_message_content": true,
"track_selectors": [
".conversation-page-chrome__content",
".conversation-input",
".cr-input-editor-host",
".cr-send-button"
],
"actions": [
{ "kind": "wait", "step_id": "wait-100ms", "duration_ms": 0, "frames": 1 },
{ "kind": "wait", "step_id": "wait-300ms", "duration_ms": 200, "frames": 1 },
{ "kind": "wait", "step_id": "wait-500ms", "duration_ms": 100, "frames": 1 },
{ "kind": "wait", "step_id": "wait-1000ms", "duration_ms": 400, "frames": 1 },
{ "kind": "wait", "step_id": "wait-2000ms", "duration_ms": 1000, "frames": 1 }
]
}
+256
View File
@@ -0,0 +1,256 @@
// TODO 08 验收:新任务发送后的立即切页与占位。
// 真实隔离 YCode(真实 preload/IPC/渲染进程)+ 真实 OMP + 受控本地 OpenAI 兼容上游。
//
// 原版依据(REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb,
// WorkBuddy 5.8.2,见 docs/对话启动提示文案-2026-10-16.md):启动阶段文案取自
// chat.prepare.<stage>,渲染在流式状态栏主文案位置。YCode 侧使用 acquiring_runtime
// =「正在启动对话运行环境…」作为主进程回合就绪前的占位文案。
// 原版「发送后」逐帧等待态没有 REA 运行时证据:REA visual probe 的 text 动作只接受
// INPUT、key 动作排除可编辑内容上的 Enter/Space(见 .tmp/todo08-wb-new-task/notes.md),
// 因此本脚本只验收可证行为:发送即切页 + 用户消息先出现 + 准备阶段占位先于真实回合 +
// 真实消息替换不重复 + 发送失败恢复草稿且不残留乐观消息。
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/new-task-switch-'))
const home = path.join(directory, 'home')
await fs.mkdir(home, { recursive: true })
const env = { ...process.env, YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_QA_DIRECTORY: directory,
YCODE_QA_REAL_OMP: process.env.YCODE_OMP_PATH || path.join(process.env.LOCALAPPDATA || '', 'Ygent', 'omp.exe'),
YCODE_OMP_HOME: home, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('NEW_TASK_SWITCH_')) process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 240000)
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.slice(-8000))
process.exitCode = code ? 1 : 0
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { createServer } = require('node:http')
const { pathToFileURL } = require('node:url')
const directory = process.env.YCODE_QA_DIRECTORY
app.disableHardwareAcceleration()
process.env.YCODE_OMP_PATH = process.env.YCODE_QA_REAL_OMP
app.setPath('home', path.join(directory, 'home'))
const checks = []
const snapshots = {}
const rendererErrors = []
let main, server, phase = 'boot'
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const read = (source, win = main) => Promise.race([win.webContents.executeJavaScript(source),
new Promise((_, reject) => setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 20000))])
async function wait(source, label) {
for (let index = 0; index < 400; index++) { const value = await read(source); if (value) return value; await pause(40) }
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 fill(value) {
await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
if(!input)throw new Error('Missing composer input');
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,${JSON.stringify(value)});
input.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
}
const within = limit => value => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value < limit
;(async () => {
// 受控上游:首个响应刻意延迟,保证「真实回合」到达前有一段可观测的占位窗口。
server = createServer(async (req, res) => {
let raw = ''
for await (const part of req) raw += part
const body = JSON.parse(raw || '{}')
// 「停止验收」场景需要一个更长的回合,方便在运行中切走/切回再停止。
await pause(JSON.stringify(body).includes('停止验收') ? 5000 : 600)
const content = `LOCAL_REPLY_${body.model}`
if (!body.stream) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ choices: [{ message: { role: 'assistant', content } }] })); return }
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
const chunk = delta => `data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta, finish_reason: null }] })}\n\n`
res.write(chunk({ role: 'assistant', content }))
res.write(`data: ${JSON.stringify({ id: 'local-test', object: 'chat.completion.chunk', created: 1, model: body.model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 } })}\n\ndata: [DONE]\n\n`)
res.end()
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const baseUrl = `http://127.0.0.1:${server.address().port}/v1`
await fs.mkdir(process.env.YCODE_USER_DATA_DIR, { recursive: true })
app.setPath('userData', process.env.YCODE_USER_DATA_DIR)
app.setPath('sessionData', process.env.YCODE_USER_DATA_DIR)
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 120; index++) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(50)
}
assert.ok(main)
main.webContents.on('console-message', (_event, level, message) => { if (level >= 2) rendererErrors.push(message) })
await wait('!!window.ycode && !!document.querySelector(".chat-pane")', 'main renderer')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
await read('window.ycode.models.select("local-one")')
await step('home-surface')
await wait(`!!document.querySelector('.wb-home-composer') && !!document.querySelector('.wb-composer__input')`, 'home composer')
const sessionCountBefore = (await read('window.ycode.session.list()')).length
check('no session exists before the first send', sessionCountBefore, 0)
await step('send-new-task')
const message = 'QA 新任务发送验收'
await read(`(()=>{const marks={};const text=${JSON.stringify(message)};
const state=()=>({conversation:!!document.querySelector('.cr-message-list'),
user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(text)),
prepare:document.body.textContent.includes('正在启动对话运行环境…'),
duration:!!document.querySelector('.cr-streaming-duration'),
reply:document.body.textContent.includes('LOCAL_REPLY_model-one')});
const tick=()=>{const now=performance.now();const value=state();
if(!marks.conversation&&value.conversation)marks.conversation=now;
if(!marks.userMessage&&value.user)marks.userMessage=now;
if(!marks.prepareText&&value.prepare)marks.prepareText=now;
if(marks.prepareText&&!marks.prepareCleared&&!value.prepare)marks.prepareCleared=now;
if(!marks.duration&&value.duration)marks.duration=now;
if(!marks.reply&&value.reply)marks.reply=now;};
window.__qa={t0:0,marks};window.__qaTick=tick;
const observer=new MutationObserver(tick);observer.observe(document.documentElement,{subtree:true,childList:true,characterData:true});
tick();return true})()`)
await fill(message)
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send button enabled')
await read(`(()=>{window.__qa.t0=performance.now();document.querySelector('button[aria-label="发送"]').click();return true})()`)
await wait(`!!(window.__qa.marks.conversation&&window.__qa.marks.userMessage&&window.__qa.marks.prepareText)`, 'optimistic marks')
await pause(150)
snapshots.optimistic = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
return {t0:window.__qa.t0,marks:{...window.__qa.marks},inputValue:input?input.value:null,
userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
home:!!document.querySelector('.wb-home-composer'),conversation:!!document.querySelector('.cr-message-list'),
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration')}})()`)
const marks = snapshots.optimistic.marks
const t0 = snapshots.optimistic.t0
check('send switches to the conversation view immediately', marks.conversation - t0, within(500))
check('user message is visible immediately after send', marks.userMessage - t0, within(500))
check('startup placeholder appears immediately after send', marks.prepareText - t0, within(500))
check('streaming duration is running during the wait', marks.duration - t0, within(500))
check('composer draft is cleared at send time', snapshots.optimistic.inputValue, '')
check('home surface is left at send time', snapshots.optimistic.home, false)
check('optimistic user message is on screen', snapshots.optimistic.userBubbles.some(text => text.includes(message)), true)
check('startup placeholder text is the recovered acquiring_runtime string', snapshots.optimistic.prepare, true)
await step('real-turn')
const sessions = await read('window.ycode.session.list()')
const sessionId = sessions[0] && sessions[0].id
check('exactly one session was created for the send', sessions.length, 1)
const done = await wait(`window.ycode.session.messages(${JSON.stringify(sessionId)}).then(list=>{const last=list.at(-1);return last&&last.status==='done'&&last})`, 'turn finished')
check('local upstream answered the turn', done.content, 'LOCAL_REPLY_model-one')
await wait(`document.body.textContent.includes('LOCAL_REPLY_model-one')`, 'reply rendered')
await pause(250)
snapshots.finished = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
prepare:document.body.textContent.includes('正在启动对话运行环境…'),duration:!!document.querySelector('.cr-streaming-duration'),
reply:document.body.textContent.includes('LOCAL_REPLY_model-one'),marks:{...window.__qa.marks}}))()`)
const finalMarks = snapshots.finished.marks
check('startup placeholder is cleared before the reply arrives', finalMarks.prepareCleared !== undefined && finalMarks.prepareCleared <= (finalMarks.reply ?? Number.POSITIVE_INFINITY), true)
check('startup placeholder is gone after the turn', snapshots.finished.prepare, false)
check('streaming placeholder is replaced by the finished footer', snapshots.finished.duration, false)
check('exactly one user message after the turn (no optimistic duplicate)', snapshots.finished.userBubbles.length, 1)
check('reply is rendered in the conversation', snapshots.finished.reply, true)
check('real turn took longer than the optimistic render (placeholder was needed)', finalMarks.reply - finalMarks.userMessage,
value => typeof value === 'number' && value > 300)
snapshots.timings = { conversationMs: finalMarks.conversation - t0, userMessageMs: finalMarks.userMessage - t0,
prepareTextMs: finalMarks.prepareText - t0, durationMs: finalMarks.duration - t0,
prepareClearedMs: finalMarks.prepareCleared === undefined ? null : finalMarks.prepareCleared - t0,
replyMs: finalMarks.reply === undefined ? null : finalMarks.reply - t0 }
await step('send-failure')
// 清空模型注册表:发送会在 chat:send 的模型解析阶段被拒绝(早于任何回合/网络请求),
// 这是「发送失败」路径;同时避免误连外部网关。
const removed = await read(`window.ycode.models.list()`)
for (const model of removed.models) await read(`window.ycode.models.remove(${JSON.stringify(model.id)})`)
const registryAfterRemoval = await read(`window.ycode.models.list()`)
check('model registry is empty for the failure case', registryAfterRemoval.models.length, 0)
await read(`document.querySelector('[title="新建任务"]').click()`)
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface after new task')
const failureMessage = 'QA 发送失败草稿恢复'
await fill(failureMessage)
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled again')
await read(`document.querySelector('button[aria-label="发送"]').click()`)
// 发送失败的错误可能落在发送框(发送框自身状态)或会话区状态行(store error);
// 首条消息失败时布局会从对话页切回新建页,发送框重挂载,因此两处都接受。
await wait(`!!(document.querySelector('.wb-composer__error[role="alert"]')||document.querySelector('.msg__status--error'))`, 'send error surfaced')
await pause(250)
snapshots.failure = await read(`(()=>{const input=[...document.querySelectorAll('.wb-composer__input')].find(node=>node.offsetParent!==null);
const composerError=document.querySelector('.wb-composer__error');const statusError=document.querySelector('.msg__status--error');
return {inputValue:input?input.value:null,
userBubbles:document.querySelectorAll('.cr-self-bubble').length,
composerError:composerError?composerError.textContent:null,statusError:statusError?statusError.textContent:null,
prepare:document.body.textContent.includes('正在启动对话运行环境…'),home:!!document.querySelector('.wb-home-composer')}})()`)
check('failed send restores the draft text', snapshots.failure.inputValue, failureMessage)
check('failed send leaves no optimistic user message', snapshots.failure.userBubbles, 0)
check('failed send surfaces an error', snapshots.failure.composerError ?? snapshots.failure.statusError,
value => typeof value === 'string' && /模型/.test(value))
check('failed send leaves no startup placeholder', snapshots.failure.prepare, false)
check('failed send returns to the home surface', snapshots.failure.home, true)
await step('stop-and-switch')
await read(`window.ycode.models.save(${JSON.stringify({ id: 'local-one', name: 'Local One', providerId: 'custom',
model: 'model-one', baseUrl, apiKey: 'LOCAL_TEST_KEY', supportsToolCall: true, maxInputTokens: 64000, maxOutputTokens: 4096 })})`)
await read('window.ycode.models.select("local-one")')
const idsBeforeStopScenario = (await read('window.ycode.session.list()')).map(session => session.id)
await read(`document.querySelector('[title="新建任务"]').click()`)
await wait(`!!document.querySelector('.wb-home-composer')`, 'home surface for the stop scenario')
const stopMessage = 'QA 停止验收'
await fill(stopMessage)
await wait(`(()=>{const button=document.querySelector('button[aria-label="发送"]');return !!button&&!button.disabled})()`, 'send enabled for the stop scenario')
await read(`document.querySelector('button[aria-label="发送"]').click()`)
await wait(`document.body.textContent.includes('正在启动对话运行环境…')`, 'startup placeholder for the stop scenario')
const stopSessionId = (await read('window.ycode.session.list()')).map(session => session.id)
.find(id => !idsBeforeStopScenario.includes(id))
check('stop scenario created exactly one new session', typeof stopSessionId === 'string' && !!stopSessionId, true)
await wait(`!!document.querySelector('button[aria-label="停止"]')`, 'stop button available while running')
// 运行中切到新任务再切回:消息与运行状态不丢。
await read(`document.querySelector('[title="新建任务"]').click()`)
await wait(`!!document.querySelector('.wb-home-composer') && !document.querySelector('.cr-self-bubble')`, 'switched to a new task mid-turn')
await read(`(()=>{const row=[...document.querySelectorAll('[data-session-id]')].find(node=>node.getAttribute('data-session-id')===${JSON.stringify(stopSessionId)});if(row)row.click();return !!row})()`)
await wait(`[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)}))`, 'session restored after switching back')
snapshots.switchedBack = await read(`(()=>({user:[...document.querySelectorAll('.cr-self-bubble')].some(node=>node.textContent.includes(${JSON.stringify(stopMessage)})),
streaming:!!document.querySelector('.wb-cr-streaming-footer'),stop:!!document.querySelector('button[aria-label="停止"]')}))()`)
check('switching tasks mid-turn keeps the message and the running turn', snapshots.switchedBack.user && snapshots.switchedBack.streaming && snapshots.switchedBack.stop, true)
await read(`document.querySelector('button[aria-label="停止"]').click()`)
await wait(`window.ycode.session.messages(${JSON.stringify(stopSessionId)}).then(list=>list.at(-1)?.status==='cancelled')`, 'turn cancelled')
await pause(250)
snapshots.stopped = await read(`(()=>({userBubbles:[...document.querySelectorAll('.cr-self-bubble')].map(node=>node.textContent.trim()),
cancelled:!!document.querySelector('.cr-cancelled-indicator'),streaming:!!document.querySelector('.wb-cr-streaming-footer'),
prepare:document.body.textContent.includes('正在启动对话运行环境…')}))()`)
check('cancelled turn keeps exactly one user message', snapshots.stopped.userBubbles.length, 1)
check('cancelled turn shows the cancelled indicator', snapshots.stopped.cancelled, true)
check('cancelled turn leaves no streaming placeholder', snapshots.stopped.streaming, false)
check('cancelled turn leaves no startup placeholder', snapshots.stopped.prepare, false)
check('no renderer error logs', rendererErrors, [])
const report = { result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode App/preload/IPC/renderer with real OMP and a controlled local upstream; new-task send timing, startup placeholder, real-turn replacement and failure recovery measured in the live window.',
sourceEvidence: ['ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb', 'docs/对话启动提示文案-2026-10-16.md', '.tmp/todo08-wb-new-task/notes.md'],
snapshots, checks, rendererErrors }
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('NEW_TASK_SWITCH_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) console.log('NEW_TASK_SWITCH_FAILURE ' + JSON.stringify(failure))
server.close()
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)
server?.close()
app.exit(1)
})
}