diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index 0df61dc2..dbd869ec 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,18 @@ # 项目活动级联实施进度 +## 2026-09-14:菜单独立高度修正 + +生产菜单移除共用双栏表面,会话层独立定位到选中项目行。打开较长会话列表时,项目层的位置、 +宽度与高度保持不变;会话层独立限制窗口边界和滚动。两侧均无法容纳子菜单时使用同层返回交互。 + +`npx vitest run src/renderer/src/ProjectActivity.test.tsx` 通过 13 项,类型检查与 Lint 通过。 +`npm test` 运行 60 秒后超时,未取得完整结果。 + +最终源码的 Electron 43.2.0 / Chromium 150 组件 fixture 使用生产样式与中文 i18n、模拟活动数据, +深浅主题共 14 个场景、108 条检查通过,覆盖短/60 条会话、两层独立滚动、底部避让、向左展开、 +375px 窄屏及 640px 两侧空间不足。桌面项目层打开前后矩形一致,短窗口会话层底部保留 16px。 +此项验证实际组件排版和交互,不代表完整 Main/Preload 端到端验证;未修改远程执行路径。 + ## 2026-09-13:生产活动入口 对应 [FR-14](./prd.md#fr-14-项目活动汇总)、[US-E1 / US-E2](./user-stories.md#6-epic-e项目活动级联)。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index 2d70c450..f95c7714 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -392,10 +392,11 @@ resourceMonitor.getLocalSnapshot(input: { - `conversation-activity.ts` 从 `App.tsx` 的会话、`activeConversationIds`、全部可见 `assistantTasks` 和项目元数据派生会话行、全局及项目计数,不新增持久化活动副本。 - `ProjectActivity` 渲染常驻汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。 + `ProjectActivity` 在有活动时渲染汇总与项目、会话级联菜单,`ProjectSwitcher` 复用项目计数。 - `App` 向 `ProjectActivity` 传入项目元数据、活动行、侧栏可见状态与既有精确跳转回调。 菜单按项目 ID 分组并记忆化派生集合;排序仅影响展示,不改聚合状态或持久化数据。 -- 两层菜单在同一 body Portal 内相接。布局在打开、层级变化、实时数据更新、窗口缩放和 +- 两层菜单在同一 body Portal 内相接,桌面会话层使用独立 fixed 定位,不参与项目层高度计算。 + 项目层锚定摘要,会话层锚定选中项目行并独立限制窗口边界。布局在打开、层级变化、实时数据更新、窗口缩放和 滚动时按锚点重算;窄窗口隐藏项目面板并保留返回操作。菜单不调用 Modal 焦点隔离, 捕获阶段消费 Escape,防止触发窄侧栏的文档级监听;Tab 关闭后交还正常焦点顺序。 菜单关闭与焦点恢复在导航回调之前同步完成。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index 79a6ce39..bdf0536d 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -216,7 +216,8 @@ CPU 4% · 内存 620 MB · 桌面进程 7 项目名独占一行,状态数量排列在名称下方,避免窄侧栏中数量挤占名称宽度。 - 点击汇总在按钮下方打开项目菜单,只列有活动会话的项目,每项显示项目名和同口径计数。 桌面鼠标悬停或点击项目展开右侧会话菜单,右侧空间不足且左侧可容纳时向左展开。两个面板 - 相接,指针离开项目行进入会话层时不收起。展开项目不切换当前会话。 + 相接,各自拥有边框、圆角与阴影;会话层对齐所选项目行,底部空间不足时仅上移会话层, + 不改变项目层的位置或高度。指针离开项目行进入会话层时不收起。展开项目不切换当前会话。 - 会话层顶部显示项目名,每行显示完整可换行的会话标题与明确状态文字,待处理会话排在 运行中会话之前。项目行保留子菜单箭头,会话行不显示箭头。不展开 Task、Job 或 Run 列表。 - 点击会话先关闭菜单并恢复入口焦点,再请求进入该条会话;允许离开当前页面后, @@ -228,6 +229,6 @@ CPU 4% · 内存 620 MB · 桌面进程 7 Enter、Space 激活,右方向键进入会话层。左方向键或 Escape 返回项目层并恢复对应项目 焦点;顶层 Escape 关闭并恢复汇总焦点,不继续关闭窄侧栏。Tab 关闭菜单后继续正常焦点 顺序;点击外部、焦点离开、再次点击入口或隐藏侧栏也关闭菜单。 -- 单面板宽度为 `264px`,窗口小于 `620px` 时就地进入会话层并显示“返回项目列表”, +- 单面板宽度为 `264px`,窗口小于 `620px` 或项目菜单两侧均无法容纳子菜单时,就地进入会话层并显示“返回项目列表”, 触控无需悬停。浮层距窗口边缘至少 `16px`,每层最大高度为 `480px`,短窗口进一步限制 - 高度并独立滚动。活动全部结束时,已打开菜单显示“暂无运行中或待处理会话。”。 + 高度并独立滚动。活动全部结束时,关闭已打开菜单并隐藏摘要。 diff --git a/src/renderer/src/ProjectActivity.test.tsx b/src/renderer/src/ProjectActivity.test.tsx index 4b6d3a4c..8edd309c 100644 --- a/src/renderer/src/ProjectActivity.test.tsx +++ b/src/renderer/src/ProjectActivity.test.tsx @@ -185,7 +185,8 @@ describe('ProjectActivity', () => { const menu = screen.getByRole('menu', { name: 'All project activity' }) fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ })) expect(menu).toHaveAttribute('data-left', 'true') - expect(menu.style.left).toBe('336px') + expect(menu.style.left).toBe('600px') + expect(screen.getByRole('menu', { name: 'Remote' }).style.left).toBe('336px') expect(menu.style.top).toBe('136px') vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(400) fireEvent(window, new Event('resize')) @@ -196,6 +197,44 @@ describe('ProjectActivity', () => { expect(menu).toHaveAttribute('data-submenu', 'false') }) + it('clamps a tall submenu independently without moving or widening the project menu', () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1000) + vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(600) + render(view()) + const trigger = screen.getByRole('button', { name: /All project activity/ }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(24, 300, 240, 32)) + open() + const menu = screen.getByRole('menu', { name: 'All project activity' }) + vi.spyOn(menu, 'offsetHeight', 'get').mockReturnValue(200) + const remote = screen.getByRole('menuitem', { name: /Remote/ }) + vi.spyOn(remote, 'getBoundingClientRect').mockReturnValue(new DOMRect(32, 410, 248, 52)) + fireEvent.click(remote) + const sessions = screen.getByRole('menu', { name: 'Remote' }) + vi.spyOn(sessions, 'offsetHeight', 'get').mockReturnValue(100) + fireEvent(window, new Event('resize')) + expect(sessions.style.top).toBe('410px') + vi.spyOn(sessions, 'offsetHeight', 'get').mockReturnValue(480) + fireEvent(window, new Event('resize')) + expect(sessions.style.top).toBe('104px') + expect(sessions.style.left).toBe('288px') + expect(menu.style.top).toBe('336px') + expect(menu.style.left).toBe('24px') + expect(menu.style.width).toBe('264px') + }) + + it('drills down when neither side of the project menu can fit a submenu', () => { + vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(640) + render(view()) + const trigger = screen.getByRole('button', { name: /All project activity/ }) + vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue(new DOMRect(200, 100, 200, 32)) + open() + fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ })) + expect(screen.getByRole('menu', { name: 'All project activity' })).toHaveAttribute('data-compact', 'true') + const sessions = screen.getByRole('menu', { name: 'Remote' }) + expect(sessions.style.left).toBe('') + expect(sessions.style.top).toBe('') + }) + it('uses shared native-browser occlusion only where menus intersect the viewport', () => { render(view()) open() @@ -204,5 +243,9 @@ describe('ProjectActivity', () => { const host = screen.getByRole('button', { name: 'Destination' }) expect(isBrowserViewportOccluded(host, new DOMRect(200, 10, 500, 500))).toBe(true) expect(isBrowserViewportOccluded(host, new DOMRect(400, 10, 500, 500))).toBe(false) + fireEvent.click(screen.getByRole('menuitem', { name: /Remote/ })) + vi.spyOn(screen.getByRole('menu', { name: 'Remote' }), 'getBoundingClientRect') + .mockReturnValue(new DOMRect(274, 60, 264, 480)) + expect(isBrowserViewportOccluded(host, new DOMRect(400, 300, 500, 500))).toBe(true) }) }) diff --git a/src/renderer/src/ProjectActivity.tsx b/src/renderer/src/ProjectActivity.tsx index cb747472..d968de90 100644 --- a/src/renderer/src/ProjectActivity.tsx +++ b/src/renderer/src/ProjectActivity.tsx @@ -87,16 +87,29 @@ function ActivityMenu({ const menu = menuRef.current if (!menu || !anchorRef.current) return const rect = anchorRef.current.getBoundingClientRect() - const compact = window.innerWidth < 620 const panelWidth = Math.min(264, window.innerWidth - 32) const baseLeft = Math.max(16, Math.min(rect.left, window.innerWidth - panelWidth - 16)) + const compact = window.innerWidth < 620 || ( + baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft < panelWidth + 16 + ) const leftward = !compact && baseLeft + panelWidth * 2 > window.innerWidth - 16 && baseLeft >= panelWidth + 16 menu.dataset.compact = String(compact) menu.dataset.left = String(leftward) - const width = panelWidth * (active && !compact ? 2 : 1) - menu.style.width = `${width}px` - menu.style.left = `${Math.max(16, Math.min(baseLeft - (leftward && active ? panelWidth : 0), window.innerWidth - width - 16))}px` + menu.style.width = `${panelWidth}px` + menu.style.left = `${baseLeft}px` menu.style.top = `${Math.max(16, Math.min(rect.bottom + 4, window.innerHeight - menu.offsetHeight - 16))}px` + const sessions = sessionsRef.current + if (sessions) { + sessions.style.width = `${panelWidth}px` + if (compact) { + sessions.style.left = '' + sessions.style.top = '' + } else { + const row = projectRef.current?.querySelector('[aria-expanded="true"]') + sessions.style.left = `${baseLeft + (leftward ? -panelWidth : panelWidth)}px` + sessions.style.top = `${Math.max(16, Math.min(row?.getBoundingClientRect().top ?? rect.bottom + 4, window.innerHeight - sessions.offsetHeight - 16))}px` + } + } if (compact && active && projectRef.current?.contains(document.activeElement)) { sessionsRef.current?.querySelector('[role="menuitem"]')?.focus() } diff --git a/src/renderer/src/project-activity.css b/src/renderer/src/project-activity.css index e6d70b5b..1859e5d5 100644 --- a/src/renderer/src/project-activity.css +++ b/src/renderer/src/project-activity.css @@ -87,23 +87,22 @@ position: fixed; -webkit-app-region: no-drag; z-index: var(--z-dialog); - display: flex; - align-items: stretch; max-width: calc(100vw - 32px); - border: 1px solid var(--border-default); - border-radius: var(--radius-control); - background: var(--surface-raised); color: var(--text-primary); - box-shadow: var(--shadow-dialog); font-size: var(--font-body); } .project-activity__menu > div { - flex: 1; + box-sizing: border-box; + width: 100%; min-width: 0; max-height: min(480px, calc(100dvh - 32px)); overflow-y: auto; padding: var(--space-2); + border: 1px solid var(--border-default); + border-radius: var(--radius-control); + background: var(--surface-raised); + box-shadow: var(--shadow-dialog); } .project-activity__menu h3, @@ -114,13 +113,11 @@ overflow-wrap: anywhere; } -.project-activity__sessions { border-left: 1px solid var(--border-default); } -.project-activity__menu[data-left="true"] { flex-direction: row-reverse; } -.project-activity__menu[data-left="true"] .project-activity__sessions { border-left: 0; border-right: 1px solid var(--border-default); } +.project-activity__sessions { position: fixed; } .project-activity__back { display: none; } .project-activity__menu[data-compact="true"][data-submenu="true"] .project-activity__projects { display: none; } .project-activity__menu[data-compact="true"] .project-activity__back { display: flex; } -.project-activity__menu[data-compact="true"] .project-activity__sessions { border: 0; } +.project-activity__menu[data-compact="true"] .project-activity__sessions { position: static; } .project-activity__row[aria-expanded="true"] { background: var(--accent-subtle); border-color: var(--accent-selected); } .project-activity__identity {