fix: keep project activity submenu height independent

Opening long conversation lists stretched and repositioned the project menu. Give the conversation submenu its own surface and positioning, aligned to the selected project row with independent viewport clamping and scrolling. Use compact navigation when neither side has room.

Validation: 13 component tests, typecheck and lint passed; 108 Electron fixture checks passed across 14 light/dark scenarios. Full test run timed out after 60 seconds.

Release note: Project activity menus now keep the project list stable when opening long conversation submenus.
This commit is contained in:
mesalogo
2026-09-14 22:10:35 +08:00
parent de14a6157a
commit 6c1e8105dd
6 changed files with 89 additions and 21 deletions
+44 -1
View File
@@ -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)
})
})
+17 -4
View File
@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[role="menuitem"]')?.focus()
}
+8 -11
View File
@@ -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 {