diff --git a/desktop/src/components/browser/BrowserAddressBar.test.tsx b/desktop/src/components/browser/BrowserAddressBar.test.tsx index c82beed7..e6c6cc3a 100644 --- a/desktop/src/components/browser/BrowserAddressBar.test.tsx +++ b/desktop/src/components/browser/BrowserAddressBar.test.tsx @@ -34,4 +34,16 @@ describe('BrowserAddressBar', () => { fireEvent.click(screen.getByLabelText('后退')) expect(onBack).toHaveBeenCalled() }) + + it('shows the loading progress bar and a busy reload button while loading', () => { + render() + expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument() + expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'true') + }) + + it('hides the loading progress bar when not loading', () => { + render() + expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument() + expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false') + }) }) diff --git a/desktop/src/components/browser/BrowserAddressBar.tsx b/desktop/src/components/browser/BrowserAddressBar.tsx index 37776bc6..05b34095 100644 --- a/desktop/src/components/browser/BrowserAddressBar.tsx +++ b/desktop/src/components/browser/BrowserAddressBar.tsx @@ -1,25 +1,28 @@ import { useEffect, useState } from 'react' -import { ArrowLeft, ArrowRight, RotateCw } from 'lucide-react' +import { ArrowLeft, ArrowRight, Loader2, RotateCw } from 'lucide-react' type Props = { url: string canGoBack: boolean canGoForward: boolean + loading?: boolean onNavigate: (url: string) => void onBack: () => void onForward: () => void onReload: () => void } -export function BrowserAddressBar({ url, canGoBack, canGoForward, onNavigate, onBack, onForward, onReload }: Props) { +export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = false, onNavigate, onBack, onForward, onReload }: Props) { const [draft, setDraft] = useState(url) useEffect(() => { setDraft(url) }, [url]) return ( -
+
- +
{ e.preventDefault(); onNavigate(draft.trim()) }}>
+ {loading && ( +
+ )}
) } diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index ae4baa80..9d829608 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -61,4 +61,43 @@ describe('BrowserSurface', () => { expect(useBrowserPanelStore.getState().bySession['s1']!.pickerActive).toBe(false) expect(bridge.eval).toHaveBeenLastCalledWith(expect.stringContaining('exit-picker')) }) + + it('renders the loading indicator while the session is loading (open starts loading)', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument() + expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'true') + }) + + it('hides the loading indicator once the page is ready', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + useBrowserPanelStore.getState().setReady('s1') + render() + expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument() + expect(screen.getByLabelText('刷新')).toHaveAttribute('aria-busy', 'false') + }) + + it('reload flips the session back into loading and shows the indicator', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + useBrowserPanelStore.getState().setReady('s1') + render() + expect(screen.queryByTestId('browser-loading-bar')).not.toBeInTheDocument() + fireEvent.click(screen.getByLabelText('刷新')) + expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) + expect(bridge.navigate).toHaveBeenCalledWith('http://localhost:5173/') + expect(screen.getByTestId('browser-loading-bar')).toBeInTheDocument() + }) + + it('forces loading off after the timeout fallback elapses', () => { + vi.useFakeTimers() + try { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true) + vi.advanceTimersByTime(15000) + expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index ef55a1c9..0ab181cb 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -44,6 +44,17 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { useEffect(() => () => { previewBridge.setVisible(false) }, []) + // 兜底:navigated/ready 依赖注入脚本,若外站 CSP 拦截则永不回灌。loading 变 true 后 ~15s 强制收尾。 + const isLoading = session?.loading ?? false + const currentUrl = session?.url + useEffect(() => { + if (!isLoading) return + const timer = window.setTimeout(() => { + useBrowserPanelStore.getState().setLoading(sessionId, false) + }, 15000) + return () => window.clearTimeout(timer) + }, [isLoading, currentUrl, sessionId]) + if (!session) return null return ( @@ -52,10 +63,11 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { url={session.url} canGoBack={session.canGoBack} canGoForward={session.canGoForward} + loading={session.loading} onNavigate={(url) => { store.navigate(sessionId, url); previewBridge.navigate(url) }} - onBack={() => { store.goBack(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} - onForward={() => { store.goForward(sessionId); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} - onReload={() => previewBridge.navigate(session.url)} + onBack={() => { store.goBack(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} + onForward={() => { store.goForward(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }} + onReload={() => { store.setLoading(sessionId, true); previewBridge.navigate(session.url) }} />