feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
// Isolated real renderer with declared IPC snapshots; no model service or user data is used.
const fs = require('node:fs/promises')
const path = require('node:path')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/subagent-display-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const workspace = path.join(directory, 'workspace')
await Promise.all([data, home, workspace].map(folder => fs.mkdir(folder)))
const now = Date.now()
const sessionId = 'subagent-mock-preview'
const user = { id: 'mock-user', role: 'user', content: '并行检查一下当前项目的对话界面和子智能体调度。', createdAt: now - 46000, status: 'done' }
const session = { id: sessionId, title: '子智能体展示 · Mock', cwd: workspace, modelId: 'mock-model', createdAt: now - 46000, updatedAt: now, messages: [user] }
const other = { id: 'subagent-other-preview', title: '其他会话', cwd: workspace, modelId: 'mock-model', createdAt: now - 60000, updatedAt: now - 50000,
messages: [{ id: 'other-assistant', role: 'assistant', content: '这是另一个会话的历史记录。', status: 'done', createdAt: now - 50000 }] }
await fs.writeFile(path.join(data, 'sessions.json'), JSON.stringify({ sessions: [session, other] }))
await fs.writeFile(path.join(data, 'config.json'), JSON.stringify({ baseUrl: 'http://127.0.0.1:9/v1', model: 'gpt-6.1-sol', workspaceRoot: workspace, agentSettings: { teamManagement: true } }))
await fs.writeFile(path.join(data, 'models.json'), JSON.stringify({ version: 1, selectedModelId: 'mock-model', models: [{ id: 'mock-model', name: 'GPT-6.1 Sol', model: 'gpt-6.1-sol', providerId: 'custom', protocol: 'openai', baseUrl: 'http://127.0.0.1:9/v1', supportsToolCall: true, supportsReasoning: true, contextWindowTokens: 500000, maxOutputTokens: 128000, tokenBudgetVersion: 2 }] }))
await fs.writeFile(path.join(directory, 'fixture.json'), JSON.stringify({ now, session, user }))
const env = { ...process.env, YCODE_SUBAGENT_PREVIEW: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, YCODE_DISABLE_SYSTEM_INTEGRATION: '1', 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; process.stdout.write(bytes) })
child.stderr.on('data', bytes => { output += bytes })
const timer = setTimeout(() => child.kill(), 60000)
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(-5000))
process.exitCode = code ?? 1
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
const { pathToFileURL } = require('node:url')
const assert = require('node:assert/strict')
const directory = process.env.YCODE_SUBAGENT_PREVIEW
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.disableHardwareAcceleration()
BrowserWindow.prototype.show = function () {}
const pause = ms => new Promise(done => setTimeout(done, ms))
const errors = []
const checks = []
const check = (label, condition) => { assert.ok(condition, label); checks.push(label); console.log('DISPLAY_CHECK ' + label) }
const until = async (operation, label) => {
for (let index = 0; index < 300; index++) {
const value = await operation()
if (value) return value
await pause(50)
}
throw Error('Timeout: ' + label)
}
app.whenReady().then(async () => {
const { now, session, user } = JSON.parse(await fs.readFile(path.join(directory, 'fixture.json'), 'utf8'))
const common = { access: 'read-only', files: [], modelName: 'GPT-6.1 Sol', createdAt: now - 46000, tools: [] }
const agents = [
{ ...common, id: 'mock-agent-ui', name: '对话界面核对', task: '检查对话区域的字体、间距和工具事件展示,整理差异及对应文件。', status: 'running', startedAt: now - 33000,
events: [
{ id: 'mock-reasoning', kind: 'reasoning', text: '先检查消息渲染组件与样式,再核对事件折叠和模型信息的布局。' },
{ id: 'mock-read', kind: 'tool', name: 'read_file', status: 'ok', text: 'src/renderer/src/components/MessageBubble.tsx\n已读取消息渲染、事件折叠和子智能体卡片入口。' },
{ id: 'mock-read-style', kind: 'tool', name: 'read_file', status: 'running', text: '正在读取 src/renderer/src/components/subagent-card.css …' }
], usage: { inputTokens: 8600, outputTokens: 740, totalTokens: 9340 } },
{ ...common, id: 'mock-agent-scheduler', name: '调度逻辑检查', task: '核对 5 个并发名额、排队和取消的行为。', status: 'completed', startedAt: now - 44000, finishedAt: now - 12000,
events: [{ id: 'mock-check', kind: 'text', text: '已核对全局并发限制和任务清理逻辑。' }], result: '已核对:最多同时运行 5 个子智能体,超出上限进入队列;停止成员后释放名额。', usage: { inputTokens: 6200, outputTokens: 920, totalTokens: 7120 } },
{ ...common, id: 'mock-agent-summary', name: '验收清单整理', task: '等待空闲名额后整理本轮检查结果。', status: 'queued', events: [] }
]
agents[0].tools = [
{ id: 'mock-read', name: 'read_file', status: 'ok', args: { path: 'src/renderer/src/components/MessageBubble.tsx' }, argsRaw: '{"path":"src/renderer/src/components/MessageBubble.tsx"}', output: '消息渲染与事件折叠入口。' },
{ id: 'mock-read-style', name: 'read_file', status: 'running', args: { path: 'src/renderer/src/workbuddy/subagents/source.css' }, argsRaw: '{"path":"src/renderer/src/workbuddy/subagents/source.css"}' }
]
const assistant = { id: 'mock-assistant', role: 'assistant', content: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。', createdAt: now - 45000, turnStartedAt: now - 46000, status: 'streaming', requestId: 'mock-run', tools: [], parts: [{ type: 'text', text: '我已拆分检查任务,正在并行核对。可以展开输入框上方的成员入口查看各自进度。' }, ...agents.map(agent => ({ type: 'subagent', agent }))] }
const snapshot = { runId: 'mock-run', assistantMessageId: assistant.id, sessionId: session.id, sequence: 1, messages: [user, assistant] }
delete process.env.YCODE_DISABLE_GPU
delete process.env.YCODE_DISABLE_GPU_SANDBOX
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
const main = await until(() => BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode' && !window.webContents.isLoading()), 'main')
main.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) errors.push(event.message) })
ipcMain.removeHandler('chat:active-run')
let hydrationDelay = 0
ipcMain.handle('chat:active-run', async (_event, id) => { if (id === session.id && hydrationDelay) await pause(hydrationDelay); return id === session.id ? snapshot : null })
await main.loadFile(path.resolve('out/renderer/index.html'), { query: { taskId: session.id } })
const read = code => main.webContents.executeJavaScript(code)
main.setContentSize(1550, 970)
await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("3 位团队成员")'), 'mock input-area members')
check('one collapsed member entry lives above the input with no reply duplicates', await read('document.querySelectorAll(".composer__area .conversation-team-collaboration__trigger").length===1&&!document.querySelector(".chat-scroll .conversation-team-collaboration")&&!document.querySelector(".conversation-team-collaboration__member")&&!document.querySelector(".yc-subagent")'))
await read('document.fonts.ready.then(() => true)')
await pause(400)
const placement = await read('(() => { const area=document.querySelector("[data-testid=composer-task-activity]"), input=document.querySelector(".composer .wb-composer"); const a=area.getBoundingClientRect(), b=input.getBoundingClientRect(); return {above:a.bottom<=b.top+1,left:Math.abs(a.left-b.left),width:Math.abs(a.width-b.width),outsideTimeline:!area.closest(".chat-scroll"),owner:area.dataset.owner} })()')
check('member entry shares input width and stays outside the transcript scroller', placement.above && placement.left < 1 && placement.width < 1 && placement.outsideTimeline && placement.owner === session.id)
await fs.writeFile(path.join(directory, 'subagents-collapsed.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
await read('document.querySelector(".conversation-team-collaboration__trigger").click();true')
await until(() => read('document.querySelectorAll(".conversation-team-collaboration__member").length===3'), 'three members')
check('member list reflects running, queued and completed state without changing task identity', await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item=>item.dataset.status).join(",")==="running,queued,completed"&&document.querySelector(".conversation-team-collaboration__member[data-status=queued]").textContent.includes("任务三")'))
await read('document.querySelector(".conversation-team-collaboration__member[data-status=running]").click();true')
await until(() => read('!!document.querySelector(".conversation-member-detail")'), 'member detail')
check('selection opens in the right detail slot with the matching member identity', await read('document.querySelector("[data-view-id=detail-panel] .conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"&&document.querySelector(".conversation-team-collaboration__member[aria-current=true]")?.dataset.subagentId==="mock-agent-ui"'))
const themeMetrics = await read('({header:getComputedStyle(document.querySelector(".conversation-member-detail__header")).minHeight,space10:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-10"),space2:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-spacing-2"),background:getComputedStyle(document.querySelector(".conversation-member-detail")).getPropertyValue("--wb-bg-primary")})')
console.log('THEME_METRICS ' + JSON.stringify(themeMetrics))
check('detail inherits source theme spacing and surface tokens', Math.abs(parseFloat(themeMetrics.header) - parseFloat(themeMetrics.space10) - parseFloat(themeMetrics.space2)) < .1 && themeMetrics.background.trim().length > 0)
await read('document.querySelector(".conversation-member-detail__assignment-toggle").click();true')
check('assignment can expand independently from the child message timeline', await read('document.querySelector(".conversation-member-detail__assignment-detail").textContent.includes("检查对话区域")'))
await read('document.querySelector(".conversation-member-detail .cr-live-event-feed .cr-collapse__header")?.click();true')
await pause(700)
const bounds = await read('Array.from(document.querySelectorAll(".conversation-team-collaboration__member")).map(item => ({ status: item.dataset.status, text: item.innerText, y: item.getBoundingClientRect().y, bottom: item.getBoundingClientRect().bottom }))')
assert.equal(await read('document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")'), 'true')
await fs.writeFile(path.join(directory, 'subagents-expanded.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
await read('localStorage.setItem("ycode:theme","dark");window.dispatchEvent(new StorageEvent("storage",{key:"ycode:theme",newValue:"dark"}));true')
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.crTheme==="dark"'), 'dark member detail')
await pause(450)
check('child thinking block uses the dark conversation styles', await read('getComputedStyle(document.querySelector(".conversation-member-detail .cr-reasoning")).backgroundColor!=="rgb(255, 255, 255)"'))
await fs.writeFile(path.join(directory, 'subagents-dark.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
const updated = { ...agents[0], events: [...agents[0].events, { id: 'live-update', kind: 'text', text: '已完成消息组件核对,正在检查状态同步。' }] }
main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: updated } })
await until(() => read('document.querySelector(".conversation-member-detail").textContent.includes("已完成消息组件核对")'), 'live child text update')
assert.equal(await read('document.querySelectorAll(".conversation-team-collaboration__member").length'), 3)
check('new child text streams into the selected detail without duplicate members', true)
check('member detail follows the dark theme', await read('getComputedStyle(document.querySelector(".conversation-member-detail")).backgroundColor===getComputedStyle(document.querySelector(".chat-pane")).backgroundColor'))
main.setContentSize(760, 850)
await pause(700)
const overflow = await read('document.documentElement.scrollWidth > window.innerWidth')
assert.equal(overflow, false, 'narrow member view has no page overflow')
check('narrow view uses the existing right drawer with no page overflow', await read('!!document.querySelector(".app-shell__detail-slot--drawer .conversation-member-detail")') && !overflow)
await fs.writeFile(path.join(directory, 'subagents-narrow.png'), (await main.webContents.capturePage(undefined, { stayHidden: true, stayAwake: true })).toPNG())
main.setContentSize(1550, 970)
await pause(350)
await read('document.querySelector("[data-session-id=subagent-other-preview]").click();true')
await until(() => read('document.body.textContent.includes("这是另一个会话的历史记录")&&!document.querySelector(".conversation-member-detail")'), 'other conversation')
check('switching conversation never leaks the selected child or its input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")'))
hydrationDelay = 500
await read('document.querySelector("[data-session-id=subagent-mock-preview]").click();true')
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"'), 'restore selected member')
await pause(700)
check('selected member survives conversation rehydration', await read('document.querySelector(".conversation-member-detail")?.dataset.subagentId==="mock-agent-ui"'))
// Terminal replay and a later branch snapshot must both update an already-open detail.
main.webContents.send('chat:event', { runId: snapshot.runId, sessionId: session.id, sequence: 3, type: 'cancelled' })
await until(() => read('document.querySelector(".conversation-member-detail")?.dataset.status==="cancelled"'), 'cancel replay')
check('parent cancellation settles the open child and removes the stop action', await read('!document.querySelector(".yc-member-stop")'))
const nextAssistant = { id: 'next-assistant', role: 'assistant', content: '新一轮开始,尚未调度成员。', parts: [], tools: [], status: 'streaming', requestId: 'next-run', createdAt: now }
main.webContents.send('chat:run-started', { ...snapshot, runId: 'next-run', assistantMessageId: nextAssistant.id, sequence: 1,
messages: [user, { ...assistant, status: 'done' }, { ...user, id: 'next-user', createdAt: now }, nextAssistant] })
await until(() => read('document.body.textContent.includes("新一轮开始")&&!document.querySelector("[data-testid=composer-task-activity]")'), 'new turn clears previous team')
check('a new turn never displays the previous reply team in the input area', true)
main.webContents.send('chat:event', { runId: 'next-run', sessionId: session.id, sequence: 2, type: 'part', part: { type: 'subagent', agent: { ...agents[0], id: 'next-child', name: '新一轮成员' } } })
await until(() => read('document.querySelector(".conversation-team-collaboration__trigger")?.textContent.includes("1 位团队成员")'), 'new team appears')
check('new team starts collapsed and binds to its own assistant', await read('document.querySelector(".conversation-team-collaboration").dataset.assistantId==="next-assistant"&&document.querySelector(".conversation-team-collaboration__trigger").getAttribute("aria-expanded")==="false"'))
const branch = { ...snapshot, runId: 'mock-branch-run', sequence: 4, messages: [user, { ...assistant, parts: [{ type: 'text', text: '已回退到不含子任务的分支。' }], content: '已回退到不含子任务的分支。', status: 'done' }] }
main.webContents.send('chat:run-started', branch)
await until(() => read('document.body.textContent.includes("已回退到不含子任务的分支")&&!document.querySelector(".conversation-member-detail")'), 'branch cleanup')
check('a branch without the child closes stale detail and removes the input entry', await read('!document.querySelector("[data-testid=composer-task-activity]")'))
check('real renderer reports no errors', errors.length === 0)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, mock: true, rendererErrors: errors, placement, cards: bounds, screenshot: 'subagents-expanded.png' }, null, 2))
console.log('SUBAGENT_PREVIEW ' + path.join(directory, 'subagents-expanded.png'))
app.exit(0)
}).catch(async error => {
await fs.writeFile(path.join(directory, 'error.txt'), error.stack || String(error))
console.error(error)
app.exit(1)
})
}