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
+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)