mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(desktop): open the composer + submenu level with its row
The side panel was bottom-aligned to the root menu, so Skills and Connectors both opened at the bottom. It now lines its title up with the row that opened it and only moves to stay inside the window.
This commit is contained in:
@@ -183,6 +183,43 @@ describe('ComposerCapabilityMenu', () => {
|
||||
expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill })
|
||||
})
|
||||
|
||||
it('opens the side panel level with its row and keeps it inside the window', () => {
|
||||
const rowTops: Record<string, number> = { Skills: 160, Connectors: 192 }
|
||||
let flyoutHeight = 120
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
const label = this.getAttribute('role') === 'option' ? Object.keys(rowTops).find(key => this.querySelector(`#${this.id}-label`)?.textContent === key) : undefined
|
||||
const top = label ? rowTops[label]! : this.className.includes('bottom-full') ? 100 : 0
|
||||
return { top, height: label ? 32 : 0, bottom: top + (label ? 32 : 0), left: 0, right: 0, width: 0, x: 0, y: top, toJSON: () => ({}) } as DOMRect
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'capability-flyout' ? flyoutHeight : 0
|
||||
})
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 })
|
||||
try {
|
||||
renderMenu()
|
||||
// Row top relative to the menu, title bar centred on the 32px row.
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
|
||||
expect(screen.getByTestId('capability-flyout').style.top).toBe('88px')
|
||||
|
||||
// Near the window bottom it lifts just enough to stay 8px clear.
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 300 })
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||
expect(screen.getByTestId('capability-flyout').style.top).toBe('56px')
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ }))
|
||||
expect(screen.getByTestId('capability-flyout').style.top).toBe('72px')
|
||||
|
||||
// A panel taller than the window keeps its top edge on screen.
|
||||
flyoutHeight = 400
|
||||
fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ }))
|
||||
expect(screen.getByTestId('capability-flyout').style.top).toBe('-92px')
|
||||
} finally {
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 768 })
|
||||
}
|
||||
})
|
||||
|
||||
it('walks into a category with the keyboard and steps back one panel per Escape', () => {
|
||||
const { onAction, onClose } = renderMenu()
|
||||
const input = searchInput()
|
||||
|
||||
@@ -35,6 +35,10 @@ type Props = {
|
||||
const ROOT_WIDTH = 288
|
||||
const FLYOUT_WIDTH = 320
|
||||
const PANEL_GAP = 4
|
||||
/** The side panel's title bar, centred on the row that opened it. */
|
||||
const FLYOUT_HEADER_HEIGHT = 40
|
||||
/** Space the side panel keeps from the window edges. */
|
||||
const VIEWPORT_MARGIN = 8
|
||||
/** The full skill list reuses the @ menu's reference browser. */
|
||||
const BROWSE_REFERENCES_KEY = 'skills:all'
|
||||
const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]'
|
||||
@@ -98,6 +102,8 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
||||
// categories in place, as the sheet does.
|
||||
const [narrow, setNarrow] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const flyoutRef = useRef<HTMLDivElement>(null)
|
||||
const [flyoutTop, setFlyoutTop] = useState(0)
|
||||
const referenceRef = useRef<ComposerReferenceMenuHandle>(null)
|
||||
useLayoutEffect(() => {
|
||||
if (sheet) return
|
||||
@@ -136,6 +142,32 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
||||
? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex)
|
||||
: rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex)
|
||||
const controlsId = showReferences ? referencesId : drillParent ? subListId : listId
|
||||
const flyoutAnchorIndex = flyoutOpen ? rootItems.findIndex(item => item.key === path[0]) : -1
|
||||
|
||||
// Like a desktop submenu, the side panel opens level with its row and only
|
||||
// moves off it when it would otherwise run past the window.
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
const flyout = flyoutRef.current
|
||||
if (!container || !flyout || flyoutAnchorIndex < 0) return
|
||||
const doc = container.ownerDocument
|
||||
const place = () => {
|
||||
const row = doc.getElementById(getCapabilityMenuOptionId(id, flyoutAnchorIndex))
|
||||
if (!row) return
|
||||
const containerTop = container.getBoundingClientRect().top
|
||||
const rowRect = row.getBoundingClientRect()
|
||||
const viewHeight = doc.defaultView?.innerHeight ?? Infinity
|
||||
const level = rowRect.top - containerTop + (rowRect.height - FLYOUT_HEADER_HEIGHT) / 2
|
||||
const lowest = viewHeight - VIEWPORT_MARGIN - flyout.offsetHeight - containerTop
|
||||
setFlyoutTop(Math.max(VIEWPORT_MARGIN - containerTop, Math.min(level, lowest)))
|
||||
}
|
||||
place()
|
||||
const View = doc.defaultView
|
||||
if (!View?.ResizeObserver) return
|
||||
const observer = new View.ResizeObserver(place)
|
||||
observer.observe(flyout)
|
||||
return () => observer.disconnect()
|
||||
}, [id, flyoutAnchorIndex, path.length, browseReferences])
|
||||
|
||||
const openCategory = (nextPath: string[]) => {
|
||||
setPath(nextPath)
|
||||
@@ -308,12 +340,12 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 flex items-end" style={{ gap: PANEL_GAP }} onMouseDown={event => event.preventDefault()}>
|
||||
<div className={`${PANEL} shrink-0 ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
|
||||
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2" onMouseDown={event => event.preventDefault()}>
|
||||
<div className={`${PANEL} ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
|
||||
{searchRow}
|
||||
{searching ? referenceMenu : rootList}
|
||||
</div>
|
||||
{flyoutOpen ? <div data-testid="capability-flyout" className={`${PANEL} shrink-0`} style={{ width: FLYOUT_WIDTH }}>
|
||||
{flyoutOpen ? <div ref={flyoutRef} data-testid="capability-flyout" className={`${PANEL} absolute`} style={{ left: ROOT_WIDTH + PANEL_GAP, top: flyoutTop, width: FLYOUT_WIDTH }}>
|
||||
<div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2">
|
||||
{path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
|
||||
<span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span>
|
||||
|
||||
Reference in New Issue
Block a user