diff --git a/config/scripts/happy-dom-animation-cancel.ts b/config/scripts/happy-dom-animation-cancel.ts new file mode 100644 index 00000000000..667f49e9614 --- /dev/null +++ b/config/scripts/happy-dom-animation-cancel.ts @@ -0,0 +1,23 @@ +// Why: the Web Animations spec marks the `finished` promise that cancel() rejects as handled, and +// Chromium does. happy-dom rejects it unhandled, so every cancelled animation fails the test run. + +const patchedPrototypes = new WeakSet() + +export function installHappyDomAnimationCancel(): boolean { + if (globalThis.Animation === undefined) { + return false + } + const prototype = globalThis.Animation.prototype + if (patchedPrototypes.has(prototype)) { + return true + } + const cancel = prototype.cancel + prototype.cancel = function cancelWithHandledFinish(this: Animation): void { + this.finished.catch(() => {}) + cancel.call(this) + } + patchedPrototypes.add(prototype) + return true +} + +installHappyDomAnimationCancel() diff --git a/config/scripts/vitest-dom-setup.ts b/config/scripts/vitest-dom-setup.ts index 738a876a21a..36138db7f7d 100644 --- a/config/scripts/vitest-dom-setup.ts +++ b/config/scripts/vitest-dom-setup.ts @@ -4,4 +4,5 @@ export {} if (globalThis.document !== undefined) { await import('./happy-dom-offscreen-canvas') await import('./happy-dom-mutation-observer-retention') + await import('./happy-dom-animation-cancel') } diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 6fec2a1ccf2..ef019209446 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -11,6 +11,7 @@ "./scripts/vitest-dom-setup.ts", "./scripts/happy-dom-offscreen-canvas.ts", "./scripts/happy-dom-mutation-observer-retention.ts", + "./scripts/happy-dom-animation-cancel.ts", "./scripts/vitest-node-runtime-pool.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", diff --git a/mobile/src/browser/MobileBrowserAddressField.test.tsx b/mobile/src/browser/MobileBrowserAddressField.test.tsx index a67ee2ea6ee..8b58cb0a638 100644 --- a/mobile/src/browser/MobileBrowserAddressField.test.tsx +++ b/mobile/src/browser/MobileBrowserAddressField.test.tsx @@ -7,6 +7,8 @@ import { MobileBrowserAddressField } from './MobileBrowserAddressField' // Hoisted with the mock factory, which runs before every module-level const in this file. const platform = vi.hoisted(() => ({ OS: 'ios' })) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ Platform: platform, StyleSheet: { @@ -26,6 +28,8 @@ function addressInput(os: string): Record { createElement(MobileBrowserAddressField, { disabled: false, focused: false, + loading: false, + loadFailed: false, onBlur: () => {}, onChangeText: () => {}, onFocus: () => {}, diff --git a/mobile/src/browser/MobileBrowserAddressField.tsx b/mobile/src/browser/MobileBrowserAddressField.tsx index 0e73a8ad0ea..1e1638fe1b4 100644 --- a/mobile/src/browser/MobileBrowserAddressField.tsx +++ b/mobile/src/browser/MobileBrowserAddressField.tsx @@ -2,10 +2,13 @@ import { Platform, StyleSheet, Text, TextInput, View } from 'react-native' import { colors, radii, spacing } from '../theme/mobile-theme' import { browserAddressFieldStyles } from './browser-address-field-styles' import { compactMobileBrowserFileAddress } from './browser-url' +import { MobileBrowserLoadProgress } from './MobileBrowserLoadProgress' type Props = { disabled: boolean focused: boolean + loading: boolean + loadFailed: boolean onBlur: () => void onChangeText: (value: string) => void onFocus: () => void @@ -16,6 +19,8 @@ type Props = { export function MobileBrowserAddressField({ disabled, focused, + loading, + loadFailed, onBlur, onChangeText, onFocus, @@ -60,6 +65,7 @@ export function MobileBrowserAddressField({ ) : null} + ) } diff --git a/mobile/src/browser/MobileBrowserLoadProgress.tsx b/mobile/src/browser/MobileBrowserLoadProgress.tsx new file mode 100644 index 00000000000..2d04c8128ee --- /dev/null +++ b/mobile/src/browser/MobileBrowserLoadProgress.tsx @@ -0,0 +1,134 @@ +import { useEffect, useRef } from 'react' +import { Animated, Easing, Platform, StyleSheet, View } from 'react-native' +import { useReducedMotionEnabled } from '../platform/use-reduced-motion' +import { colors, radii } from '../theme/mobile-theme' + +// Same curve as the desktop line (browser-load-progress.tsx): hidden for fast loads, then it eases +// toward 90% and the real stop finishes it. +const SHOW_DELAY_MS = 200 +const TRICKLE_STEPS: [atMs: number, scale: number][] = [ + [300, 0.15], + [2_000, 0.45], + [6_000, 0.7], + [15_000, 0.85], + [30_000, 0.9] +] +// Why: web has no native driver. +const useNativeDriver = Platform.OS !== 'web' + +function timing(value: Animated.Value, toValue: number, duration: number) { + return Animated.timing(value, { + toValue, + duration, + easing: Easing.out(Easing.quad), + isInteraction: false, + useNativeDriver + }) +} + +/** A thin line along the bottom edge of the address field while the desktop page loads. */ +export function MobileBrowserLoadProgress({ + loading, + failed +}: { + loading: boolean + failed: boolean +}): React.JSX.Element { + const scale = useRef(new Animated.Value(0)).current + const opacity = useRef(new Animated.Value(0)).current + const runningRef = useRef<{ + animation: Animated.CompositeAnimation + loading: boolean + reducedMotion: boolean + } | null>(null) + // Why: native stopAnimation answers asynchronously, so a newer load or unmount voids its callback. + const generationRef = useRef(0) + // Unknown counts as reduced, the same as LoadingSpinner. + const reducedMotion = useReducedMotionEnabled() !== false + + useEffect(() => { + const running = runningRef.current + if (loading) { + // Why: a navigation that replaces one in flight keeps loading true, so the line carries on. + if (running?.loading && running.reducedMotion === reducedMotion) { + return + } + generationRef.current += 1 + running?.animation.stop() + scale.setValue(reducedMotion ? 1 : 0) + opacity.setValue(0) + let previousMs = 0 + const trickle = TRICKLE_STEPS.map(([atMs, toScale]) => { + const step = timing(scale, toScale, atMs - previousMs) + previousMs = atMs + return step + }) + const animation = Animated.sequence([ + // A motion-preference switch mid-load swaps the animation without hiding it again. + Animated.delay(running?.loading ? 0 : SHOW_DELAY_MS), + reducedMotion + ? timing(opacity, 0.5, 150) + : Animated.parallel([timing(opacity, 1, 150), Animated.sequence(trickle)]) + ]) + runningRef.current = { animation, loading: true, reducedMotion } + animation.start() + return + } + if (!running?.loading) { + return + } + running.animation.stop() + runningRef.current = null + const generation = ++generationRef.current + opacity.stopAnimation((shownOpacity) => { + if (generation !== generationRef.current) { + return + } + // Still inside the show delay: the load was fast enough to show nothing. + if (shownOpacity === 0) { + scale.setValue(0) + return + } + // Why: a failed load fades where it stopped instead of filling up like a success. + const fadeOut = timing(opacity, 0, 270) + const animation = + failed || reducedMotion ? fadeOut : Animated.sequence([timing(scale, 1, 180), fadeOut]) + runningRef.current = { animation, loading: false, reducedMotion } + animation.start() + }) + }, [failed, loading, opacity, reducedMotion, scale]) + + useEffect( + () => () => { + generationRef.current += 1 + runningRef.current?.animation.stop() + }, + [] + ) + + return ( + + + + ) +} + +const styles = StyleSheet.create({ + clip: { + ...StyleSheet.absoluteFillObject, + borderRadius: radii.input, + overflow: 'hidden' + }, + line: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 2, + backgroundColor: colors.accentBlue, + transformOrigin: 'left' + } +}) diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx index 3ab40f1e444..17ff815ade4 100644 --- a/mobile/src/browser/MobileBrowserPane.tsx +++ b/mobile/src/browser/MobileBrowserPane.tsx @@ -1,6 +1,7 @@ /* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { AppState, type View } from 'react-native' +import type { BrowserLoadError } from '../../../src/shared/browser-workspace-types' import type { RpcClient } from '../transport/rpc-client' import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol' import type { MobileBrowserViewMode } from './browser-screencast-request' @@ -38,6 +39,7 @@ export type MobileBrowserTab = { browserPageId: string | null url: string loading: boolean + loadError?: BrowserLoadError | null canGoBack: boolean canGoForward: boolean isActive: boolean diff --git a/mobile/src/browser/MobileBrowserPaneView.tsx b/mobile/src/browser/MobileBrowserPaneView.tsx index 8b0eb89da7f..bf9d17f7f01 100644 --- a/mobile/src/browser/MobileBrowserPaneView.tsx +++ b/mobile/src/browser/MobileBrowserPaneView.tsx @@ -150,6 +150,8 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) { onSubmit={() => void navigateToAddress()} focused={addressFocused} disabled={controlsDisabled} + loading={tab.loading} + loadFailed={tab.loadError != null} /> ({ useBrowserBinaryScreencastGrant: () => true })) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', AppState: { diff --git a/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx b/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx index 19e3baff989..eac0d098635 100644 --- a/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx +++ b/mobile/src/browser/mobile-browser-dialog-through-the-bridge.test.tsx @@ -17,6 +17,8 @@ vi.mock('./use-browser-binary-screencast-grant', () => ({ useBrowserBinaryScreencastGrant: vi.fn(() => true) })) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, diff --git a/mobile/src/browser/mobile-browser-frameless-stream.test.tsx b/mobile/src/browser/mobile-browser-frameless-stream.test.tsx index 170450bae81..92ec614fc4c 100644 --- a/mobile/src/browser/mobile-browser-frameless-stream.test.tsx +++ b/mobile/src/browser/mobile-browser-frameless-stream.test.tsx @@ -16,6 +16,8 @@ vi.mock('./use-browser-binary-screencast-grant', () => ({ useBrowserBinaryScreencastGrant: vi.fn(() => true) })) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, diff --git a/mobile/src/browser/mobile-browser-pane-frame-layers.test.tsx b/mobile/src/browser/mobile-browser-pane-frame-layers.test.tsx index 367ec820ffa..e3f38f2ef2e 100644 --- a/mobile/src/browser/mobile-browser-pane-frame-layers.test.tsx +++ b/mobile/src/browser/mobile-browser-pane-frame-layers.test.tsx @@ -22,6 +22,8 @@ const appState = vi.hoisted(() => { return holder }) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', AppState: { diff --git a/mobile/src/browser/mobile-browser-pane-in-page-unmount.test.tsx b/mobile/src/browser/mobile-browser-pane-in-page-unmount.test.tsx index 03f3e27d587..39dd217274e 100644 --- a/mobile/src/browser/mobile-browser-pane-in-page-unmount.test.tsx +++ b/mobile/src/browser/mobile-browser-pane-in-page-unmount.test.tsx @@ -12,6 +12,8 @@ vi.mock('./use-browser-binary-screencast-grant', () => ({ useBrowserBinaryScreencastGrant: vi.fn(() => true) })) +vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null })) + vi.mock('react-native', () => ({ ActivityIndicator: 'ActivityIndicator', AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) }, diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx index f2fd2ad533b..a955c33c3f4 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.dead-guest.test.tsx @@ -40,6 +40,14 @@ function nullContentWindowFocus(): TypeError { return new TypeError("Cannot read properties of null (reading 'focus')") } +function pageNavigationStart(): Event { + return Object.assign(new Event('did-start-navigation'), { + isMainFrame: true, + isInPlace: false, + url: 'https://example.com/next' + }) +} + function page(overrides?: Partial): BrowserPage { return { id: 'page-a', @@ -223,11 +231,11 @@ describe('client-hosted browser pane over a dead guest', () => { }) act(() => { - webview.dispatchEvent(new Event('did-start-loading')) + webview.dispatchEvent(pageNavigationStart()) }) expect(screen.getByText('Client-hosted browser unavailable')).toBeTruthy() - // did-start-loading writes loading:true first; the loss must be the last word. + // The navigation start writes loading:true first; the loss must be the last word. expect(onUpdatePageState.mock.calls.at(-1)).toEqual(['page-a', { loading: false }]) }) @@ -237,7 +245,7 @@ describe('client-hosted browser pane over a dead guest', () => { act(() => webview.dispatchEvent(new Event('destroyed'))) onUpdatePageState.mockClear() - act(() => webview.dispatchEvent(new Event('did-start-loading'))) + act(() => webview.dispatchEvent(pageNavigationStart())) expect(onUpdatePageState).not.toHaveBeenCalled() expect(screen.getByText('Client-hosted browser unavailable')).toBeTruthy() diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index bb37b21fa20..144dc82117f 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -36,6 +36,7 @@ import { getBrowserPageZoomIndicatorState } from './host-guest/browser-page-zoom import { useBrowserPageWebviewShortcuts } from './host-guest/use-browser-page-webview-shortcuts' import { useClientHostedGuestActivationFocus } from './host-guest/use-client-hosted-guest-activation-focus' import { useBrowserPageZoomFeedback } from './host-guest/use-browser-page-zoom-feedback' +import { isPageDocumentNavigationStart } from './host-guest/browser-page-webview-navigation-handlers' import { BrowserLoadFailureOverlay } from './navigate/browser-load-failure-overlay' import { useClientHostedPageUrlSubmission } from './navigate/use-client-hosted-page-url-submission' import { convertBrowserPageToWorkspaceDoc } from '@/lib/file-preview' @@ -256,7 +257,10 @@ export function ClientHostedBrowserPagePane({ recordHistoryFromGuest(metadata.url, getBrowserDisplayTitle(metadata.title, metadata.url)) setAddressBarValueFromPage(toDisplayUrl(metadata.url)) } - const onStart = (): void => { + const onStart = (event: Electron.DidStartNavigationEvent): void => { + if (!isPageDocumentNavigationStart(event)) { + return + } activeLoadFailureRef.current = null updatePageStateFromGuest(browserTab.id, { loading: true, loadError: null }) const startMetadata = readBrowserClientPageGuestMetadataIfLive(webview, undefined, true) @@ -275,7 +279,7 @@ export function ClientHostedBrowserPagePane({ } ) const cleanupGuest = (): void => { - webview.removeEventListener('did-start-loading', onStart) + webview.removeEventListener('did-start-navigation', onStart) webview.removeEventListener('did-stop-loading', syncNavigation) webview.removeEventListener('did-navigate', syncNavigation) webview.removeEventListener('did-navigate-in-page', syncNavigation) @@ -287,7 +291,7 @@ export function ClientHostedBrowserPagePane({ attachment.detach() } releaseGuest = cleanupGuest - webview.addEventListener('did-start-loading', onStart) + webview.addEventListener('did-start-navigation', onStart) webview.addEventListener('did-stop-loading', syncNavigation) webview.addEventListener('did-navigate', syncNavigation) webview.addEventListener('did-navigate-in-page', syncNavigation) @@ -330,6 +334,10 @@ export function ClientHostedBrowserPagePane({ showFailureOverlay }) + // Why the unrecovered case reads not-loading: nothing is coming, and a loading cue nobody + // will ever stop is the one state this pane must not sit in. + const loading = !restoredPageUnrecovered && (placement === null || browserTab.loading) + return (
{/* IPC-driven context menu in a Portal so position:fixed escapes ancestor transform/backdrop-filter containing blocks. */} @@ -346,9 +354,6 @@ export function ClientHostedBrowserPagePane({ controls={{ canGoBack: browserTab.canGoBack, canGoForward: browserTab.canGoForward, - // Why the unrecovered case reads not-loading: nothing is coming, and a spinner nobody - // will ever stop is the one state this pane must not sit in. - loading: !restoredPageUnrecovered && (placement === null || browserTab.loading), goBack: () => webviewRef.current?.goBack(), goForward: () => webviewRef.current?.goForward(), reload: () => reload.runReloadTrigger('button'), @@ -372,9 +377,12 @@ export function ClientHostedBrowserPagePane({ presentation="client-hosted" /> } + loading={loading} + loadFailed={browserTab.loadError !== null} /> } reloadLabel={reload.reloadButtonLabel} + reloadShowsStop={reload.reloadButtonLabelKind === 'stop'} > {markup.drawButton} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx index 29c500d68ee..be2a1b5b717 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/BrowserAddressBar.tsx @@ -16,6 +16,7 @@ import { useBrowserAddressBarClickSelect } from './use-browser-address-bar-click import { useBrowserAddressBarDismissal } from './use-browser-address-bar-dismissal' import type { BrowserAddressBarEditSessionBinding } from './use-browser-address-bar-edit-session' import BrowserAddressBarSuggestionList from './BrowserAddressBarSuggestionList' +import { BrowserLoadProgress } from './browser-load-progress' type BrowserAddressBarProps = { /** The page this bar edits; its worktree's other open pages are offered as "Switch to tab". */ @@ -35,6 +36,8 @@ type BrowserAddressBarProps = { editSession?: BrowserAddressBarEditSessionBinding | null /** Replaces the leading globe (e.g. the SSH egress indicator). */ leadingIcon?: React.ReactNode + loading?: boolean + loadFailed?: boolean } export default function BrowserAddressBar({ @@ -47,7 +50,9 @@ export default function BrowserAddressBar({ inputRef, dismissSuggestionsRef, editSession, - leadingIcon + leadingIcon, + loading = false, + loadFailed = false }: BrowserAddressBarProps): React.ReactElement { const [open, setOpen] = useState(false) const [selectedValueOverride, setSelectedValueOverride] = useState(null) @@ -393,7 +398,7 @@ export default function BrowserAddressBar({ // pixels the squeezed slot has left. overlay ? 'absolute inset-x-3 top-1/2 z-30 -translate-y-1/2 shadow-[0_10px_24px_rgba(0,0,0,0.18)]' - : 'min-w-0 flex-1' + : 'relative min-w-0 flex-1' )} // Why: when squeezed the input is zero-width, so clicks land on the // form padding — forward them to the input so it expands and edits. @@ -444,6 +449,7 @@ export default function BrowserAddressBar({ aria-controls="browser-history-listbox" aria-autocomplete="list" /> + {suggestions.length > 0 && ( diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-load-progress.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-load-progress.tsx new file mode 100644 index 00000000000..5604847facd --- /dev/null +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-load-progress.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react' +import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion' + +// Why a delay: loads that finish sooner (most local dev servers) should show nothing at all. +const SHOW_DELAY_MS = 200 +const TRICKLE_MS = 30_000 +const TRICKLE_ID = 'browser-load-trickle' +const STILL_ID = 'browser-load-still' + +// Electron reports no load percentage, so the line eases toward 90% and the real stop finishes it. +const TRICKLE_STEPS: [atMs: number, scale: number][] = [ + [0, 0], + [300, 0.15], + [2_000, 0.45], + [6_000, 0.7], + [15_000, 0.85], + [TRICKLE_MS, 0.9] +] +const TRICKLE_KEYFRAMES: Keyframe[] = TRICKLE_STEPS.map(([atMs, scale]) => ({ + offset: atMs / TRICKLE_MS, + transform: `scaleX(${scale})`, + opacity: atMs === 0 ? 0 : 1, + easing: 'ease-out' +})) +// Reduced motion: a still, dimmed full-width line that still reads as "working". +const STILL_KEYFRAMES: Keyframe[] = [ + { transform: 'scaleX(1)', opacity: 0 }, + { transform: 'scaleX(1)', opacity: 0.5 } +] + +/** A thin line along the bottom edge of the address bar while the page loads. */ +export function BrowserLoadProgress({ + loading, + failed +}: { + loading: boolean + failed: boolean +}): React.JSX.Element { + const barRef = useRef(null) + const reducedMotion = usePrefersReducedMotion() + + useEffect(() => { + const bar = barRef.current + if (!bar) { + return + } + const animations = bar.getAnimations() + const running = animations.find(({ id }) => id === TRICKLE_ID || id === STILL_ID) + if (loading) { + const id = reducedMotion ? STILL_ID : TRICKLE_ID + // Why: a navigation that replaces one in flight keeps loading true, so the line carries on. + if (running?.id === id) { + return + } + animations.forEach((animation) => animation.cancel()) + // Why fill forwards: a slow page holds at its last frame, and a finished animation costs + // nothing per frame, even in a hidden tab. + bar.animate(reducedMotion ? STILL_KEYFRAMES : TRICKLE_KEYFRAMES, { + id, + duration: reducedMotion ? 150 : TRICKLE_MS, + // A motion-preference switch mid-load swaps the animation without hiding it again. + delay: running ? 0 : SHOW_DELAY_MS, + fill: 'forwards' + }) + return + } + if (!running) { + return + } + const { transform, opacity } = getComputedStyle(bar) + running.cancel() + // Still inside the show delay: the load was fast enough to show nothing. + if (Number(opacity) === 0) { + return + } + // Why: a failed load fades where it stopped instead of filling up like a success. + const end = failed || reducedMotion ? transform : 'scaleX(1)' + bar.animate( + [ + { transform, opacity }, + { transform: end, opacity, offset: 0.4 }, + { transform: end, opacity: 0 } + ], + { duration: 450, easing: 'ease-out' } + ) + }, [failed, loading, reducedMotion]) + + useEffect(() => { + const bar = barRef.current + // Why: an animation outlives its removed element and keeps ticking until cancelled. + return () => bar?.getAnimations().forEach((animation) => animation.cancel()) + }, []) + + return ( + // Why -inset-px: the line sits on the bar's bottom border instead of a pixel above it. +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx index 593dfd837d0..265c3f79185 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.test.tsx @@ -30,7 +30,6 @@ function renderRow(overrides: Partial = {}): BrowserN const controls: BrowserNavigationControls = { canGoBack: true, canGoForward: true, - loading: false, goBack: vi.fn(), goForward: vi.fn(), reload: vi.fn(), @@ -66,7 +65,6 @@ function renderWithIdentityChip(): void { controls={{ canGoBack: false, canGoForward: false, - loading: false, goBack: vi.fn(), goForward: vi.fn(), reload: vi.fn(), diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx index 999f890a37f..1d20a4ddb0c 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-navigation-control-row.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, ArrowRight, Loader2, RefreshCw } from 'lucide-react' +import { ArrowLeft, ArrowRight, RefreshCw, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { cn } from '@/lib/utils' @@ -15,7 +15,6 @@ import { export type BrowserNavigationControls = { canGoBack: boolean canGoForward: boolean - loading: boolean goBack: () => void goForward: () => void reload: () => void @@ -35,6 +34,7 @@ export function BrowserNavigationControlRow({ addressSlot, reloadControl, reloadLabel, + reloadShowsStop = false, showTourAnchors = true, rowRef, children @@ -45,6 +45,8 @@ export function BrowserNavigationControlRow({ reloadControl?: React.ReactNode /** Accessible name for the default reload button, which doubles as Stop and Retry. */ reloadLabel?: string + /** Only when the default button's click stops the load, matching its Stop label. */ + reloadShowsStop?: boolean /** Off for surfaces the browsing tour does not cover — a second anchor would steal its steps. */ showTourAnchors?: boolean rowRef?: React.Ref @@ -84,11 +86,7 @@ export function BrowserNavigationControlRow({ onClick={controls.reload} aria-label={reloadLabel ?? translate('browser.navigation.reload', 'Reload')} > - {controls.loading ? ( - - ) : ( - - )} + {reloadShowsStop ? : } )} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx index b162a2ba34b..258d2ac9adb 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-chrome-header.tsx @@ -75,6 +75,7 @@ export function BrowserPageChromeHeader({ convertedFrom={browserTab.convertedFrom ?? null} convertedTo={browserTab.convertedTo ?? null} loading={browserTab.loading} + loadFailed={browserTab.loadError !== null} webviewRef={webviewRef} reloadMenuOpen={reload.reloadMenuOpen} setReloadMenuOpen={reload.setReloadMenuOpen} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx index 523d1d28556..d564b12d389 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-pane.tsx @@ -119,7 +119,6 @@ export function BrowserPagePane({ const browserTabUrlRef = useRef(browserTab.url) // Most-recent observed webview URL; URL sync checks it to avoid force-navigating to an intermediate redirect (which would loop the redirect chain). const lastKnownWebviewUrlRef = useRef(null) - const trackNextLoadingEventRef = useRef(false) const recoveryNavigationValidationRef = useRef<{ committed: boolean started: boolean @@ -189,7 +188,6 @@ export function BrowserPagePane({ webviewRef, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, onSetUrlRef, onUpdatePageStateRef, @@ -235,7 +233,6 @@ export function BrowserPagePane({ faviconUrl: browserTab.faviconUrl, webviewRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, activeLoadFailureRef, retryGuestRecoveryRef, @@ -252,7 +249,6 @@ export function BrowserPagePane({ webviewRef, chromeHeaderRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, keepAddressBarFocusRef, addressBarInputRef, browserTabUrlRef, @@ -263,7 +259,6 @@ export function BrowserPagePane({ const reload = useBrowserPageReloadActions({ browserTab, webviewRef, - trackNextLoadingEventRef, retryGuestRecoveryRef, onUpdatePageStateRef }) diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx index a62d3506f72..b12aff9eb14 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-page-toolbar.tsx @@ -44,6 +44,7 @@ export function BrowserPageToolbar({ convertedFrom, convertedTo, loading, + loadFailed, webviewRef, reloadMenuOpen, setReloadMenuOpen, @@ -84,6 +85,7 @@ export function BrowserPageToolbar({ /** Set on a page Back returned to; Forward re-crosses it once guest history runs out. */ convertedTo?: BrowserPageConversionOrigin | null loading: boolean + loadFailed: boolean webviewRef: RefObject reloadMenuOpen: boolean setReloadMenuOpen: Dispatch> @@ -138,7 +140,6 @@ export function BrowserPageToolbar({ controls={{ canGoBack: canGoBack || Boolean(convertedFrom), canGoForward: canGoForward || Boolean(convertedTo), - loading, // Why the fallbacks: guest history cannot survive a conversion (the guest was replaced), // so once it runs out Back returns across the conversion — and Forward re-crosses it — // instead of going dead. @@ -176,6 +177,8 @@ export function BrowserPageToolbar({ inputRef={addressBarInputRef} dismissSuggestionsRef={dismissAddressBarSuggestionsRef} leadingIcon={} + loading={loading} + loadFailed={loadFailed} /> } reloadControl={ @@ -183,7 +186,7 @@ export function BrowserPageToolbar({ menuOpen={reloadMenuOpen} onMenuOpenChange={setReloadMenuOpen} label={reloadButtonLabel} - loading={loading} + showStop={reloadButtonLabelKind === 'stop'} showShortcutHint={reloadButtonLabelKind === 'reload'} reloadShortcut={reloadShortcut} hardReloadShortcut={hardReloadShortcut} diff --git a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx index 32cf8ad50e0..c6b2f6bd5de 100644 --- a/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx +++ b/src/renderer/src/components/browser-pane/assemble-chrome/browser-reload-control.tsx @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react' -import { Loader2, RefreshCw } from 'lucide-react' +import { RefreshCw, X } from 'lucide-react' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -19,7 +19,7 @@ export function BrowserReloadControl({ menuOpen, onMenuOpenChange, label, - loading, + showStop = false, showShortcutHint, reloadShortcut, hardReloadShortcut, @@ -30,7 +30,8 @@ export function BrowserReloadControl({ menuOpen: boolean onMenuOpenChange: Dispatch> label: string - loading: boolean + /** Only when the primary click stops the load; a reload-only caller keeps the reload icon. */ + showStop?: boolean /** The chord maps to plain reload, which is not what Stop or Retry do — hint only when they match. */ showShortcutHint: boolean reloadShortcut: string @@ -71,11 +72,7 @@ export function BrowserReloadControl({ onMenuOpenChange(true) }} > - {loading ? ( - - ) : ( - - )} + {showStop ? : } diff --git a/src/renderer/src/components/browser-pane/browser-client-page-guest-metadata.ts b/src/renderer/src/components/browser-pane/browser-client-page-guest-metadata.ts index 93e62de4dd7..03c99cebc0d 100644 --- a/src/renderer/src/components/browser-pane/browser-client-page-guest-metadata.ts +++ b/src/renderer/src/components/browser-pane/browser-client-page-guest-metadata.ts @@ -10,7 +10,7 @@ import type { BrowserClientPageMetadataSnapshot } from './browser-client-page-me * can no longer reach its guest. * * `eventUrl` wins when a navigation event carries one: the tag's own getURL() can still report the - * previous page while the event is being delivered. `loading` is forced for did-start-loading, + * previous page while the event is being delivered. `loading` is forced for a navigation start, * which fires before isLoading() flips. * * Why total rather than throwing: a guest destroyed in main leaves the tag holding its id, so diff --git a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts index dd880308b26..351cfa94af3 100644 --- a/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts +++ b/src/renderer/src/components/browser-pane/host-guest/attach-browser-page-webview.ts @@ -50,7 +50,6 @@ export type AttachBrowserPageWebviewArgs = { faviconUrlRef: MutableRefObject addressBarInputRef: RefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> onSetUrlRef: MutableRefObject setBrowserOverlayViewport: Dispatch> diff --git a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts index a7d786e12d3..d3659e58f73 100644 --- a/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts +++ b/src/renderer/src/components/browser-pane/host-guest/bind-browser-page-webview-listeners.ts @@ -53,7 +53,6 @@ export function bindBrowserPageWebviewListeners({ faviconUrlRef, addressBarInputRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, invalidateBrowserAnnotationDocumentRef, onSetUrlRef, setBrowserOverlayViewport, @@ -101,7 +100,6 @@ export function bindBrowserPageWebviewListeners({ addressBarInputRef, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, invalidateBrowserAnnotationDocumentRef, @@ -165,10 +163,9 @@ export function bindBrowserPageWebviewListeners({ webview.addEventListener('console-message', handleAnnotationViewportMessage) if (needsInitialNavigation) { - // Why: set src only after listeners attach so a fast localhost failure isn't missed; only non-blank tabs show the loading indicator. + // Why: set src only after listeners attach so a fast localhost failure isn't missed. const initialUrl = normalizeBrowserNavigationUrl(initialBrowserUrlRef.current) ?? ORCA_BROWSER_BLANK_URL - trackNextLoadingEventRef.current = initialUrl !== ORCA_BROWSER_BLANK_URL lastKnownWebviewUrlRef.current = initialUrl webview.src = initialUrl } else if (isPaintableRef.current) { diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts index e0a65879f9c..db1d09f809a 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-annotation-document.test.ts @@ -71,7 +71,6 @@ function createHarness() { ...common, browserTabUrlRef: ref('https://example.com/'), addressBarValueRef: ref('https://example.com/'), - trackNextLoadingEventRef: ref(false), keepAddressBarFocusRef: ref(false), focusAddressBarNow: () => false }) diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-navigation.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-navigation.test.ts index 40b0e122a1f..3b91cb486cd 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-navigation.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-favicon-navigation.test.ts @@ -51,7 +51,6 @@ function createHarness(startUrl: string) { addressBarInputRef: ref(null), activeLoadFailureRef: ref(null), lastKnownWebviewUrlRef: ref(startUrl), - trackNextLoadingEventRef: ref(true), keepAddressBarFocusRef: ref(false), recoveryNavigationValidationRef: ref(null), invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), @@ -72,7 +71,7 @@ function createHarness(startUrl: string) { committedUrl.current = url } - return { faviconUrlRef, updates, navigation, navigateTo, committedUrl } + return { faviconUrlRef, updates, navigation, loading, navigateTo, committedUrl } } describe('favicon retention across navigations', () => { @@ -126,7 +125,7 @@ describe('favicon retention across navigations', () => { expect(harness.faviconUrlRef.current).toBe(GITHUB_ICON) }) - it('reports loading without touching the icon on did-start-loading', () => { + it('reports loading without touching the icon when a page navigation starts', () => { const harness = createHarness('https://github.com/nodejs/node') harness.navigation.handleFaviconUpdate({ favicons: [GITHUB_ICON] }) harness.updates.length = 0 @@ -136,6 +135,28 @@ describe('favicon retention across navigations', () => { expect(harness.updates).toEqual([{ loading: true }]) }) + it('does not report loading for iframe loads or same-document navigations', () => { + const harness = createHarness('https://github.com/nodejs/node') + + const startNavigation = (url: string, isMainFrame: boolean, isInPlace: boolean) => + Object.assign(new Event('did-start-navigation'), { + url, + isMainFrame, + isInPlace, + frameProcessId: 1, + frameRoutingId: 1 + }) + harness.loading.handleDidStartLoading() + harness.navigation.handleDidStartNavigation( + startNavigation('https://ads.example.com/frame', false, false) + ) + harness.navigation.handleDidStartNavigation( + startNavigation('https://github.com/nodejs/node#readme', true, true) + ) + + expect(harness.updates.some((update) => update.loading)).toBe(false) + }) + it('takes the first renderable icon rather than the first declared one', () => { const harness = createHarness('https://example.com/') harness.navigation.handleFaviconUpdate({ diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.test.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.test.ts index 1c74f886e2e..ba50aa6e778 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.test.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-mount-admission.test.ts @@ -28,7 +28,6 @@ function createLoadingHandlers(currentUrl: string) { addressBarInputRef: ref(null), activeLoadFailureRef: ref(null), lastKnownWebviewUrlRef: ref(null), - trackNextLoadingEventRef: ref(true), keepAddressBarFocusRef: ref(false), recoveryNavigationValidationRef: ref(null), invalidateBrowserAnnotationDocumentRef: ref(vi.fn()), diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts index 6757290f34c..59a1429db90 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-loading-handlers.ts @@ -32,7 +32,6 @@ export type BrowserPageWebviewLoadingHandlersArgs = { addressBarInputRef: RefObject activeLoadFailureRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void> @@ -58,7 +57,6 @@ export function createBrowserPageWebviewLoadingHandlers({ addressBarInputRef, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, keepAddressBarFocusRef, recoveryNavigationValidationRef, invalidateBrowserAnnotationDocumentRef, @@ -72,14 +70,6 @@ export function createBrowserPageWebviewLoadingHandlers({ // A reload replaces geometry even when the URL stays the same. invalidateBrowserAnnotationDocumentRef.current() setBrowserOverlayViewport({ scrollX: 0, scrollY: 0, version: 0 }) - if (!trackNextLoadingEventRef.current) { - return - } - // Why the favicon isn't cleared here: it is dropped on the cross-origin did-start-navigation - // instead, because Chromium won't re-announce an unchanged icon for a same-origin load. - onUpdatePageStateRef.current(browserTabId, { - loading: true - }) } const handleDidStopLoading = (): void => { @@ -90,7 +80,6 @@ export function createBrowserPageWebviewLoadingHandlers({ const browserModelUrl = redactKagiSessionToken(currentUrl) const activeLoadFailure = activeLoadFailureRef.current if (isChromiumErrorPage(currentUrl)) { - trackNextLoadingEventRef.current = false const synthesizedFailure = { code: -1, description: translate( @@ -109,7 +98,6 @@ export function createBrowserPageWebviewLoadingHandlers({ return } if (activeLoadFailure?.code === BROWSER_GUEST_RECOVERY_ERROR_CODE) { - trackNextLoadingEventRef.current = false onUpdatePageStateRef.current(browserTabId, { loading: false, title: getBrowserDisplayTitle(webview.getTitle(), browserModelUrl), @@ -125,7 +113,6 @@ export function createBrowserPageWebviewLoadingHandlers({ activeLoadFailure.validatedUrl const normalizedCurrentUrl = normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl if (normalizedAttemptedUrl === normalizedCurrentUrl) { - trackNextLoadingEventRef.current = false // Why: some failures still emit did-stop-loading on the original URL; keep loadError so the known-failed load isn't cleared to a blank surface. onUpdatePageStateRef.current(browserTabId, { loading: false, @@ -138,7 +125,6 @@ export function createBrowserPageWebviewLoadingHandlers({ return } } - trackNextLoadingEventRef.current = false activeLoadFailureRef.current = null lastKnownWebviewUrlRef.current = normalizeBrowserNavigationUrl(browserModelUrl) ?? browserModelUrl @@ -168,7 +154,6 @@ export function createBrowserPageWebviewLoadingHandlers({ if (!loadError) { return } - trackNextLoadingEventRef.current = false const pendingRecoveryNavigation = recoveryNavigationValidationRef.current if (pendingRecoveryNavigation?.started) { recoveryNavigationValidationRef.current = null diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts index 7af4659a1d0..d7b9278885d 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-navigation-handlers.ts @@ -54,6 +54,17 @@ export type BrowserPageWebviewNavigationHandlers = { handleAnnotationViewportMessage: (event: { message?: string }) => void } +/** + * A new document starting to load in the page itself. Not did-start-loading, which also fires for + * iframe loads; and this covers navigations the page starts (links, forms, redirects, history). + */ +export function isPageDocumentNavigationStart(event: { + isMainFrame: boolean + isInPlace: boolean +}): boolean { + return event.isMainFrame && !event.isInPlace +} + export function createBrowserPageWebviewNavigationHandlers({ webview, browserTabId, @@ -97,9 +108,10 @@ export function createBrowserPageWebviewNavigationHandlers({ if (event.isMainFrame && event.url) { invalidateBrowserAnnotationDocumentRef.current() } - if (!event.isMainFrame || event.isInPlace || !event.url) { + if (!isPageDocumentNavigationStart(event) || !event.url) { return } + onUpdatePageStateRef.current(browserTabId, { loading: true }) const pendingRecoveryNavigation = recoveryNavigationValidationRef.current const browserStartedUrl = redactKagiSessionToken(event.url) const startedUrl = normalizeBrowserNavigationUrl(browserStartedUrl) ?? browserStartedUrl diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index 5e5a8036f80..1a35e8e88f9 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -66,7 +66,6 @@ export function useBrowserPageWebviewLifecycle({ faviconUrl, webviewRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, activeLoadFailureRef, retryGuestRecoveryRef, @@ -107,7 +106,6 @@ export function useBrowserPageWebviewLifecycle({ faviconUrl: string | null webviewRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject activeLoadFailureRef: MutableRefObject retryGuestRecoveryRef: MutableRefObject<() => void> @@ -267,7 +265,6 @@ export function useBrowserPageWebviewLifecycle({ faviconUrlRef, addressBarInputRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, invalidateBrowserAnnotationDocumentRef, onSetUrlRef, setBrowserOverlayViewport, diff --git a/src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts b/src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts index 2256c4171f7..582a08ff502 100644 --- a/src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/chromium-error-page-poll-visibility.test.ts @@ -31,7 +31,6 @@ function renderUrlSync(guestUrl: () => string): { webviewRef: { current: webview }, chromeHeaderRef: { current: null }, lastKnownWebviewUrlRef: { current: 'https://example.test/slow' }, - trackNextLoadingEventRef: { current: false }, keepAddressBarFocusRef: { current: false }, addressBarInputRef: { current: null }, browserTabUrlRef: { current: 'https://example.test/slow' }, diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts index fd42af12c7c..3b8aeced896 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-notebook-link.test.ts @@ -29,7 +29,6 @@ function openNotebookUrl(url: string): void { worktreeId: 'wt-1', activeLoadFailureRef: ref(null), lastKnownWebviewUrlRef: ref(null), - trackNextLoadingEventRef: ref(false), recoveryNavigationValidationRef: ref(null), webviewRef: ref(null), onSetUrlRef: ref(vi.fn()), diff --git a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts index 0d62edddf0d..9d36d97462d 100644 --- a/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts +++ b/src/renderer/src/components/browser-pane/navigate/navigate-browser-page-url.ts @@ -30,7 +30,6 @@ export type NavigateBrowserPageToUrlArgs = { worktreeId: string activeLoadFailureRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject webviewRef: MutableRefObject onSetUrlRef: MutableRefObject @@ -46,7 +45,6 @@ export function navigateBrowserPageToUrl({ worktreeId, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, webviewRef, onSetUrlRef, @@ -76,7 +74,6 @@ export function navigateBrowserPageToUrl({ if (!webview) { return } - trackNextLoadingEventRef.current = targetUrl !== ORCA_BROWSER_BLANK_URL lastKnownWebviewUrlRef.current = normalizedBrowserModelUrl recoveryNavigationValidationRef.current = recoveryLoadError ? { committed: false, started: false, targetUrl: normalizedBrowserModelUrl } diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx index 56442889c56..506240f7c1b 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.file-drop.test.tsx @@ -28,7 +28,6 @@ function DropSurface() { webviewRef: { current: null }, activeLoadFailureRef: { current: null }, lastKnownWebviewUrlRef: { current: null }, - trackNextLoadingEventRef: { current: false }, recoveryNavigationValidationRef: { current: null }, onSetUrlRef: { current: vi.fn() }, onUpdatePageStateRef: { current: vi.fn() }, diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts index ff3f3973a05..341ac8c6aa0 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-navigation-downloads.ts @@ -40,7 +40,6 @@ export function useBrowserPageNavigationDownloads({ webviewRef, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, onSetUrlRef, onUpdatePageStateRef, @@ -56,7 +55,6 @@ export function useBrowserPageNavigationDownloads({ webviewRef: MutableRefObject activeLoadFailureRef: MutableRefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject recoveryNavigationValidationRef: MutableRefObject onSetUrlRef: MutableRefObject onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> @@ -106,7 +104,6 @@ export function useBrowserPageNavigationDownloads({ worktreeId, activeLoadFailureRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, recoveryNavigationValidationRef, webviewRef, onSetUrlRef, @@ -125,7 +122,6 @@ export function useBrowserPageNavigationDownloads({ onUpdatePageStateRef, recoveryNavigationValidationRef, setResourceNotice, - trackNextLoadingEventRef, webviewRef, worktreeId ] diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx index 9db4bd34c6c..a42f3ed751f 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.test.tsx @@ -32,15 +32,13 @@ function createWebview(overrides: Partial = {}): Electron.W } describe('useBrowserPageReloadActions', () => { - it('arms the lifecycle gate and exposes loading after an accepted reload', () => { + it('exposes loading after an accepted reload', () => { const webview = createWebview() - const trackNextLoadingEventRef = { current: false } const onUpdatePageStateRef = { current: vi.fn() } const view = renderHook(() => useBrowserPageReloadActions({ browserTab: createBrowserTab(), webviewRef: { current: webview }, - trackNextLoadingEventRef, retryGuestRecoveryRef: { current: vi.fn() }, onUpdatePageStateRef }) @@ -49,7 +47,6 @@ describe('useBrowserPageReloadActions', () => { act(() => view.result.current.runReloadTrigger('button')) expect(webview.reload).toHaveBeenCalledTimes(1) - expect(trackNextLoadingEventRef.current).toBe(true) expect(onUpdatePageStateRef.current).toHaveBeenCalledWith('page-a', { loading: true }) }) @@ -59,21 +56,17 @@ describe('useBrowserPageReloadActions', () => { throw new Error('The WebView must be attached to the DOM') }) }) - const trackNextLoadingEventRef = { current: false } const onUpdatePageStateRef = { current: vi.fn() } const view = renderHook(() => useBrowserPageReloadActions({ browserTab: createBrowserTab(), webviewRef: { current: webview }, - trackNextLoadingEventRef, retryGuestRecoveryRef: { current: vi.fn() }, onUpdatePageStateRef }) ) act(() => view.result.current.runReloadTrigger('button')) - - expect(trackNextLoadingEventRef.current).toBe(false) expect(onUpdatePageStateRef.current).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts index 88a36fcad2d..7b18718a121 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-reload-actions.ts @@ -14,13 +14,11 @@ import type { BrowserTabPageState } from '../describe-page/browser-page-types' export function useBrowserPageReloadActions({ browserTab, webviewRef, - trackNextLoadingEventRef, retryGuestRecoveryRef, onUpdatePageStateRef }: { browserTab: BrowserPageState webviewRef: MutableRefObject - trackNextLoadingEventRef?: MutableRefObject retryGuestRecoveryRef: MutableRefObject<() => void> onUpdatePageStateRef: MutableRefObject<(tabId: string, updates: BrowserTabPageState) => void> }): { @@ -46,30 +44,17 @@ export function useBrowserPageReloadActions({ if (!webview) { return } - if (trackNextLoadingEventRef) { - trackNextLoadingEventRef.current = true - } const result = reloadBrowserPageWebview(webview, { ignoreCache }) - if (result === 'reloaded') { - onUpdatePageStateRef.current(browserTab.id, { loading: true }) - } else if (result === 'guest-missing') { - if (trackNextLoadingEventRef) { - trackNextLoadingEventRef.current = false - } + if (result === 'not-ready') { + return + } + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + if (result === 'guest-missing') { // Why: reload cannot revive a destroyed guest (STA-3448) — recreate it instead. - onUpdatePageStateRef.current(browserTab.id, { loading: true }) retryGuestRecoveryRef.current() - } else if (trackNextLoadingEventRef) { - trackNextLoadingEventRef.current = false } }, - [ - browserTab.id, - onUpdatePageStateRef, - retryGuestRecoveryRef, - trackNextLoadingEventRef, - webviewRef - ] + [browserTab.id, onUpdatePageStateRef, retryGuestRecoveryRef, webviewRef] ) const runReloadTrigger = useCallback( (trigger: BrowserReloadTrigger) => { diff --git a/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts b/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts index bfd93702440..d6e83b493f2 100644 --- a/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts +++ b/src/renderer/src/components/browser-pane/navigate/use-browser-page-webview-url-sync.ts @@ -24,7 +24,6 @@ export function useBrowserPageWebviewUrlSync({ webviewRef, chromeHeaderRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, keepAddressBarFocusRef, addressBarInputRef, browserTabUrlRef, @@ -41,7 +40,6 @@ export function useBrowserPageWebviewUrlSync({ webviewRef: MutableRefObject chromeHeaderRef: RefObject lastKnownWebviewUrlRef: MutableRefObject - trackNextLoadingEventRef: MutableRefObject keepAddressBarFocusRef: MutableRefObject addressBarInputRef: RefObject browserTabUrlRef: MutableRefObject @@ -98,8 +96,6 @@ export function useBrowserPageWebviewUrlSync({ webview.src !== normalizedUrl && declaredSrc !== normalizedUrl ) { - // Why: browserTab.url changes are Orca-driven navigations; gate did-start-loading so only real navigations show loading UI. - trackNextLoadingEventRef.current = normalizedUrl !== ORCA_BROWSER_BLANK_URL lastKnownWebviewUrlRef.current = normalizedUrl webview.src = normalizedUrl if (normalizedUrl !== ORCA_BROWSER_BLANK_URL) { @@ -115,7 +111,6 @@ export function useBrowserPageWebviewUrlSync({ focusWebviewNow, keepAddressBarFocusRef, lastKnownWebviewUrlRef, - trackNextLoadingEventRef, webviewRef ]) diff --git a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx index 3b6937efe49..e027d488b18 100644 --- a/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx +++ b/src/renderer/src/components/browser-pane/restored-client-hosted-recovery-window.test.tsx @@ -108,12 +108,10 @@ function renderPane(): ReturnType { return render(paneElement()) } -// Scoped to the navigation row: the unavailable notice's reopen button keeps its own pending -// spinner in the DOM at all times, hidden by class, so an unscoped query is always true. -function spinnerShown(): boolean { +function loadingShown(): boolean { return ( document.querySelector( - '[data-contextual-tour-target="client-hosted-browser-controls"] .animate-spin' + '[data-contextual-tour-target="client-hosted-browser-controls"] [data-browser-load-progress="loading"]' ) !== null ) } @@ -143,7 +141,7 @@ describe('restored client-hosted recovery window', () => { act(() => vi.advanceTimersByTime(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS - 1)) - expect(spinnerShown()).toBe(true) + expect(loadingShown()).toBe(true) expect(noticeShown()).toBe(false) }) @@ -154,7 +152,7 @@ describe('restored client-hosted recovery window', () => { act(() => vi.advanceTimersByTime(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS)) expect(noticeShown()).toBe(true) - expect(spinnerShown()).toBe(false) + expect(loadingShown()).toBe(false) expect(screen.getByRole('button', { name: 'Reopen on server' })).not.toBeNull() }) @@ -175,7 +173,7 @@ describe('restored client-hosted recovery window', () => { act(() => vi.advanceTimersByTime(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS * 10)) - expect(spinnerShown()).toBe(true) + expect(loadingShown()).toBe(true) expect(noticeShown()).toBe(false) }) @@ -208,7 +206,7 @@ describe('restored client-hosted recovery window', () => { act(() => vi.advanceTimersByTime(RESTORED_CLIENT_HOSTED_RECOVERY_WINDOW_MS * 10)) - expect(spinnerShown()).toBe(true) + expect(loadingShown()).toBe(true) expect(noticeShown()).toBe(false) }) diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx index 851def90ade..ba11d900223 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-pane.tsx @@ -395,6 +395,7 @@ export function RemoteBrowserPagePane({ addressBarEditSession={addressBarEditSession} busy={busy} loading={browserTab.loading} + loadFailed={browserTab.loadError !== null} markup={markup} frameUrl={frameUrl} isActive={isActive} diff --git a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx index 0e79d5e786d..0dfcce2c6b8 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/stream-remote/remote-browser-page-toolbar.tsx @@ -1,4 +1,4 @@ -import { Loader2, MessageSquarePlus, RefreshCw } from 'lucide-react' +import { MessageSquarePlus, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' @@ -21,6 +21,7 @@ export function RemoteBrowserPageToolbar({ addressBarEditSession, busy, loading, + loadFailed, markup, frameUrl, isActive, @@ -39,6 +40,7 @@ export function RemoteBrowserPageToolbar({ addressBarEditSession: BrowserAddressBarEditSessionBinding busy: boolean loading: boolean + loadFailed: boolean markup: MarkupModeController frameUrl: string | null isActive: boolean @@ -53,7 +55,6 @@ export function RemoteBrowserPageToolbar({ // dead-end back/forward RPC is simply a no-op on the host. canGoBack: true, canGoForward: true, - loading: busy || loading, goBack: onBack, goForward: onForward, reload: onReload, @@ -75,6 +76,8 @@ export function RemoteBrowserPageToolbar({ presentation="streamed" /> } + loading={busy || loading} + loadFailed={loadFailed} /> } reloadControl={ @@ -92,11 +95,7 @@ export function RemoteBrowserPageToolbar({ )} onClick={onReload} > - {busy || loading ? ( - - ) : ( - - )} + diff --git a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx index 899285a6d38..40272ec1917 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/HtmlDocPreview.tsx @@ -315,7 +315,6 @@ export function HtmlDocPreview({ previewId={previewId} worktreeId={worktreeId} history={historyWithConversionCrossings} - loading={state === 'loading' && failureReason === null} onReload={handleReload} onHardReload={handleHardReload} onCopyPath={() => void window.api.ui.writeClipboardText(identity.absolutePath)} diff --git a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx index 18fe0404289..ef7c8cd3c33 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx +++ b/src/renderer/src/components/browser-pane/workspace-doc/doc-preview-toolbar.tsx @@ -20,7 +20,6 @@ export function DocPreviewToolbar({ previewId, worktreeId, history, - loading, onReload, onHardReload, onCopyPath, @@ -38,7 +37,6 @@ export function DocPreviewToolbar({ previewId: string worktreeId: string history: DocPreviewHistory - loading: boolean onReload: () => void /** Drops the grant and mints a new one — the preview's equivalent of ignoring every cache. */ onHardReload: () => void @@ -64,7 +62,6 @@ export function DocPreviewToolbar({ controls={{ canGoBack: history.canGoBack, canGoForward: history.canGoForward, - loading, goBack: history.goBack, goForward: history.goForward, reload: onReload, @@ -79,7 +76,6 @@ export function DocPreviewToolbar({ menuOpen={reloadMenuOpen} onMenuOpenChange={setReloadMenuOpen} label={reloadLabel} - loading={loading} showShortcutHint={false} reloadShortcut="" hardReloadShortcut="" diff --git a/tests/e2e/browser-reload-feedback.spec.ts b/tests/e2e/browser-reload-feedback.spec.ts index ef5ba6839a9..93917b31d33 100644 --- a/tests/e2e/browser-reload-feedback.spec.ts +++ b/tests/e2e/browser-reload-feedback.spec.ts @@ -1,5 +1,6 @@ import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' +import type { Page } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' import { ensureTerminalVisible, @@ -14,7 +15,19 @@ function closeServer(server: Server): Promise { ) } -test('shows Stop and a spinner during a held reload, then returns to Reload', async ({ +function readTabLoading(page: Page, worktreeId: string, tabId: string): Promise { + return page.evaluate( + ({ worktreeId, tabId }) => { + const tab = window.__store + ?.getState() + .browserTabsByWorktree[worktreeId]?.find((entry) => entry.id === tabId) + return tab?.loading ?? null + }, + { worktreeId, tabId } + ) +} + +test('shows Stop and the loading line during a held reload, then returns to Reload', async ({ orcaPage }, testInfo) => { let requestCount = 0 @@ -75,20 +88,11 @@ test('shows Stop and a spinner during a held reload, then returns to Reload', as timeout: 20_000 }) .toBe(url) - const getLoading = (): Promise => - orcaPage.evaluate( - ({ worktreeId, tabId }) => { - const tab = window.__store - ?.getState() - .browserTabsByWorktree[worktreeId]?.find((entry) => entry.id === tabId) - return tab?.loading ?? null - }, - { worktreeId, tabId: tab.id } - ) + const getLoading = (): Promise => readTabLoading(orcaPage, worktreeId, tab.id) await expect.poll(getLoading, { timeout: 10_000 }).toBe(true) const stop = pane.getByRole('button', { name: 'Stop' }) await expect(stop).toBeVisible({ timeout: 10_000 }) - await expect(stop.locator('svg')).toHaveAttribute('class', /animate-spin/) + await expect(pane.locator('[data-browser-load-progress="loading"]')).toHaveCount(1) await orcaPage.screenshot({ path: testInfo.outputPath('reload-held.png') }) reloadReleased = true reloadResolvers.splice(0).forEach((resolve) => resolve()) @@ -106,3 +110,56 @@ test('shows Stop and a spinner during a held reload, then returns to Reload', as await closeServer(server) } }) + +test('shows loading while a link inside the page waits on the server', async ({ orcaPage }) => { + let releaseHeld: (() => void) | undefined + const server = createServer((request, response) => { + const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + if (pathname === '/held') { + releaseHeld = () => response.end('Heldheld') + return + } + response.end('Startnext') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('held-link server has no port') + } + const origin = `http://127.0.0.1:${address.port}` + try { + await waitForSessionReady(orcaPage) + await ensureTerminalVisible(orcaPage) + const worktreeId = await waitForActiveWorktree(orcaPage) + const tabId = await orcaPage.evaluate( + ({ worktreeId, url }) => + window.__store?.getState().createBrowserTab(worktreeId, url, { activate: true })?.id ?? + null, + { worktreeId, url: `${origin}/` } + ) + if (!tabId) { + throw new Error('browser tab was not created') + } + const pane = orcaPage.locator(`[data-browser-overlay-tab-id="${tabId}"]`) + const getLoading = (): Promise => readTabLoading(orcaPage, worktreeId, tabId) + await expect(pane.getByRole('button', { name: 'Reload' })).toBeVisible({ timeout: 20_000 }) + await expect.poll(getLoading, { timeout: 10_000 }).toBe(false) + + await pane + .locator('webview') + .evaluate((webview: Electron.WebviewTag) => + webview.executeJavaScript('document.querySelector("a").click()') + ) + await expect.poll(() => releaseHeld !== undefined, { timeout: 10_000 }).toBe(true) + await expect.poll(getLoading, { timeout: 10_000 }).toBe(true) + await expect(pane.locator('[data-browser-load-progress="loading"]')).toHaveCount(1) + + releaseHeld?.() + await expect.poll(getLoading, { timeout: 10_000 }).toBe(false) + await expect(pane.locator('[data-browser-load-progress="loading"]')).toHaveCount(0) + } finally { + releaseHeld?.() + await closeServer(server) + } +})