diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 826d56c..ca9ead6 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -220,12 +220,41 @@ ### 08. 新建任务发送后的立即切页与占位 -- [ ] 盘点发送到创建会话、准备环境、接收首个事件之间的阻塞位置。 -- [ ] 对照原版发送后的页面切换时机与等待状态。 -- [ ] 发送后立即进入消息页面并显示用户消息;启动较慢时显示合适的助手占位/准备状态。 -- [ ] 验收:慢启动、正常启动、发送失败、停止、切换任务;不等到模型输出后才切页,不重复创建会话或丢失草稿。 +- [x] 盘点发送到创建会话、准备环境、接收首个事件之间的阻塞位置。 +- [x] 对照原版发送后的页面切换时机与等待状态。 +- [x] 发送后立即进入消息页面并显示用户消息;启动较慢时显示合适的助手占位/准备状态。 +- [x] 验收:慢启动、正常启动、发送失败、停止、切换任务;不等到模型输出后才切页,不重复创建会话或丢失草稿。 -状态:待 REA 对照与发送状态链路排查。 +状态:已实现并通过真实窗口回归(发送即切页 + 乐观用户消息 + 启动占位 + 真实回合替换 + 失败/停止/切换任务)。 + +阻塞位置盘点(YCode 现状): +- 渲染进程 `stores/chat.ts` 的 `send()` 在无会话时依次 `await session.create` → `await chat.send`,旧实现只有两步都完成后才把用户消息与助手占位写入 `messages`,因此「点了发送什么也不动」直到回合就绪。 +- 主进程 `chat:send` 在返回前要完成:附件/上下文准备、`createCapabilitySnapshot`、`getOmpKernel`(运行环境启动,本轮实测约 1.9–2.3 s)、`sessions.startTurn`;模型回合本身在后台推进,不属于返回前的阻塞。 + +原版证据: +- 启动阶段文案:REA `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`(WorkBuddy 5.8.2,`contexts-BMeWSUHk.js`,SHA-256 `5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b`):`chat.prepare.` 表与显示逻辑见 `docs/对话启动提示文案-2026-10-16.md`——阶段文案渲染在流式状态栏主文案位置,进入终态/失败/非 idle/MCP 进度后隐藏,其余回落「思考中」。本次占位使用 `acquiring_runtime` =「正在启动对话运行环境…」(用户确认沿用 5.8.2 已有内容,不重新采集)。 +- REA 采集限制(已核到实现层):visual probe 的 `text` 动作要求 enabled text/search `INPUT`,`key` 动作对可编辑内容上的 Enter/Space 直接拒绝(`.tmp/rea-css-runtime/dist/browser/CdpElectronVisualProbe.js`;schema `.tmp/rea-css-runtime/dist/domain/javascript/ElectronVisualProbeSchema.js`)。WorkBuddy 新建任务编辑器是 `DIV[role=textbox]._editable_1cxd2_1`,因此无法用 REA 驱动发送并抓取「发送后」逐帧等待态;本项不通过其它通道查看/驱动原版。三次采集(`ev_a492a83d…`、`ev_150e8217…`、`ev_5b13ec7d…`)与限制说明见 `.tmp/todo08-wb-new-task/notes.md`。 +- 观察范围:桌面端浅色主题、本机 5.8.3 运行时(文案表来自 5.8.2 已回收证据)。未覆盖:原版发送后逐帧顺序与各段时长。 + +实现: +- `stores/chat.ts`:无会话发送时先落地乐观的「用户消息 + 助手占位」(`local-user-*` / `local-assistant-*`,占位带 `prepareStage: acquiring_runtime` 与 `turnStartedAt`),随后才 `await session.create` / `chat.send`;`newSession(context, seedMessages)` 允许把乐观消息带进新会话;真实结果到达时先过滤 `local-*` 再追加真实用户消息与占位,避免重复;失败时移除乐观消息并写入 `error`。 +- `components/ChatPane.tsx`:`submit()` 在发送前清空输入框,失败时把草稿恢复到当前所有者键(会话创建后草稿已迁移到会话 id);消息非空即离开首页布局(`isHome = currentId === null || messages.length === 0`)。 +- `workbuddy/conversation/prepare-phases.ts`(新增)与 `StreamingFooter` 的 `prepareText`:按回收的 `chat.prepare` 表渲染「正在启动对话运行环境…」替换「思考中」,真实回合一到位即自动回落。 +- `shared/types.ts`:`ChatMessage.prepareStage`(仅渲染进程启动占位使用,不持久化)。 + +验证:新增 `scripts/verify-new-task-switch.cjs`(真实隔离 Electron + 真实 preload/IPC/渲染进程 + 真实 OMP + 受控本地上游)30 项断言全 PASS: +- 计时(点击发送为 t0):对话页、用户消息、启动占位、「已处理」计时均在 **+26 ms** 出现;启动占位在 **+1.9 s** 让位给真实回合,真实回复在 **+2.6 s** 渲染(另一次跑分 +23.8 ms / +2.27 s / +2.97 s)——占位确实覆盖了运行环境启动窗口。 +- 不重复:一次发送只创建 1 个会话;回合结束后 `.cr-self-bubble` 恰好 1 条(无乐观+真实重复);占位在真实回合出现前清除。 +- 发送失败(清空模型注册表后发送):草稿恢复为原文、无残留乐观消息、错误「请先添加并选择自定义模型」显示、回到新建页、无启动占位。 +- 停止与切换任务:回合运行中切到新任务再切回,消息与运行状态都在;点击停止后助手消息状态为 `cancelled`、取消指示可见、无残留占位、仍只有 1 条用户消息。 +- 回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-user-message-edit.cjs` 全部通过。 +- 报告:`.tmp/new-task-switch-641vdN/report.json`(另有 `.tmp/new-task-switch-TX5VSX/`)。 + +遗留: +1. 原版「发送后」逐帧等待态(先后顺序、每段时长)无 REA 运行时证据(探针限制见上),本项只对齐可证部分。 +2. 发送失败时仍会留下一个空会话(`session.create` 已成功、回合未开始);原版在准备失败时是否保留会话未采集。 +3. 启动阶段目前只使用 `acquiring_runtime` 一条;原版多阶段(expert → … → checking_launch_config)的发出方、顺序与时长未采集,不按字符串表臆造。 +4. 发送窗口内没有「停止」入口(`停止` 只在回合运行后出现,沿用既有实现);原版在准备阶段是否可直接取消未采集。 ### 09. 账户菜单与页面 diff --git a/scripts/rea/probe-new-task-send-20261017.json b/scripts/rea/probe-new-task-send-20261017.json new file mode 100644 index 0000000..a236ff5 --- /dev/null +++ b/scripts/rea/probe-new-task-send-20261017.json @@ -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 } + ] +} diff --git a/scripts/verify-new-task-switch.cjs b/scripts/verify-new-task-switch.cjs new file mode 100644 index 0000000..5a03fd6 --- /dev/null +++ b/scripts/verify-new-task-switch.cjs @@ -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.,渲染在流式状态栏主文案位置。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) + }) +} diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index 265a0e8..4ccbe91 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -221,10 +221,22 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise => { if ((!text && !attachments.length && !quotes.length) || running || sending) return followBottom.current = true - await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode }) - const destination = draftMoves.current.get(draftKey) ?? draftKey - draftMoves.current.delete(draftKey) - setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) + // 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(TODO 08)。 + const submitKey = draftKey + const restore = { ...entry, text, attachments, resources, quotes } + setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] } })) + try { + await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode }) + // 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。 + const destination = draftMoves.current.get(submitKey) ?? submitKey + draftMoves.current.delete(submitKey) + setDrafts((all) => ({ ...all, [submitKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) + } catch (error) { + const destination = draftMoves.current.get(submitKey) + draftMoves.current.delete(submitKey) + setDrafts((all) => ({ ...all, [submitKey]: restore, ...(destination && destination !== submitKey ? { [destination]: restore } : {}) })) + throw error + } } /** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */ diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index 1e4c5c7..78867be 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -23,6 +23,7 @@ import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/con import MarkdownContent from './MarkdownContent' import StreamingFooter from '../workbuddy/conversation/StreamingFooter' import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration' +import { resolvePrepareText } from '../workbuddy/conversation/prepare-phases' import { getMessageParts } from '@shared/message-parts' import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection' import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag' @@ -265,7 +266,7 @@ export default function MessageBubble({ message, onQuote, quotedSourceIds, darkM return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined }} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? : null)} - {streaming && } + {streaming && } {message.status === 'error' && message.error ? (
{message.error}
diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index f8d23b6..da806d9 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -8,10 +8,30 @@ import type { ThinkingLevel } from '@shared/workspaces' import type { AgentMode } from '@shared/conversation' import { expertText, type ExpertActivation } from '@shared/capabilities' import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer' +import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases' interface SummonExpertOptions { newTask?: boolean; initPrompt?: string } interface RunningState { runId: string; assistantId: string; sessionId?: string } + +/** + * 新任务发送后、主进程回合就绪前渲染的乐观占位(用户消息 + 助手占位)。 + * id 以 local- 开头,只在渲染进程存在;真实消息到达或被替换/清理。 + */ +function optimisticStartMessages(draftOwner: string, text: string, options: Omit): ChatMessage[] { + const startedAt = Date.now() + return [ + { id: `local-user-${draftOwner}`, role: 'user', content: text, createdAt: startedAt, status: 'done', + attachments: options.attachments, resources: options.resources, quotes: options.quotes }, + { id: `local-assistant-${draftOwner}`, role: 'assistant', content: '', tools: [], parts: [], + createdAt: startedAt, turnStartedAt: startedAt, status: 'streaming', prepareStage: PENDING_START_STAGE } + ] +} + +function isOptimisticStartMessage(message: ChatMessage): boolean { + return message.id.startsWith('local-user-') || message.id.startsWith('local-assistant-') +} + interface ChatState { sessions: SessionMeta[] currentSessionId: string | null @@ -27,7 +47,7 @@ interface ChatState { draftId: number init(): Promise selectSession(id: string): Promise - newSession(context?: SessionContext): Promise + newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise startTask(context?: SessionContext): void selectModel(id: string): Promise selectExpert(id: string | null): Promise @@ -225,14 +245,14 @@ export const useChatStore = create((set, get) => { window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } })) } }, - async newSession(context) { + async newSession(context, seedMessages: ChatMessage[] = []) { const previousSessionId = get().currentSessionId if (previousSessionId) streamCommitter.flushSession(previousSessionId) const version = ++selectionVersion const created = await window.ycode.session.create(undefined, context) set((state) => ({ sessions: [created, ...state.sessions], - sessionMessages: { ...state.sessionMessages, [created.id]: [] }, - ...(version === selectionVersion ? { currentSessionId: created.id, messages: [], running: null, + sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages }, + ...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null, sending: false, loading: false, error: null } : {}) })) return created }, @@ -286,13 +306,20 @@ export const useChatStore = create((set, get) => { let sessionId = get().currentSessionId if (!sessionId) { if (get().sending) return + // 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」, + // 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。 const draftOwner = `new-${get().draftId}` - set({ sending: true }) + const optimistic = optimisticStartMessages(draftOwner, trimmed, options) + set({ sending: true, messages: optimistic }) try { - sessionId = (await get().newSession({ ...get().draftContext })).id + sessionId = (await get().newSession({ ...get().draftContext }, optimistic)).id if (typeof window.dispatchEvent === 'function') window.dispatchEvent(new CustomEvent('ycode:composer-owner-migrated', { detail: { from: draftOwner, to: sessionId } })) } - catch (error) { set({ sending: false }); throw error } + catch (error) { + set((state) => ({ sending: false, messages: state.currentSessionId === null ? [] : state.messages, + error: error instanceof Error ? error.message : String(error) })) + throw error + } } if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return const owner = sessionId @@ -306,7 +333,8 @@ export const useChatStore = create((set, get) => { assistantDisplay: result.assistantDisplay, createdAt: Date.now(), turnStartedAt: result.userMessage.createdAt, status: 'streaming' } set((state) => { - const history = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [] + const history = (state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? []) + .filter((item) => !isOptimisticStartMessage(item)) const messages = result.messages ?? [...history, result.userMessage, placeholder] const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner } return { sessionMessages: { ...state.sessionMessages, [owner]: messages }, @@ -320,8 +348,14 @@ export const useChatStore = create((set, get) => { void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined) } catch (error) { pendingEvents.delete(owner) - set((state) => ({ sendingSessions: state.sendingSessions.filter((id) => id !== owner), - ...(state.currentSessionId === owner ? { sending: false, error: error instanceof Error ? error.message : String(error) } : {}) })) + // 首条消息发送失败:移除乐观占位(用户草稿由输入框恢复),不留下未持久化的消息。 + set((state) => { + const base = state.currentSessionId === owner ? state.messages : state.sessionMessages[owner] ?? [] + const cleaned = base.filter((item) => !isOptimisticStartMessage(item)) + return { sessionMessages: { ...state.sessionMessages, [owner]: cleaned }, + sendingSessions: state.sendingSessions.filter((id) => id !== owner), + ...(state.currentSessionId === owner ? { messages: cleaned, sending: false, error: error instanceof Error ? error.message : String(error) } : {}) } + }) throw error } }, diff --git a/src/renderer/src/workbuddy/conversation/StreamingFooter.tsx b/src/renderer/src/workbuddy/conversation/StreamingFooter.tsx index 0779118..05ef1c2 100644 --- a/src/renderer/src/workbuddy/conversation/StreamingFooter.tsx +++ b/src/renderer/src/workbuddy/conversation/StreamingFooter.tsx @@ -97,9 +97,11 @@ function activeBrandName(): string { export interface StreamingFooterProps { locale?: string + /** 新任务启动阶段文案(原版 chat.prepare.);提供时替换「思考中」。 */ + prepareText?: string | null } -export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Element { +export function StreamingFooter({ locale, prepareText }: StreamingFooterProps): React.JSX.Element { const resolvedLocale = locale ?? activeLocale() const brandName = activeBrandName() const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale]) @@ -112,7 +114,7 @@ export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Ele isPaused: () => isPaused.current }), [tips]) - const thinkingLabel = conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn') + const thinkingLabel = prepareText ?? conversationLabel('message.loading.thinking', {}, resolveLoadingTipLocale(resolvedLocale) === 'en' ? 'en' : 'zh-cn') return
diff --git a/src/renderer/src/workbuddy/conversation/prepare-phases.ts b/src/renderer/src/workbuddy/conversation/prepare-phases.ts new file mode 100644 index 0000000..f5808ad --- /dev/null +++ b/src/renderer/src/workbuddy/conversation/prepare-phases.ts @@ -0,0 +1,29 @@ +// Recovered from REA ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb +// (WorkBuddy 5.8.2 renderer/assets/contexts-BMeWSUHk.js, SHA-256 +// 5f35b7489b107e21ef452c8ee88a95265756f6fc444841eef869228bbc44768b); +// 文案表与显示逻辑见 docs/对话启动提示文案-2026-10-16.md。 +// 原版按会话 prepareStatus.stage 选择 chat.prepare.;YCode 的 chat:send 在 +// 主进程启动运行环境期间是单个阻塞阶段,因此发送后的占位只使用 acquiring_runtime, +// 不按字符串表臆造原版阶段顺序与持续时长。 +export const CONVERSATION_PREPARE_TEXT = { + expert: '正在准备专家能力…', + reading_conversation_settings: '正在读取对话设置…', + resolving_system_prompt: '正在生成系统提示词…', + configuring_model_and_tools: '正在配置模型与工具…', + resolving_skill_requirements: '正在整理技能配置…', + resolving_plugin_requirements: '正在整理插件配置…', + preparing_environment: '正在准备运行环境…', + mcp: '正在整理连接器配置…', + checking_launch_config: '正在检查启动配置…', + acquiring_runtime: '正在启动对话运行环境…' +} as const + +export type ConversationPrepareStage = keyof typeof CONVERSATION_PREPARE_TEXT + +/** 新任务发送后、主进程回合就绪前的占位阶段(对应运行环境启动)。 */ +export const PENDING_START_STAGE: ConversationPrepareStage = 'acquiring_runtime' + +export function resolvePrepareText(stage: string | null | undefined): string | null { + if (!stage) return null + return stage in CONVERSATION_PREPARE_TEXT ? CONVERSATION_PREPARE_TEXT[stage as ConversationPrepareStage] : null +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 96e0f75..1c6f01d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -43,6 +43,8 @@ export interface ChatMessage { /** 本回合的终态 */ status?: 'streaming' | 'done' | 'error' | 'cancelled' error?: string + /** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */ + prepareStage?: string } export interface ToolCallRecord {