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:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(', ')
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user