fix(desktop): repair native browser preview flows

Fixes #819 and #879.

The mounted BrowserSurface now drives native preview navigation when a new browser target is opened for the same session, so the WebContentsView no longer stays on the first previewed file.

The preview-agent edit bubble now clamps to the viewport, prefers placing above low selected elements, and keeps its footer controls out of the scroll body.

Tested: cd desktop && bun run test -- --run src/components/browser/BrowserSurface.test.tsx src/stores/browserPanelStore.test.ts src/components/workspace/WorkspaceFileOpenWith.test.tsx src/components/chat/CurrentTurnChangeCard.test.tsx src/preview-agent/editBubble.test.ts

Tested: bun run check:desktop

Confidence: high

Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-22 23:22:52 +08:00
parent f36af8e17f
commit 10f4768c18
5 changed files with 141 additions and 32 deletions

File diff suppressed because one or more lines are too long

View File

@ -109,6 +109,29 @@ describe('BrowserSurface', () => {
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe('http://localhost:3000/')
})
it('navigates the mounted native preview when another browser target opens for the same session', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 200 }))
useBrowserPanelStore.getState().open('s1', 'http://127.0.0.1:3456/preview-fs/s1/first.md')
render(<BrowserSurface sessionId="s1" />)
await waitFor(() => {
expect(bridge.open).toHaveBeenCalledWith(
'http://127.0.0.1:3456/preview-fs/s1/first.md',
expect.objectContaining({ width: expect.any(Number) }),
)
})
act(() => {
useBrowserPanelStore.getState().open('s1', 'http://127.0.0.1:3456/preview-fs/s1/second.md')
})
await waitFor(() => {
expect(bridge.navigate).toHaveBeenCalledWith('http://127.0.0.1:3456/preview-fs/s1/second.md')
})
expect(useBrowserPanelStore.getState().bySession['s1']!.url).toBe(
'http://127.0.0.1:3456/preview-fs/s1/second.md',
)
})
it('closes the native webview on unmount', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
const { unmount } = render(<BrowserSurface sessionId="s1" />)

View File

@ -58,6 +58,8 @@ function resolveBrowserNavigationUrl(input: string, sessionId: string): string {
export function BrowserSurface({ sessionId }: { sessionId: string }) {
const hostRef = useRef<HTMLDivElement>(null)
const loadSeqRef = useRef(0)
const requestedUrlRef = useRef<string | null>(null)
const hasNativePreviewRef = useRef(false)
const session = useBrowserPanelStore((s) => s.bySession[sessionId])
const store = useBrowserPanelStore.getState()
const overlayCount = useOverlayStore((s) => s.count)
@ -82,20 +84,43 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
await action()
})().catch(() => {
if (loadSeqRef.current === seq) {
if (requestedUrlRef.current === url) {
requestedUrlRef.current = null
}
useBrowserPanelStore.getState().setLoading(sessionId, false)
}
})
}
const requestNativePreview = (url: string, options?: { force?: boolean }) => {
if (!url) return
if (!options?.force && requestedUrlRef.current === url) return
requestedUrlRef.current = url
loadNativePreview(url, async () => {
if (hasNativePreviewRef.current) {
await previewBridge.navigate(url)
return
}
const el = hostRef.current
hasNativePreviewRef.current = true
if (el) {
await previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect()))
} else {
await previewBridge.navigate(url)
}
})
}
useLayoutEffect(() => {
const el = hostRef.current
if (el && session?.url) {
const bounds = computeWebviewBounds(el.getBoundingClientRect())
const url = session.url
loadNativePreview(url, () => previewBridge.open(url, bounds))
if (session?.url) {
requestNativePreview(session.url)
}
return () => {
loadSeqRef.current += 1
requestedUrlRef.current = null
hasNativePreviewRef.current = false
previewBridge.close()
}
// The visibility-sync effect below owns setVisible() — including the
@ -103,6 +128,12 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId])
useEffect(() => {
if (!session?.url || !session.loading) return
requestNativePreview(session.url)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.url, session?.loading, sessionId])
// Visibility-sync: a fullscreen DOM overlay (e.g. ImageGalleryModal) would
// otherwise be partially covered by the native child webview, which always
// renders above the DOM. While overlayCount > 0 we hide the webview; when
@ -146,19 +177,8 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
const openOrNavigate = (inputUrl: string) => {
const url = resolveBrowserNavigationUrl(inputUrl, sessionId)
if (!url) return
const current = useBrowserPanelStore.getState().bySession[sessionId]
store.navigate(sessionId, url)
if (current?.url) {
loadNativePreview(url, () => previewBridge.navigate(url))
return
}
const el = hostRef.current
if (el) {
const bounds = computeWebviewBounds(el.getBoundingClientRect())
loadNativePreview(url, () => previewBridge.open(url, bounds))
} else {
loadNativePreview(url, () => previewBridge.navigate(url))
}
requestNativePreview(url)
}
const actionButtonClass = [
@ -214,18 +234,18 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
store.goBack(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
loadNativePreview(url, () => previewBridge.navigate(url))
requestNativePreview(url)
}}
onForward={() => {
store.goForward(sessionId)
store.setLoading(sessionId, true)
const url = useBrowserPanelStore.getState().bySession[sessionId]!.url
loadNativePreview(url, () => previewBridge.navigate(url))
requestNativePreview(url)
}}
onReload={() => {
if (!session.url) return
store.setLoading(sessionId, true)
loadNativePreview(session.url, () => previewBridge.navigate(session.url))
requestNativePreview(session.url, { force: true })
}}
rightActions={previewActions}
/>

View File

@ -26,6 +26,28 @@ describe('snapshotEditableStyles', () => {
})
describe('createEditBubble', () => {
it('positions controls inside the viewport when selecting a low element', () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 720 })
const el = document.getElementById('t')!
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
x: 420,
y: 650,
top: 650,
right: 500,
bottom: 670,
left: 420,
width: 80,
height: 20,
toJSON: () => ({}),
})
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })
expect(bubble.host.style.top).toBe('262px')
bubble.destroy()
})
it('prefills the text field and live-applies edits to the element', () => {
const el = document.getElementById('t')!
const bubble = createEditBubble(el, { onConfirm: vi.fn(), onCancel: vi.fn() })

View File

@ -4,6 +4,12 @@ export type EditableSnapshot = { text: string; color: string; background: string
export type EditBubbleChange = EditDiff & { description?: string }
type Deps = { onConfirm: (change: EditBubbleChange) => void; onCancel: () => void }
const VIEWPORT_MARGIN = 8
const BUBBLE_GAP = 8
const BUBBLE_WIDTH = 340
const BUBBLE_ESTIMATED_HEIGHT = 380
const BUBBLE_MIN_HEIGHT = 160
const FIELDS: Array<{ key: keyof EditableSnapshot; label: string }> = [
{ key: 'text', label: '文本' },
{ key: 'color', label: '文字颜色' },
@ -43,6 +49,35 @@ function buildPatch(key: keyof EditableSnapshot, value: string): EditInput {
return patch
}
function computeBubbleLayout(rect: DOMRect, contentHeight: number) {
const viewportWidth = Math.max(window.innerWidth || 0, BUBBLE_WIDTH + VIEWPORT_MARGIN * 2)
const viewportHeight = Math.max(window.innerHeight || 0, BUBBLE_MIN_HEIGHT + VIEWPORT_MARGIN * 2)
const desiredHeight = Math.min(
Math.max(Math.ceil(contentHeight) || BUBBLE_ESTIMATED_HEIGHT, BUBBLE_MIN_HEIGHT),
Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN * 2),
)
const belowTop = rect.bottom + BUBBLE_GAP
const spaceBelow = viewportHeight - VIEWPORT_MARGIN - belowTop
const spaceAbove = rect.top - BUBBLE_GAP - VIEWPORT_MARGIN
let top: number
if (spaceBelow >= desiredHeight) {
top = belowTop
} else if (spaceAbove >= desiredHeight) {
top = rect.top - BUBBLE_GAP - desiredHeight
} else if (spaceAbove > spaceBelow) {
top = VIEWPORT_MARGIN
} else {
top = Math.min(Math.max(belowTop, VIEWPORT_MARGIN), viewportHeight - VIEWPORT_MARGIN - BUBBLE_MIN_HEIGHT)
}
top = Math.max(VIEWPORT_MARGIN, Math.round(top))
const maxLeft = Math.max(VIEWPORT_MARGIN, viewportWidth - BUBBLE_WIDTH - VIEWPORT_MARGIN)
const left = Math.max(VIEWPORT_MARGIN, Math.min(Math.round(rect.left), maxLeft))
const maxHeight = Math.max(BUBBLE_MIN_HEIGHT, viewportHeight - VIEWPORT_MARGIN - top)
return { top, left, maxHeight }
}
export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLElement; destroy: () => void } {
const original = snapshotEditableStyles(el)
const current: EditableSnapshot = { ...original }
@ -50,25 +85,26 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
const host = document.createElement('div')
const rect = el.getBoundingClientRect()
const top = Math.max(8, Math.min(rect.bottom + 8, window.innerHeight - 380))
const left = Math.max(8, Math.min(rect.left, window.innerWidth - 356))
host.style.cssText = `position:fixed;top:${top}px;left:${left}px;z-index:2147483647`
host.style.cssText = 'position:fixed;top:0;left:0;z-index:2147483647;visibility:hidden'
const shadow = host.attachShadow({ mode: 'open' })
const wrap = document.createElement('div')
wrap.setAttribute('style', 'width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111')
wrap.setAttribute('style', 'width:340px;box-sizing:border-box;background:#fff;border-radius:14px;box-shadow:0 10px 34px rgba(0,0,0,.2);padding:12px;font:13px/1.45 -apple-system,system-ui,sans-serif;color:#111;display:flex;flex-direction:column;overflow:hidden')
const body = document.createElement('div')
body.setAttribute('style', 'min-height:0;overflow:auto')
const desc = document.createElement('input')
desc.setAttribute('data-field', 'description')
desc.placeholder = '描述这些更改…'
desc.setAttribute('style', 'width:100%;box-sizing:border-box;border:none;outline:none;font-size:14px;padding:6px 4px')
desc.addEventListener('input', () => { description = desc.value })
wrap.appendChild(desc)
body.appendChild(desc)
const tag = document.createElement('div')
tag.textContent = el.tagName.toLowerCase()
tag.setAttribute('style', 'color:#8a8a8a;border-top:1px solid #eee;margin-top:6px;padding:8px 4px 4px;font-weight:600')
wrap.appendChild(tag)
body.appendChild(tag)
for (const f of FIELDS) {
const row = document.createElement('label')
@ -87,10 +123,11 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
})
row.appendChild(lab)
row.appendChild(inp)
wrap.appendChild(row)
body.appendChild(row)
}
const footer = document.createElement('div')
footer.setAttribute('data-region', 'footer')
footer.setAttribute('style', 'display:flex;justify-content:space-between;align-items:center;margin-top:12px')
const cancelBtn = document.createElement('button')
cancelBtn.setAttribute('data-action', 'cancel')
@ -111,9 +148,16 @@ export function createEditBubble(el: HTMLElement, deps: Deps): { host: HTMLEleme
footer.appendChild(cancelBtn)
footer.appendChild(confirmBtn)
wrap.appendChild(body)
wrap.appendChild(footer)
shadow.appendChild(wrap)
document.documentElement.appendChild(host)
const measuredHeight = wrap.getBoundingClientRect().height || wrap.scrollHeight || BUBBLE_ESTIMATED_HEIGHT
const layout = computeBubbleLayout(rect, measuredHeight)
host.style.top = `${layout.top}px`
host.style.left = `${layout.left}px`
host.style.visibility = 'visible'
wrap.style.maxHeight = `${layout.maxHeight}px`
return { host, destroy: () => { host.remove() } }
}