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
+403 -21

No files matched your search

+34 -5
View File
@@ -220,12 +220,41 @@
### 08. 新建任务发送后的立即切页与占位 ### 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.<stage>` 表与显示逻辑见 `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. 账户菜单与页面 ### 09. 账户菜单与页面
@@ -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)
})
}
+15 -3
View File
@@ -221,10 +221,22 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings,
const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => { const submit = async ({ text, attachments, resources, quotes, mode }: ComposerSendPayload): Promise<void> => {
if ((!text && !attachments.length && !quotes.length) || running || sending) return if ((!text && !attachments.length && !quotes.length) || running || sending) return
followBottom.current = true followBottom.current = true
// 原版发送即清空输入框并立即切到对话页:先清空草稿,失败时再恢复(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 }) await send(text, { attachments, resources, quotes, skillIds: entry.skills?.map(item => item.id), permissionMode: mode })
const destination = draftMoves.current.get(draftKey) ?? draftKey // 会话创建后草稿所有权已迁移到会话 id;两处都清空,避免留下旧文本。
draftMoves.current.delete(draftKey) const destination = draftMoves.current.get(submitKey) ?? submitKey
setDrafts((all) => ({ ...all, [draftKey]: { text: '', attachments: [] }, [destination]: { text: '', attachments: [] } })) 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 对齐)。 */ /** 就地编辑:进入、取消、提交(提交走 send 的 action='edit' 分支,与原版 message.edit.submit 对齐)。 */
@@ -23,6 +23,7 @@ import type { InteractionResponse, MessageQuote, PlanContent } from '@shared/con
import MarkdownContent from './MarkdownContent' import MarkdownContent from './MarkdownContent'
import StreamingFooter from '../workbuddy/conversation/StreamingFooter' import StreamingFooter from '../workbuddy/conversation/StreamingFooter'
import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration' import StreamingTurnDuration from '../workbuddy/conversation/StreamingTurnDuration'
import { resolvePrepareText } from '../workbuddy/conversation/prepare-phases'
import { getMessageParts } from '@shared/message-parts' import { getMessageParts } from '@shared/message-parts'
import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection' import { projectWorkBuddyHistoryTool } from '@shared/workbuddy-history-projection'
import { MessageQuoteTag } from '../workbuddy/conversation/MessageQuoteTag' 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 return option ? onRespond?.(id, { kind: 'approval', optionId: option.optionId }) : undefined
}} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)} }} /> : interaction.answer && !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) ? <QuestionAnswerTranscript key={interaction.id} questionAnswer={interaction.answer} /> : null)}
{streaming && <StreamingFooter />} {streaming && <StreamingFooter prepareText={resolvePrepareText(message.prepareStage)} />}
{message.status === 'error' && message.error ? ( {message.status === 'error' && message.error ? (
<div className="msg__status msg__status--error">{message.error}</div> <div className="msg__status msg__status--error">{message.error}</div>
+44 -10
View File
@@ -8,10 +8,30 @@ import type { ThinkingLevel } from '@shared/workspaces'
import type { AgentMode } from '@shared/conversation' import type { AgentMode } from '@shared/conversation'
import { expertText, type ExpertActivation } from '@shared/capabilities' import { expertText, type ExpertActivation } from '@shared/capabilities'
import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer' import { ChatStreamCommitter, type ChatStreamKey } from './chat-stream-committer'
import { PENDING_START_STAGE } from '../workbuddy/conversation/prepare-phases'
interface SummonExpertOptions { newTask?: boolean; initPrompt?: string } interface SummonExpertOptions { newTask?: boolean; initPrompt?: string }
interface RunningState { runId: string; assistantId: string; sessionId?: string } interface RunningState { runId: string; assistantId: string; sessionId?: string }
/**
* 新任务发送后、主进程回合就绪前渲染的乐观占位(用户消息 + 助手占位)。
* id 以 local- 开头,只在渲染进程存在;真实消息到达或被替换/清理。
*/
function optimisticStartMessages(draftOwner: string, text: string, options: Omit<SendPayload, 'sessionId' | 'text'>): 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 { interface ChatState {
sessions: SessionMeta[] sessions: SessionMeta[]
currentSessionId: string | null currentSessionId: string | null
@@ -27,7 +47,7 @@ interface ChatState {
draftId: number draftId: number
init(): Promise<void> init(): Promise<void>
selectSession(id: string): Promise<void> selectSession(id: string): Promise<void>
newSession(context?: SessionContext): Promise<SessionMeta> newSession(context?: SessionContext, seedMessages?: ChatMessage[]): Promise<SessionMeta>
startTask(context?: SessionContext): void startTask(context?: SessionContext): void
selectModel(id: string): Promise<void> selectModel(id: string): Promise<void>
selectExpert(id: string | null): Promise<void> selectExpert(id: string | null): Promise<void>
@@ -225,14 +245,14 @@ export const useChatStore = create<ChatState>((set, get) => {
window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } })) window.dispatchEvent(new CustomEvent('ycode:expert-input', { detail: { owner: targetOwner, clear: true } }))
} }
}, },
async newSession(context) { async newSession(context, seedMessages: ChatMessage[] = []) {
const previousSessionId = get().currentSessionId const previousSessionId = get().currentSessionId
if (previousSessionId) streamCommitter.flushSession(previousSessionId) if (previousSessionId) streamCommitter.flushSession(previousSessionId)
const version = ++selectionVersion const version = ++selectionVersion
const created = await window.ycode.session.create(undefined, context) const created = await window.ycode.session.create(undefined, context)
set((state) => ({ sessions: [created, ...state.sessions], set((state) => ({ sessions: [created, ...state.sessions],
sessionMessages: { ...state.sessionMessages, [created.id]: [] }, sessionMessages: { ...state.sessionMessages, [created.id]: seedMessages },
...(version === selectionVersion ? { currentSessionId: created.id, messages: [], running: null, ...(version === selectionVersion ? { currentSessionId: created.id, messages: seedMessages, running: null,
sending: false, loading: false, error: null } : {}) })) sending: false, loading: false, error: null } : {}) }))
return created return created
}, },
@@ -286,13 +306,20 @@ export const useChatStore = create<ChatState>((set, get) => {
let sessionId = get().currentSessionId let sessionId = get().currentSessionId
if (!sessionId) { if (!sessionId) {
if (get().sending) return if (get().sending) return
// 原版在新任务发送后立即切到对话页:先渲染乐观的「用户消息 + 助手占位」,
// 等主进程建好会话与回合(可能包含运行环境启动)后再用真实消息替换(TODO 08)。
const draftOwner = `new-${get().draftId}` const draftOwner = `new-${get().draftId}`
set({ sending: true }) const optimistic = optimisticStartMessages(draftOwner, trimmed, options)
set({ sending: true, messages: optimistic })
try { 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 } })) 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 if (!sessionId || get().sendingSessions.includes(sessionId) || currentRun(get().runs, sessionId)) return
const owner = sessionId const owner = sessionId
@@ -306,7 +333,8 @@ export const useChatStore = create<ChatState>((set, get) => {
assistantDisplay: result.assistantDisplay, createdAt: Date.now(), assistantDisplay: result.assistantDisplay, createdAt: Date.now(),
turnStartedAt: result.userMessage.createdAt, status: 'streaming' } turnStartedAt: result.userMessage.createdAt, status: 'streaming' }
set((state) => { 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 messages = result.messages ?? [...history, result.userMessage, placeholder]
const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner } const running = { runId: result.runId, assistantId: result.assistantMessageId, sessionId: owner }
return { sessionMessages: { ...state.sessionMessages, [owner]: messages }, return { sessionMessages: { ...state.sessionMessages, [owner]: messages },
@@ -320,8 +348,14 @@ export const useChatStore = create<ChatState>((set, get) => {
void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined) void window.ycode.session.list().then((sessions) => set({ sessions })).catch(() => undefined)
} catch (error) { } catch (error) {
pendingEvents.delete(owner) 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 throw error
} }
}, },
@@ -97,9 +97,11 @@ function activeBrandName(): string {
export interface StreamingFooterProps { export interface StreamingFooterProps {
locale?: string locale?: string
/** 新任务启动阶段文案(原版 chat.prepare.<stage>);提供时替换「思考中」。 */
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 resolvedLocale = locale ?? activeLocale()
const brandName = activeBrandName() const brandName = activeBrandName()
const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale]) const tips = useMemo(() => getSourceLoadingTips(resolvedLocale, brandName), [brandName, resolvedLocale])
@@ -112,7 +114,7 @@ export function StreamingFooter({ locale }: StreamingFooterProps): React.JSX.Ele
isPaused: () => isPaused.current isPaused: () => isPaused.current
}), [tips]) }), [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 <div className="wb-cr-streaming-footer"> return <div className="wb-cr-streaming-footer">
<div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}> <div className="_loadingWithCredit_ttf8p_463" style={{ '--cb-assistant-inline-padding': '0px' } as CSSProperties}>
@@ -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.<stage>;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
}
+2
View File
@@ -43,6 +43,8 @@ export interface ChatMessage {
/** 本回合的终态 */ /** 本回合的终态 */
status?: 'streaming' | 'done' | 'error' | 'cancelled' status?: 'streaming' | 'done' | 'error' | 'cancelled'
error?: string error?: string
/** 渲染进程专用的启动占位阶段(新任务发送后、主进程回合就绪前);不参与持久化。 */
prepareStage?: string
} }
export interface ToolCallRecord { export interface ToolCallRecord {