diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index a2ed1b8c..f2d233f4 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -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% 缩放时仍可 操作。没有清单或收到有效空清单时整行隐藏,历史内容仍按消息归属查看。 diff --git a/docs/features/assistant-workbar/runtime-checklist-technical-design.md b/docs/features/assistant-workbar/runtime-checklist-technical-design.md index 42715f95..f717f1df 100644 --- a/docs/features/assistant-workbar/runtime-checklist-technical-design.md +++ b/docs/features/assistant-workbar/runtime-checklist-technical-design.md @@ -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 不推断进行中或优先级。 新轮先选择本轮消息,再读取清单,不回溯寻找旧轮非空清单。后台更新保持展开状态和焦点。 diff --git a/src/renderer/src/AnchoredMenu.tsx b/src/renderer/src/AnchoredMenu.tsx index 58a390f9..1413b02e 100644 --- a/src/renderer/src/AnchoredMenu.tsx +++ b/src/renderer/src/AnchoredMenu.tsx @@ -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 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('[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 + }, [anchorRef, role]) + return
{children}
} diff --git a/src/renderer/src/RuntimeChecklistStrip.test.tsx b/src/renderer/src/RuntimeChecklistStrip.test.tsx index f1197cbd..070e94fb 100644 --- a/src/renderer/src/RuntimeChecklistStrip.test.tsx +++ b/src/renderer/src/RuntimeChecklistStrip.test.tsx @@ -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() - 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() + 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() + expect(screen.queryByRole('group')).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/RuntimeChecklistStrip.tsx b/src/renderer/src/RuntimeChecklistStrip.tsx index ed05b56a..ca828385 100644 --- a/src/renderer/src/RuntimeChecklistStrip.tsx +++ b/src/renderer/src/RuntimeChecklistStrip.tsx @@ -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(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 (
- {summary}{result ? ` · ${result}` : ''} - + }
) } diff --git a/src/renderer/src/browser-viewport-occlusion.ts b/src/renderer/src/browser-viewport-occlusion.ts index e2b3b4e0..6e05e356 100644 --- a/src/renderer/src/browser-viewport-occlusion.ts +++ b/src/renderer/src/browser-viewport-occlusion.ts @@ -3,6 +3,7 @@ export const browserOverlaySelector = [ '[role="dialog"]', '[role="alertdialog"]', '[role="menu"]', + '.anchored-menu', '[role="tooltip"]', '.app-notification' ].join(', ') diff --git a/src/renderer/src/runtime-checklist.css b/src/renderer/src/runtime-checklist.css index cca872c9..56190111 100644 --- a/src/renderer/src/runtime-checklist.css +++ b/src/renderer/src/runtime-checklist.css @@ -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; diff --git a/src/renderer/validation/runtime-checklist-electron.mjs b/src/renderer/validation/runtime-checklist-electron.mjs index 331289b1..55c2c33f 100644 --- a/src/renderer/validation/runtime-checklist-electron.mjs +++ b/src/renderer/validation/runtime-checklist-electron.mjs @@ -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)