fix(desktop): open-with menu viewport-flip + openPreview opens panel

真机验证发现「打开方式」点了没反应:OpenWithMenu 用 top:anchor.bottom+6 无视口翻转,
触发器贴在输入框上方时菜单渲染到输入框后面/视口外 → 改为测量后向上翻转+视口夹取+提高 z-index。
另外 openPreview 只加预览 tab 不开面板,从聊天/卡片触发时面板不显示 → openPreview 现在先 openPanel。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-29 19:58:01 +08:00
parent ca2de74f87
commit a8406fd903
4 changed files with 57 additions and 3 deletions

View File

@ -68,4 +68,18 @@ describe('OpenWithMenu', () => {
fireEvent.mouseDown(document.body)
expect(onClose).toHaveBeenCalledTimes(1)
})
it('flips above the anchor when it would overflow the viewport bottom', () => {
// The trigger often sits right above the composer; the menu must not render off-screen below it.
Object.defineProperty(window, 'innerHeight', { value: 300, configurable: true })
Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true })
const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 200, width: 220, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => ({}),
} as DOMRect)
// anchor near the bottom: top:260/bottom:270. Down would be 276, 276+200=476 > 300-8 ⇒ flip up.
render(<OpenWithMenu items={makeItems()} anchor={{ top: 260, bottom: 270, left: 20, right: 120 }} onClose={vi.fn()} />)
// flipped top = anchor.top - height - 6 = 260 - 200 - 6 = 54
expect(screen.getByRole('menu').style.top).toBe('54px')
rectSpy.mockRestore()
})
})

View File

@ -1,5 +1,5 @@
import { createPortal } from 'react-dom'
import { useEffect, useRef } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Globe, ExternalLink, FileText } from 'lucide-react'
import { TargetIcon } from './TargetIcon'
import type { OpenWithItem } from '../../lib/openWithItems'
@ -17,8 +17,35 @@ function ItemIcon({ item }: { item: OpenWithItem }) {
return <ExternalLink size={18} strokeWidth={1.9} />
}
const MARGIN = 8
export function OpenWithMenu({ items, anchor, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null)
// Initial guess; corrected (before paint) by the layout effect once we can measure the menu.
const [pos, setPos] = useState<{ top: number; left: number }>(() => ({
top: anchor.bottom + 6,
left: Math.max(MARGIN, Math.min(anchor.left, window.innerWidth - 240 - MARGIN)),
}))
// Position viewport-aware: flip above the anchor if it would overflow the bottom
// (the trigger often sits right above the composer), and clamp into the viewport.
useLayoutEffect(() => {
const el = ref.current
if (!el) return
const { height, width } = el.getBoundingClientRect()
const vh = window.innerHeight
const vw = window.innerWidth
let top = anchor.bottom + 6
if (height > 0 && top + height > vh - MARGIN) {
const flipped = anchor.top - height - 6
top = flipped >= MARGIN ? flipped : Math.max(MARGIN, vh - height - MARGIN)
}
let left = anchor.left
if (width > 0) left = Math.max(MARGIN, Math.min(left, vw - width - MARGIN))
setPos({ top, left })
}, [anchor])
useEffect(() => {
const onDown = (e: MouseEvent) => { if (!ref.current?.contains(e.target as Node)) onClose() }
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
@ -31,8 +58,8 @@ export function OpenWithMenu({ items, anchor, onClose }: Props) {
<div
ref={ref}
role="menu"
className="fixed z-50 min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
style={{ top: anchor.bottom + 6, left: Math.min(anchor.left, window.innerWidth - 240) }}
className="fixed min-w-[220px] overflow-hidden rounded-[12px] border border-[var(--color-border)] bg-[var(--color-surface)] py-1 shadow-[var(--shadow-dropdown)]"
style={{ top: pos.top, left: pos.left, zIndex: 1000 }}
>
{items.map((item) => (
<button

View File

@ -305,6 +305,15 @@ describe('workspacePanelStore', () => {
])
})
it('openPreview opens the workspace panel when it was closed', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok', path: 'src/a.ts', content: 'export const a = 1', language: 'typescript', size: 18,
})
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(false)
await useWorkspacePanelStore.getState().openPreview('session-closed-preview', 'src/a.ts', 'file')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(true)
})
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null

View File

@ -431,6 +431,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
},
openPreview: async (sessionId, path, kind) => {
// Ensure the workspace panel is visible — openPreview is now triggered from places
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
// not only from inside the already-open file tree.
get().openPanel(sessionId)
const tabId = getWorkspacePreviewTabId(path, kind)
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)