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
+5 -1
View File
@@ -1245,7 +1245,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
expect(input).toHaveValue('/typed/path')
})
it('saves Execute in one request without extra confirmation checklists', async () => {
it.each(['opencode', 'continue'])('saves %s Execute in one request without extra confirmation checklists', async (provider) => {
const api = installRemoteApi()
const { onRemoteCommitted } = renderSwitcher()
fireEvent.click(screen.getByLabelText('新建项目'))
@@ -1264,6 +1264,9 @@ describe('ProjectSwitcher managed SSH projects', () => {
fireEvent.change(within(dialog).getByLabelText('远端工作目录'), {
target: { value: '/srv/project' }
})
fireEvent.change(within(dialog).getByRole('combobox', { name: '新对话默认 Runtime' }), {
target: { value: provider }
})
const modeGroup = within(dialog)
.getAllByRole('group', { name: '默认模式' })
.find((candidate) =>
@@ -1285,6 +1288,7 @@ describe('ProjectSwitcher managed SSH projects', () => {
intent: 'create',
draft: expect.objectContaining({
defaultWorkMode: 'execute',
runtimeSelection: { provider },
hostId,
remoteRootPath: '/srv/project'
})
+13 -6
View File
@@ -927,7 +927,7 @@ export function ProjectSwitcher({
name: draft.name,
description: draft.description,
runtimeSelection:
draft.runtimeSelection?.provider === 'opencode'
draft.runtimeSelection?.provider === 'opencode' || draft.runtimeSelection?.provider === 'continue'
? draft.runtimeSelection
: ({ provider: 'opencode' } as const),
hostId: remoteHostId,
@@ -1478,8 +1478,7 @@ export function ProjectSwitcher({
setDraft((current) => ({
...current,
runtimeSelection:
current.runtimeSelection?.provider ===
'opencode'
current.runtimeSelection?.provider === 'opencode' || current.runtimeSelection?.provider === 'continue'
? current.runtimeSelection
: { provider: 'opencode' }
}))
@@ -1707,19 +1706,27 @@ export function ProjectSwitcher({
{t('projectSwitcher.remote.rootHelp')}
</small>
</label>
<div className="remote-project-runtime">
<label className="remote-project-runtime">
<span>
{t(
'projectSwitcher.dialog.fields.defaultRuntime'
)}
</span>
<strong>OpenCode</strong>
<select aria-label={t('projectSwitcher.dialog.fields.defaultRuntime')}
disabled={remoteFieldsDisabled} value={draft.runtimeSelection?.provider ?? 'opencode'}
onChange={event => {
const provider = event.target.value === 'continue' ? 'continue' : 'opencode'
setDraft(current => ({ ...current, runtimeSelection: { provider } }))
}}>
<option value="opencode">OpenCode</option>
<option value="continue">Continue</option>
</select>
<small>
{t(
'projectSwitcher.remote.runtimeHelp'
)}
</small>
</div>
</label>
<ProjectWorkModeFields
ariaLabel={t(
'projectSwitcher.dialog.fields.defaultMode'
@@ -0,0 +1,95 @@
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { readFileSync } from 'node:fs'
import type { Message } from './ChatTimeline'
import { RuntimeChecklistStrip } from './RuntimeChecklistStrip'
import { ConversationTaskStrip } from './ConversationTaskStrip'
afterEach(cleanup)
const message: Message = {
id: 'request-message', role: 'assistant', content: '', createdAt: 1, state: 'streaming',
runtimeChecklist: { source: 'opencode', items: [
{ content: 'Inspect', status: 'completed' },
{ content: 'Implement', status: 'in_progress', priority: 'high' },
{ content: 'Verify', status: 'pending' },
{ content: 'Omitted', status: 'cancelled' }
] }
}
describe('RuntimeChecklistStrip', () => {
it('is read-only, preserves collapse and focus during full replacements, and retains terminal item states', () => {
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
const toggle = screen.getByRole('button', { name: /执行清单/ })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
toggle.focus()
fireEvent.click(toggle)
expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toBeVisible()
expect(screen.getByRole('group', { name: '执行清单' })).toHaveAttribute('tabindex', '0')
expect(screen.getByText('高优先级')).toBeVisible()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
const updated: Message = { ...message, state: 'error', status: '已取消', runtimeChecklist: {
source: 'continue', items: [
{ content: 'Duplicate', status: 'pending' }, { content: 'Duplicate', status: 'completed' }
]
} }
rerender(<RuntimeChecklistStrip messages={[updated]} />)
expect(toggle).toHaveFocus()
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(toggle).toHaveTextContent('已完成 1/2')
expect(toggle).toHaveTextContent('已取消')
expect(screen.getAllByText('Duplicate')).toHaveLength(2)
expect(screen.getByText('待处理')).toBeVisible()
fireEvent.click(toggle)
rerender(<RuntimeChecklistStrip messages={[{ ...updated, status: '失败' }]} />)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(toggle).toHaveFocus()
})
it('selects the current request before looking at data and hides explicit empty snapshots', () => {
const next: Message = { id: 'next', role: 'assistant', content: '', createdAt: 2, state: 'streaming' }
const { rerender } = render(<RuntimeChecklistStrip messages={[message, next]} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId="missing" />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[message, next]} activeMessageId={message.id} />)
expect(screen.getByRole('region', { name: '执行清单' })).toBeVisible()
rerender(<RuntimeChecklistStrip messages={[message, { ...next, runtimeChecklist: { source: 'opencode', items: [] } }]} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
rerender(<RuntimeChecklistStrip messages={[{ ...message, state: 'complete' }, next]} activeMessageId={message.id} />)
expect(screen.queryByRole('region')).not.toBeInTheDocument()
})
it('coexists with independent Task expansion and keeps the existing Task action', () => {
const select = vi.fn()
render(<div className="conversation-context-strips">
<ConversationTaskStrip locale="zh-CN" schedules={[]} tasks={[{
id: 'task', title: 'Existing Task', status: 'running', createdAt: new Date().toISOString(),
instructions: 'Work', origin: 'schedule'
}]} onSelectTask={select} onRemoveSchedule={vi.fn()} onRunSchedule={vi.fn()} onSetScheduleEnabled={vi.fn()} />
<RuntimeChecklistStrip messages={[message]} />
</div>)
const checklistToggle = screen.getByRole('button', { name: /执行清单/ })
const taskToggle = document.querySelector<HTMLButtonElement>('.conversation-task-strip__toggle')!
fireEvent.click(taskToggle)
expect(checklistToggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(checklistToggle)
expect(taskToggle).toHaveAttribute('aria-expanded', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Existing Task' }))
expect(select).toHaveBeenCalledWith('task')
fireEvent.click(taskToggle)
expect(within(screen.getByRole('region', { name: '执行清单' })).getByRole('list')).toBeVisible()
})
it('bounds combined height and long content while retaining wrapping and keyboard focus', () => {
const css = readFileSync('src/renderer/src/runtime-checklist.css', 'utf8')
expect(css).toMatch(/\.conversation-context-strips\s*\{[^}]*max-height: 45vh;[^}]*overflow: auto;/s)
expect(css).toMatch(/\.runtime-checklist__toggle\s*\{\s*flex: 0 0 auto;/s)
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*min-height: 0;/s)
expect(css).toMatch(/\.runtime-checklist__content\s*\{[^}]*max-height: 20vh;[^}]*overflow: auto;/s)
expect(css).toContain('overflow-wrap: anywhere')
expect(css).toContain('flex-wrap: wrap')
expect(css).toContain(':focus-visible')
})
})
@@ -0,0 +1,69 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import './runtime-checklist.css'
const statusIcons = {
pending: Circle,
in_progress: CircleDashed,
completed: CheckCircle2,
cancelled: XCircle
}
export function RuntimeChecklistStrip({ messages, activeMessageId }: {
messages: readonly Message[]
activeMessageId?: string
}): React.JSX.Element | null {
// Select the request first, never search backwards for a populated checklist.
const activeMessage = activeMessageId
? messages.find(candidate => candidate.id === activeMessageId)
: undefined
const message = activeMessageId && (!activeMessage || activeMessage.state === 'streaming')
? activeMessage
: messages.at(-1)
if (message?.role !== 'assistant' || !message.runtimeChecklist?.items.length) return null
return <Checklist key={message.id} message={message} />
}
function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const contentId = useId()
const items = message.runtimeChecklist!.items
const completed = items.reduce((count, item) => count + Number(item.status === 'completed'), 0)
const current = items.find(item => item.status === 'in_progress')
const result = message.state === 'streaming' ? undefined : message.status || t(
message.state === 'error' ? 'runtimeChecklist.failed' : 'runtimeChecklist.finished'
)
const summary = t('runtimeChecklist.count', { completed, total: items.length })
return (
<section className="runtime-checklist" aria-label={t('runtimeChecklist.title')}>
<button className="runtime-checklist__toggle" type="button"
aria-expanded={expanded} aria-controls={contentId}
onClick={() => setExpanded(value => !value)}>
<strong>{t('runtimeChecklist.title')}</strong>
<span>{summary}</span>
{result && <span className="runtime-checklist__result">{result}</span>}
{current && <span className="runtime-checklist__current" title={current.content}>{current.content}</span>}
{expanded ? <ChevronUp aria-hidden="true" size={14} /> : <ChevronDown aria-hidden="true" size={14} />}
</button>
<span className="sr-only" role="status" aria-live="polite">{summary}{result ? ` · ${result}` : ''}</span>
<div id={contentId} hidden={!expanded} className="runtime-checklist__content"
tabIndex={0} role="group" aria-label={t('runtimeChecklist.title')}>
<ol>
{items.map((item, index) => {
const Icon = statusIcons[item.status]
return <li key={index}>
<span className={`runtime-checklist__status runtime-checklist__status--${item.status}`}>
<Icon aria-hidden="true" size={14} /><span>{t(`runtimeChecklist.status.${item.status}`)}</span>
</span>
<span className="runtime-checklist__text">{item.content}</span>
{item.priority && <small>{t(`runtimeChecklist.priority.${item.priority}`)}</small>}
</li>
})}
</ol>
</div>
</section>
)
}
@@ -2,6 +2,12 @@ import type { TranslationShape } from '../../resource-types'
import type { app as chineseApp } from '../zh-CN/app'
export const app = {
runtimeChecklist: {
title: 'Execution checklist', count: '{{completed}}/{{total}} completed',
finished: 'Request completed', failed: 'Request failed',
status: { pending: 'Pending', in_progress: 'In progress', completed: 'Completed', cancelled: 'Cancelled' },
priority: { low: 'Low priority', medium: 'Medium priority', high: 'High priority' }
},
applications: {
alwaysShown: 'Always shown', optional: 'Optional',
manage: 'Manage Apps',
@@ -573,7 +573,7 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent packages',
description:
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode and Continue Runtimes adapted by GoodBuddy. Opening this page reads only the small signed catalog to show available updates and never downloads an Agent package automatically. Online actions use the source selected under About & Updates.',
loading: 'Verifying local Agent packages and checking online versions…',
refresh: 'Refresh package inventory',
import: 'Import offline package',
@@ -68,7 +68,7 @@ export const workspace = {
stages: {
network: 'Restoring network connection…',
agent: 'Restoring remote Agent…',
runtime: 'Restoring OpenCode Runtime…',
runtime: 'Restoring remote Runtime…',
cursor: 'Restoring conversation at event {{current}}',
completed: 'Recovery completed'
},
@@ -172,7 +172,7 @@ export const workspace = {
select: 'Select this directory'
},
runtimeHelp:
'Managed SSH projects use OpenCode Runtime only. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and Runtime.',
'Managed SSH projects can use an installed OpenCode or Continue Runtime. Ask is read-only; Execute can use every permission available to the selected SSH account. Saving checks the Host, Agent, workspace, and selected Runtime.',
actions: {
save: 'Save remote project',
saving: 'Saving remote project…'
@@ -186,7 +186,7 @@ export const workspace = {
host: 'SSH host',
agent: 'Remote Agent',
workspace: 'Remote workspace',
runtime: 'OpenCode Runtime',
runtime: 'Remote Runtime',
saving: 'Project settings'
},
validation: {
@@ -1,4 +1,10 @@
export const app = {
runtimeChecklist: {
title: '执行清单', count: '已完成 {{completed}}/{{total}}',
finished: '请求已完成', failed: '请求失败',
status: { pending: '待处理', in_progress: '进行中', completed: '已完成', cancelled: '已取消' },
priority: { low: '低优先级', medium: '中优先级', high: '高优先级' }
},
applications: {
alwaysShown: '始终显示', optional: '可选',
manage: '管理应用',
@@ -543,7 +543,7 @@ export const settingsSections = {
agentInventory: {
title: 'GoodBuddy Agent 包',
description:
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode、Continue Runtime。打开此页只读取小型签名目录并显示可用更新,不会自动下载 Agent 包;在线操作使用“关于与更新”中选择的更新源。',
loading: '正在校验本地 Agent 包并检查在线版本…',
refresh: '刷新包清单',
import: '导入离线包',
@@ -65,7 +65,7 @@ export const workspace = {
stages: {
network: '正在恢复网络连接…',
agent: '正在恢复远端 Agent…',
runtime: '正在恢复 OpenCode Runtime…',
runtime: '正在恢复远端 Runtime…',
cursor: '正在恢复对话,已同步到事件 {{current}}',
completed: '恢复完成'
},
@@ -167,7 +167,7 @@ export const workspace = {
select: '选择此目录'
},
runtimeHelp:
'托管 SSH 项目仅使用 OpenCode Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和 Runtime。',
'托管 SSH 项目可使用已安装的 OpenCode 或 Continue Runtime。Ask 为只读;Execute 可使用所选 SSH 账户拥有的全部权限。保存时会检查主机、远端 Agent、工作区和所选 Runtime。',
actions: {
save: '保存远程项目',
saving: '正在保存远程项目…'
@@ -181,7 +181,7 @@ export const workspace = {
host: 'SSH 主机',
agent: '远端 Agent',
workspace: '远端工作区',
runtime: 'OpenCode Runtime',
runtime: '远端 Runtime',
saving: '项目设置'
},
validation: {
+84
View File
@@ -0,0 +1,84 @@
.conversation-context-strips {
display: flex;
flex-direction: column;
flex: 0 0 auto;
min-width: 0;
max-height: 45vh;
overflow: auto;
scrollbar-width: thin;
}
.conversation-context-strips > .conversation-task-strip,
.conversation-context-strips > .runtime-checklist {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 46px;
}
.conversation-context-strips .conversation-task-strip__header,
.runtime-checklist__toggle {
flex: 0 0 auto;
}
.conversation-context-strips .conversation-task-strip__content {
flex: 0 1 auto;
min-height: 0;
max-height: 20vh;
overflow: auto;
}
.runtime-checklist {
min-width: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--surface-raised);
color: var(--text-secondary);
font-size: var(--font-caption);
padding: 0 max(var(--page-gutter), calc((100% - var(--content-reading)) / 2));
}
.runtime-checklist__toggle {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
width: 100%;
min-height: 46px;
padding: var(--space-2);
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.runtime-checklist__toggle:hover { background: var(--surface-subtle); }
.runtime-checklist__toggle:focus-visible,
.runtime-checklist__content:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.runtime-checklist__toggle strong { color: var(--text-primary); }
.runtime-checklist__toggle svg { flex: 0 0 auto; }
.runtime-checklist__current {
flex: 1 1 6em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-checklist__result { overflow-wrap: anywhere; }
.runtime-checklist__content { flex: 0 1 auto; min-height: 0; max-height: 20vh; overflow: auto; }
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
.runtime-checklist__content li {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: var(--space-2);
padding: var(--space-2) 0;
}
.runtime-checklist__text { flex: 1 1 12em; min-width: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.runtime-checklist__status { display: inline-flex; align-items: baseline; gap: var(--space-1); }
.runtime-checklist__status svg { align-self: center; flex: 0 0 auto; }
.runtime-checklist__status--completed { color: var(--success); }
.runtime-checklist__status--in_progress { color: var(--accent); }
.runtime-checklist__content small { font-size: inherit; }
+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 />)