feat: show runtime checklists and support remote Continue

Display native OpenCode and Continue checklists alongside scheduled tasks, preserving request ownership, explicit clears and saved progress. Bundle both remote runtimes and support managed Continue installation, model routing, cancellation and restart continuation.

Validation: real local and Linux remote model checks, full Electron remote Continue flows, typecheck and lint passed. Full suite: 4707 passed, 67 skipped, one OpenCode compaction failure passed targeted rerun.

Release note: Display native execution checklists above conversations and support managed remote Continue projects with saved progress and cancellation recovery.
This commit is contained in:
mesalogo
2026-09-19 10:47:27 +08:00
parent 164e89253a
commit c7a93803c9
78 changed files with 2266 additions and 238 deletions
+1
View File
@@ -0,0 +1 @@
{"name":"runtime-checklist-visual-validation","version":"1.0.0","main":"runtime-checklist-electron.mjs","private":true}
@@ -0,0 +1,151 @@
import { app, BrowserWindow } from 'electron'
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import process from 'node:process'
import console from 'node:console'
import { setTimeout as wait } from 'node:timers/promises'
const evidence = process.env.CHECKLIST_VISUAL_EVIDENCE
const results = []
let window
const evaluate = script => window.webContents.executeJavaScript(script, true)
const click = selector => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`)
const check = async (name, script) => {
const passed = await evaluate(script)
results.push({ name, passed })
console.log(name, passed ? 'PASS' : 'FAIL')
}
async function key(keyCode, modifiers = []) {
if (keyCode === 'Enter') keyCode = 'Return'
window.focus()
window.webContents.focus()
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
if (keyCode === 'Return') window.webContents.sendInputEvent({ type: 'char', keyCode: '\r', modifiers })
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
await wait(100)
}
async function capture(name) {
await wait(150)
const measurements = await evaluate(`(() => {
const rect = selector => { const e = document.querySelector(selector); if (!e) return null; const r = e.getBoundingClientRect(); return { x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth } };
return { viewport: {width:innerWidth,height:innerHeight,dpr:devicePixelRatio}, body:rect('body'), top:rect('.conversation-context-strips'), task:rect('.conversation-task-strip__toggle'), checklist:rect('.runtime-checklist__toggle'), list:rect('.runtime-checklist__content'), chat:rect('.chat'), input:rect('input'), focus:document.activeElement?.className };
})()`)
const image = await window.webContents.capturePage()
writeFileSync(join(evidence, `${name}.png`), image.toPNG())
results.push({ name, ...measurements })
results.push({ name: `${name}-layout`, passed: measurements.body.scrollWidth <= measurements.body.clientWidth + 1 && measurements.input.bottom <= measurements.viewport.height + 1 && measurements.chat.height > 40 && (!measurements.checklist || measurements.checklist.bottom <= measurements.top.bottom + 1) && (!measurements.task || measurements.task.y >= measurements.top.y - 1) })
console.log(name, JSON.stringify(measurements))
}
app.whenReady().then(async () => {
mkdirSync(evidence, { recursive: true })
window = new BrowserWindow({ width: 1440, height: 1000, useContentSize: true, show: true, autoHideMenuBar: true, webPreferences: { contextIsolation: true, sandbox: true } })
window.webContents.on('console-message', event => console.log('renderer:', event.message))
await window.loadURL(process.env.CHECKLIST_VISUAL_URL)
for (let attempt = 0; attempt < 100; attempt++) {
if (await evaluate('Boolean(window.checklistHarness)')) break
await wait(100)
}
if (process.env.CHECKLIST_VISUAL_BASELINE === '1') {
await evaluate('document.fonts.ready.then(() => true)')
await click('.runtime-checklist__toggle')
for (const [name, width, height, zoom] of [
['wide-100', 1440, 1000, 1], ['narrow-100', 760, 800, 1],
['wide-200', 1440, 1000, 2], ['narrow-200', 760, 800, 2]
]) {
window.setContentSize(width, height)
window.focus()
await wait(300)
window.webContents.setZoomFactor(zoom)
await window.webContents.capturePage()
let viewportReady = false
for (let attempt = 0; attempt < 40; attempt++) {
await wait(100)
viewportReady = await evaluate(`Math.abs(innerWidth - ${width / zoom}) < 3 && Math.abs(innerHeight - ${height / zoom}) < 3`)
if (viewportReady) break
}
if (!viewportReady) throw new Error(`Viewport did not settle for ${name}: ${JSON.stringify(await evaluate('({width:innerWidth,height:innerHeight,dpr:devicePixelRatio})'))}; native=${window.getContentSize()}; zoom=${window.webContents.getZoomFactor()}`)
for (const index of [0, 1, 3, 4]) {
await evaluate(`document.querySelectorAll('.runtime-checklist__content li')[${index}].scrollIntoView({block:'start'})`)
await wait(100)
const measured = await evaluate(`(() => {
const item = document.querySelectorAll('.runtime-checklist__content li')[${index}];
const label = item.querySelector('.runtime-checklist__status > span');
const text = item.querySelector('.runtime-checklist__text');
const range = document.createRange();
const rect = r => ({top:r.top,bottom:r.bottom,height:r.height,left:r.left,right:r.right});
range.selectNodeContents(label);
const labelText = rect(range.getClientRects()[0]);
range.selectNodeContents(text);
const lines = Array.from(range.getClientRects()).filter(r => r.width > 0);
const firstLine = rect(lines[0]);
const labelSpan = rect(label.getBoundingClientRect());
return {status:label.textContent, content:text.textContent, viewport:{width:innerWidth,height:innerHeight,dpr:devicePixelRatio},
lineCount:new Set(lines.map(r => r.top)).size, labelSpan, labelText, firstLine,
spanTopDelta:labelSpan.top-firstLine.top,spanBottomDelta:labelSpan.bottom-firstLine.bottom,
textTopDelta:labelText.top-firstLine.top,textBottomDelta:labelText.bottom-firstLine.bottom};
})()`)
const passed = [measured.textTopDelta, measured.textBottomDelta, measured.spanTopDelta, measured.spanBottomDelta]
.every(delta => Math.abs(delta) < 0.1)
results.push({ name, index, zoom, passed, ...measured })
console.log('baseline', JSON.stringify({ name, index, passed, ...measured, content: undefined }))
if (index === 0 || index === 3) {
writeFileSync(join(evidence, `${name}-item-${index + 1}.png`), (await window.webContents.capturePage()).toPNG())
}
}
}
writeFileSync(join(evidence, 'baseline-results.json'), JSON.stringify({ environment: {
platform:process.platform,electron:process.versions.electron,chrome:process.versions.chrome,
path:'production renderer components in Electron harness; not full desktop IPC',units:'CSS pixels'
}, results }, null, 2))
app.exit(results.some(result => !result.passed) ? 1 : 0)
return
}
await capture('01-wide-collapsed')
await click('.conversation-task-strip__toggle')
await click('.runtime-checklist__toggle')
await capture('02-wide-both-expanded')
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
await key('Space')
await check('space-collapses-only-checklist', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'false' && document.querySelector('.conversation-task-strip__toggle').getAttribute('aria-expanded') === 'true'")
await key('Enter')
await check('enter-expands-checklist-keeps-focus', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'true' && document.activeElement.matches('.runtime-checklist__toggle')")
await evaluate("document.querySelector('input').focus(); window.checklistHarness.update()")
results.push({ name: 'update-keeps-input-focus', passed: await evaluate("document.activeElement === document.querySelector('input')") })
await evaluate("document.querySelector('.runtime-checklist__content').scrollTop = 100000")
await capture('03-long-list-end')
window.webContents.setZoomFactor(2)
await capture('03b-wide-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(760, 800)
await capture('04-narrow-both-expanded')
window.webContents.setZoomFactor(2)
await capture('05-narrow-200-percent')
await evaluate("document.querySelector('.runtime-checklist__toggle').focus()")
await key('Tab')
await check('tab-reaches-checklist-scroll-region', "document.activeElement.matches('.runtime-checklist__content')")
await key('End', ['control'])
await wait(300)
await check('keyboard-reaches-last-item-at-200-percent', "(() => {const list=document.querySelector('.runtime-checklist__content'); const last=list.querySelector('li:last-child').getBoundingClientRect(); const r=list.getBoundingClientRect(); return document.activeElement === list && last.bottom <= r.bottom + 1 && last.bottom > r.top;})()")
await evaluate("document.querySelector('.conversation-task-details__actions button').focus()")
await key('Enter')
await check('task-run-action-at-200-percent', 'window.checklistHarness.runs === 1')
await capture('05b-narrow-keyboard-task-action')
await evaluate("window.checklistHarness.theme('dark')")
await check('dark-theme-applied-to-root', "document.documentElement.dataset.theme === 'dark' && getComputedStyle(document.documentElement).getPropertyValue('--surface-raised').trim() !== '#ffffff'")
await capture('06-dark-narrow-200-percent')
window.webContents.setZoomFactor(1)
window.setContentSize(1440, 1000)
await evaluate("window.checklistHarness.scenario('next')")
results.push({ name: 'new-request-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await capture('07-new-request')
await evaluate("window.checklistHarness.scenario('stale')")
await check('stale-terminal-request-does-not-revive-old-checklist', "!document.querySelector('.runtime-checklist')")
await evaluate("window.checklistHarness.scenario('cleared')")
results.push({ name: 'empty-hides-old-checklist', passed: await evaluate("!document.querySelector('.runtime-checklist')") })
await evaluate("window.checklistHarness.scenario('history')")
await click('.runtime-checklist__toggle')
await capture('08-cancelled-history')
await evaluate("window.checklistHarness.scenario('remote')")
await capture('09-remote-without-task')
writeFileSync(join(evidence, 'results.json'), JSON.stringify({ environment: { platform:process.platform, electron:process.versions.electron, chrome:process.versions.chrome, node:process.versions.node, path:'component harness, not full desktop IPC path', sandbox:true, contextIsolation:true, gpu:'default', checklistItems:80 }, results }, null, 2))
app.exit(results.some(result => result.passed === false) ? 1 : 0)
}).catch(error => { console.error(error); app.exit(1) })
@@ -0,0 +1,20 @@
import { createServer } from 'vite'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
import process from 'node:process'
import console from 'node:console'
const require = createRequire(import.meta.url)
const server = await createServer({ configFile: false, root: process.cwd(), server: { host: '127.0.0.1', port: 0 }, esbuild: { jsx: 'automatic' } })
await server.listen()
const port = server.httpServer.address().port
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.CHECKLIST_VISUAL_URL = `http://127.0.0.1:${port}/src/renderer/validation/runtime-checklist.html`
env.CHECKLIST_VISUAL_EVIDENCE = resolve(process.argv[2])
env.CHECKLIST_VISUAL_BASELINE = process.argv.includes('--baseline') ? '1' : ''
const child = spawn(require('electron'), [resolve('src/renderer/validation'), `--user-data-dir=${resolve(process.argv[2], 'profile')}`], { env, stdio: ['ignore', 'pipe', 'pipe'] })
child.stdout.pipe(process.stdout)
child.stderr.pipe(process.stderr)
child.on('exit', async code => { console.log('Electron exit:', code); await server.close(); process.exit(code ?? 1) })
@@ -0,0 +1,3 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><title>Runtime checklist visual validation</title></head>
<body><div id="root"></div><script type="module" src="./runtime-checklist.tsx"></script></body></html>
@@ -0,0 +1,63 @@
import React, { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { flushSync } from 'react-dom'
import '../src/i18n'
import '../src/styles.css'
import { RuntimeChecklistStrip } from '../src/RuntimeChecklistStrip'
import { ConversationTaskStrip } from '../src/ConversationTaskStrip'
import type { Message } from '../src/ChatTimeline'
const original: Message = {
id: 'original', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
runtimeChecklist: { source: 'opencode', items: Array.from({ length: 80 }, (_, index) => ({
content: `${index + 1}. ${index === 1 ? '正在核对当前项目中的修改与既有任务,同时保留用户未提交的工作' : index === 79 ? 'LAST ITEM' : '检查生产组件布局、当前请求归属及历史状态'} ${index === 3 ? 'long_unbroken_path_'.repeat(35) : ''}`,
status: index === 0 ? 'completed' : index === 1 ? 'in_progress' : index === 4 ? 'cancelled' : 'pending',
priority: index % 3 === 0 ? 'high' : index % 3 === 1 ? 'medium' : 'low'
})) }
}
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
const task = {
id: 'task', title: '核对项目与构建验证', instructions: '保留用户修改', origin: 'schedule' as const,
status: 'idle' as const, createdAt: '2026-09-18T00:00:00Z'
}
function Harness() {
const [scenario, setScenario] = useState('live')
const [runs, setRuns] = useState(0)
const [update, setUpdate] = useState(0)
const [theme, setTheme] = useState('light')
const messages = scenario === 'stale' ? [{ ...original, state: 'complete' as const }, next]
: scenario === 'next' ? [original, next]
: scenario === 'cleared' ? [original, { ...next, runtimeChecklist: { source: 'opencode' as const, items: [] } }]
: scenario === 'history' || scenario === 'remote' ? [{ ...original, state: 'error' as const, status: '已取消' }]
: [{ ...original, runtimeChecklist: { ...original.runtimeChecklist!, items: original.runtimeChecklist!.items.map((item, index) => index === 2 && update ? { ...item, content: `更新 ${update}`, status: 'completed' as const } : item) } }]
Object.assign(window, { checklistHarness: {
scenario: (value: string) => flushSync(() => setScenario(value)),
update: () => flushSync(() => setUpdate(value => value + 1)),
theme: (value: string) => {
document.documentElement.dataset.theme = value
document.documentElement.style.colorScheme = value
flushSync(() => setTheme(value))
},
runs
} })
return <div data-theme={theme} style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: 'var(--surface-raised)', color: 'var(--text-primary)' }}>
<header style={{ flex: '0 0 auto', padding: '8px 16px', background: 'var(--surface-subtle)' }}>Renderer component harness · {scenario} · {theme}</header>
<main style={{ flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div className="chat-history-pane" style={{ flex: '1 1 auto', height: 'auto' }}>
<div className="conversation-context-strips">
{scenario !== 'remote' && <ConversationTaskStrip locale="zh-CN" tasks={[task]} schedules={[{
id: 'schedule', taskId: 'task', title: task.title, instructions: task.instructions, enabled: true,
recurrence: 'daily', nextRunAt: '2026-09-19T08:00:00Z', createdAt: task.createdAt, updatedAt: task.createdAt
}]} onSelectTask={() => undefined} onRunSchedule={async () => { setRuns(value => value + 1) }} onRemoveSchedule={async () => undefined} onSetScheduleEnabled={async () => undefined} />}
<RuntimeChecklistStrip messages={messages} activeMessageId={scenario === 'next' ? 'next' : scenario === 'stale' ? 'original' : undefined} />
</div>
<section className="chat"><div style={{ padding: '16px' }}>对话正文仍应可见。此页仅复用 renderer 组件,未连接 Main、IPC 或模型。</div></section>
</div>
<footer style={{ flex: '0 0 auto', padding: '16px', borderTop: '1px solid var(--border-default)' }}>
<label>消息输入 <input aria-label="消息输入" placeholder="输入区保持可见" style={{ width: '100%', minWidth: 0 }} /></label>
</footer>
</main>
</div>
}
createRoot(document.getElementById('root')!).render(<Harness />)