fix: expand runtime checklists without shifting chat content

Render checklist details in an anchored floating panel with bounded scrolling, outside dismissal and Escape focus restoration. Reuse the shared overlay mechanism and update layout documentation and regression coverage.

Validation: 25 focused tests, Electron checklist layout and keyboard scenarios, and lint passed. Full-suite and typecheck blockers outside this change were reported separately.

Release note: Runtime execution checklists now expand in a floating panel without pushing conversation content down.
This commit is contained in:
mesalogo
2026-09-30 02:36:50 +08:00
parent 608a9a5fec
commit d30f794184
8 changed files with 79 additions and 27 deletions
+5 -3
View File
@@ -981,12 +981,14 @@ todo/plan,本次不为其增加工具。也不增加 plan 工作模式、计
布局遵循 [UI-DESIGN](../../../UI-DESIGN.md#610-应用顶栏与全局操作)。
默认折叠为一行“执行清单 · 已完成 m/n”,有进行中条目时附简短摘要;展开后按原生顺序
显示内容、状态及原生提供的优先级。行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
在摘要按钮旁的浮层显示内容、状态及原生提供的优先级,不推移或压缩下方消息流。
行内状态使用图标和“待处理 / 进行中 / 已完成 / 已取消”文字,
不用可勾选 Checkbox,也不根据位置推测优先级或进行中状态。请求结束后的摘要附真实
请求结果,例如“已取消”,不改变条目状态。后台更新不强制展开、不抢焦点。
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,折叠后焦点留在
按钮;实时区域只播报摘要变化。复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
展开按钮提供 `aria-expanded` 和 `aria-controls`,支持 Enter/Space,展开后焦点进入清单。
Escape 收起并返回按钮;点击外部或焦点移出也收起。实时区域只播报摘要变化。
复用全局颜色、间距和字体令牌,浅深主题均可读。窄窗口
摘要截短、展开内容换行并有界滚动,保留完成计数、请求结果与输入区;200% 缩放时仍可
操作。没有清单或收到有效空清单时整行隐藏,历史内容仍按消息归属查看。
@@ -110,7 +110,9 @@ No SQLite schema migration or separate checklist store is needed.
`ConversationTaskStrip` 与 `RuntimeChecklistStrip` 共用顶部容器,任务在上、清单在下,
各自展开和折叠。没有 Task 时清单仍可显示;远程会话也保留清单入口。总高度不超过
45vh,各自展开内容不超过 20vh 并内部滚动,标题不收缩。清单内容区可用键盘聚焦和滚动。
45vh,Task 展开内容不超过 20vh 并内部滚动,标题不收缩。清单详情复用 `AnchoredMenu`
的 `group` 语义和 `FloatingPortal`,宽度跟随摘要按钮并受视口约束,高度按可用空间限制,
内部可用键盘滚动。展开不改变顶部容器或消息流的高度;点击外部、焦点移出或 Escape 收起。
状态文字与条目首行按文字基线对齐,状态图标单独居中。CN 不推断进行中或优先级。
新轮先选择本轮消息,再读取清单,不回溯寻找旧轮非空清单。后台更新保持展开状态和焦点。
+11 -8
View File
@@ -3,11 +3,13 @@ import { flushSync } from 'react-dom'
import { FloatingPortal } from './FloatingPortal'
import './anchored-menu.css'
export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280, onClose, children }: {
export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280, role = 'menu', className = '', onClose, children }: {
anchorRef: RefObject<HTMLButtonElement | null>
id: string
label: string
width?: number
width?: number | 'anchor'
role?: 'menu' | 'group'
className?: string
onClose: () => void
children: ReactNode
}): React.JSX.Element {
@@ -23,12 +25,13 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
const width = viewport?.width ?? window.innerWidth
const height = viewport?.height ?? window.innerHeight
const rect = anchor.getBoundingClientRect()
const menuWidth = Math.max(0, Math.min(preferredWidth, width - 32))
const menuWidth = Math.max(0, Math.min(preferredWidth === 'anchor' ? rect.width : preferredWidth, width - 32))
menu.style.width = `${menuWidth}px`
const above = Math.max(0, Math.min(height - 32, rect.top - top - 24))
const below = Math.max(0, Math.min(height - 32, top + height - rect.bottom - 24))
const openBelow = menu.scrollHeight > above && below > above
menu.style.maxHeight = `${openBelow ? below : above}px`
const availableHeight = openBelow ? below : above
menu.style.maxHeight = `${role === 'group' ? Math.min(availableHeight, 400, height / 2) : availableHeight}px`
menu.style.left = `${Math.max(left + 16, Math.min(rect.left, left + width - menuWidth - 16))}px`
menu.style.top = `${Math.max(top + 16, openBelow ? rect.bottom + 8 : Math.min(rect.top - 8, top + height - 16) - menu.getBoundingClientRect().height)}px`
})
@@ -60,7 +63,7 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
anchor?.focus()
return
}
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
if (role !== 'menu' || !['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]:not(:disabled)'))
const index = items.indexOf(document.activeElement as HTMLElement)
@@ -90,7 +93,7 @@ export function AnchoredMenu({ anchorRef, id, label, width: preferredWidth = 280
window.visualViewport?.removeEventListener('scroll', reposition)
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
}
}, [anchorRef])
return <FloatingPortal anchorRef={anchorRef}><div ref={menuRef} id={id} role="menu" aria-label={label}
tabIndex={-1} className="anchored-menu">{children}</div></FloatingPortal>
}, [anchorRef, role])
return <FloatingPortal anchorRef={anchorRef}><div ref={menuRef} id={id} role={role} aria-label={label}
tabIndex={-1} className={`anchored-menu ${className}`}>{children}</div></FloatingPortal>
}
@@ -37,7 +37,9 @@ describe('RuntimeChecklistStrip', () => {
toggle.focus()
fireEvent.click(toggle)
expect(document.getElementById(toggle.getAttribute('aria-controls')!)).toBeVisible()
expect(screen.getByRole('group', { name: '执行清单' })).toHaveAttribute('tabindex', '0')
const panel = screen.getByRole('group', { name: '执行清单' })
expect(panel).toHaveFocus()
expect(screen.getByRole('region', { name: '执行清单' })).not.toContainElement(panel)
expect(screen.getByText('高优先级')).toBeVisible()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
const updated: Message = { ...message, state: 'error', status: '已取消', runtimeChecklist: {
@@ -46,7 +48,7 @@ describe('RuntimeChecklistStrip', () => {
]
} }
rerender(<RuntimeChecklistStrip messages={[updated]} />)
expect(toggle).toHaveFocus()
expect(panel).toHaveFocus()
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(toggle).toHaveTextContent('已完成 1/2')
expect(toggle).toHaveTextContent('已取消')
@@ -90,7 +92,7 @@ describe('RuntimeChecklistStrip', () => {
fireEvent.click(screen.getByRole('button', { name: 'Existing Task' }))
expect(select).toHaveBeenCalledWith('task')
fireEvent.click(taskToggle)
expect(within(screen.getByRole('region', { name: '执行清单' })).getByRole('list')).toBeVisible()
expect(within(screen.getByRole('group', { name: '执行清单' })).getByRole('list')).toBeVisible()
})
it('bounds combined height and long content while retaining wrapping and keyboard focus', () => {
@@ -98,9 +100,26 @@ describe('RuntimeChecklistStrip', () => {
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).toMatch(/\.runtime-checklist__content\s*\{[^}]*overflow: auto;/s)
expect(css).toContain('overflow-wrap: anywhere')
expect(css).toContain('flex-wrap: wrap')
expect(css).toContain(':focus-visible')
})
it('dismisses on Escape and outside interaction and leaves scrolling keys native', () => {
const { rerender } = render(<RuntimeChecklistStrip messages={[message]} />)
const toggle = screen.getByRole('button', { name: /执行清单/ })
fireEvent.click(toggle)
const panel = screen.getByRole('group', { name: '执行清单' })
expect(fireEvent.keyDown(panel, { key: 'End' })).toBe(true)
fireEvent.keyDown(panel, { key: 'Escape' })
expect(screen.queryByRole('group')).not.toBeInTheDocument()
expect(toggle).toHaveFocus()
fireEvent.click(toggle)
fireEvent.pointerDown(document.body)
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
rerender(<RuntimeChecklistStrip messages={[{ ...message, id: 'other-request' }]} />)
expect(screen.queryByRole('group')).not.toBeInTheDocument()
})
})
+7 -5
View File
@@ -1,8 +1,9 @@
import { CheckCircle2, ChevronDown, ChevronUp, Circle, CircleDashed, XCircle } from 'lucide-react'
import { useId, useState } from 'react'
import { useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Message } from './ChatTimeline'
import { isCancelledMessage } from './message-terminal-status'
import { AnchoredMenu } from './AnchoredMenu'
import './runtime-checklist.css'
const statusIcons = {
@@ -31,6 +32,7 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const contentId = useId()
const anchorRef = useRef<HTMLButtonElement>(null)
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')
@@ -41,7 +43,7 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
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"
<button ref={anchorRef} className="runtime-checklist__toggle" type="button"
aria-expanded={expanded} aria-controls={contentId}
onClick={() => setExpanded(value => !value)}>
<strong>{t('runtimeChecklist.title')}</strong>
@@ -51,8 +53,8 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
{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')}>
{expanded && <AnchoredMenu anchorRef={anchorRef} id={contentId} label={t('runtimeChecklist.title')}
role="group" width="anchor" className="runtime-checklist__content" onClose={() => setExpanded(false)}>
<ol>
{items.map((item, index) => {
const Icon = statusIcons[item.status]
@@ -65,7 +67,7 @@ function Checklist({ message }: { message: Message }): React.JSX.Element {
</li>
})}
</ol>
</div>
</AnchoredMenu>}
</section>
)
}
@@ -3,6 +3,7 @@ export const browserOverlaySelector = [
'[role="dialog"]',
'[role="alertdialog"]',
'[role="menu"]',
'.anchored-menu',
'[role="tooltip"]',
'.app-notification'
].join(', ')
+6 -1
View File
@@ -67,7 +67,12 @@
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 {
min-height: 0;
overflow: auto;
color: var(--text-secondary);
font-size: var(--font-caption);
}
.runtime-checklist__content ol { margin: 0; padding: var(--space-2); list-style: none; }
.runtime-checklist__content li {
display: flex;
@@ -24,7 +24,20 @@ async function key(keyCode, modifiers = []) {
await wait(100)
}
async function capture(name) {
await wait(150)
await window.webContents.capturePage()
for (let attempt = 0; attempt < 50; attempt++) {
const settled = await evaluate(`(() => {
const panel = document.querySelector('.runtime-checklist__content');
if (!panel) return true;
const rect = panel.getBoundingClientRect();
const anchor = document.querySelector('.runtime-checklist__toggle').getBoundingClientRect();
return rect.right <= innerWidth + 1 && rect.bottom <= innerHeight + 1
&& Math.abs(rect.width - Math.min(anchor.width, visualViewport.width - 32)) < 1;
})()`)
if (settled) break
if (attempt === 49) throw new Error(`Floating panel did not settle for ${name}`)
await wait(100)
}
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 };
@@ -101,15 +114,19 @@ app.whenReady().then(async () => {
}
await capture('01-wide-collapsed')
await click('.conversation-task-strip__toggle')
await evaluate("window.checklistChatRect = document.querySelector('.chat').getBoundingClientRect().toJSON()")
await click('.runtime-checklist__toggle')
await capture('02-wide-both-expanded')
await check('expanded-checklist-does-not-move-chat', "(() => { const rect = document.querySelector('.chat').getBoundingClientRect(); return rect.y === window.checklistChatRect.y && rect.height === window.checklistChatRect.height && getComputedStyle(document.querySelector('.runtime-checklist__content')).position === 'fixed'; })()")
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 check('enter-expands-checklist-focuses-panel', "document.querySelector('.runtime-checklist__toggle').getAttribute('aria-expanded') === 'true' && document.activeElement.matches('.runtime-checklist__content')")
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 check('outside-focus-closes-checklist', "!document.querySelector('.runtime-checklist__content')")
await click('.runtime-checklist__toggle')
await evaluate("document.querySelector('.runtime-checklist__content').scrollTop = 100000")
await capture('03-long-list-end')
window.webContents.setZoomFactor(2)
@@ -119,17 +136,18 @@ app.whenReady().then(async () => {
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 evaluate("document.querySelector('.runtime-checklist__content').focus()")
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 key('Escape')
await check('escape-closes-and-restores-toggle-focus', "!document.querySelector('.runtime-checklist__content') && document.activeElement.matches('.runtime-checklist__toggle')")
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 click('.runtime-checklist__toggle')
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)