feat(browser): show every page load, link clicks included, as a line along the address bar on desktop and mobile (#31597)

This commit is contained in:
Kelvin Amoaba
2026-10-21 23:46:33 +00:00
committed by GitHub
parent 06861d9072
commit 7e795b6be6
46 changed files with 467 additions and 141 deletions
@@ -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<Animation>()
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()
+1
View File
@@ -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')
}
+1
View File
@@ -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",
@@ -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<string, unknown> {
createElement(MobileBrowserAddressField, {
disabled: false,
focused: false,
loading: false,
loadFailed: false,
onBlur: () => {},
onChangeText: () => {},
onFocus: () => {},
@@ -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({
</Text>
</View>
) : null}
<MobileBrowserLoadProgress loading={loading} failed={loadFailed} />
</View>
)
}
@@ -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 (
<View pointerEvents="none" style={styles.clip}>
<Animated.View
testID="mobile-browser-load-progress"
style={[styles.line, { opacity, transform: [{ scaleX: scale }] }]}
/>
</View>
)
}
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'
}
})
+2
View File
@@ -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
@@ -150,6 +150,8 @@ export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
onSubmit={() => void navigateToAddress()}
focused={addressFocused}
disabled={controlsDisabled}
loading={tab.loading}
loadFailed={tab.loadError != null}
/>
<MobileBrowserViewModeSwitch
disabled={controlsDisabled}
@@ -17,6 +17,8 @@ vi.mock('./use-browser-binary-screencast-grant', () => ({
useBrowserBinaryScreencastGrant: () => true
}))
vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: {
@@ -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: () => {} }) },
@@ -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: () => {} }) },
@@ -22,6 +22,8 @@ const appState = vi.hoisted(() => {
return holder
})
vi.mock('./MobileBrowserLoadProgress', () => ({ MobileBrowserLoadProgress: () => null }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
AppState: {
@@ -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: () => {} }) },
@@ -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>): 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()
@@ -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 (
<div className="relative flex h-full min-h-0 flex-1 flex-col bg-background">
{/* 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}
</BrowserNavigationControlRow>
@@ -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<string | null>(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"
/>
<BrowserLoadProgress loading={loading} failed={loadFailed} />
</form>
</PopoverTrigger>
{suggestions.length > 0 && (
@@ -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<HTMLDivElement>(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.
<div
aria-hidden
className="pointer-events-none absolute -inset-px overflow-hidden rounded-[inherit]"
>
<div
ref={barRef}
data-browser-load-progress={loading ? 'loading' : 'idle'}
className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-callout-note opacity-0"
/>
</div>
)
}
@@ -30,7 +30,6 @@ function renderRow(overrides: Partial<BrowserNavigationControls> = {}): 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(),
@@ -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<HTMLDivElement>
@@ -84,11 +86,7 @@ export function BrowserNavigationControlRow({
onClick={controls.reload}
aria-label={reloadLabel ?? translate('browser.navigation.reload', 'Reload')}
>
{controls.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
{reloadShowsStop ? <X className="size-4" /> : <RefreshCw className="size-4" />}
</Button>
)}
@@ -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}
@@ -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<string | null>(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
})
@@ -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<Electron.WebviewTag | null>
reloadMenuOpen: boolean
setReloadMenuOpen: Dispatch<SetStateAction<boolean>>
@@ -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={<SshEgressIndicator worktreeId={worktreeId} />}
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}
@@ -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<SetStateAction<boolean>>
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 ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
{showStop ? <X className="size-4" /> : <RefreshCw className="size-4" />}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -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
@@ -50,7 +50,6 @@ export type AttachBrowserPageWebviewArgs = {
faviconUrlRef: MutableRefObject<string | null>
addressBarInputRef: RefObject<HTMLInputElement | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
invalidateBrowserAnnotationDocumentRef: MutableRefObject<() => void>
onSetUrlRef: MutableRefObject<BrowserPageUrlSetter>
setBrowserOverlayViewport: Dispatch<SetStateAction<BrowserOverlayViewport>>
@@ -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) {
@@ -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
})
@@ -51,7 +51,6 @@ function createHarness(startUrl: string) {
addressBarInputRef: ref(null),
activeLoadFailureRef: ref(null),
lastKnownWebviewUrlRef: ref<string | null>(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({
@@ -28,7 +28,6 @@ function createLoadingHandlers(currentUrl: string) {
addressBarInputRef: ref(null),
activeLoadFailureRef: ref(null),
lastKnownWebviewUrlRef: ref<string | null>(null),
trackNextLoadingEventRef: ref(true),
keepAddressBarFocusRef: ref(false),
recoveryNavigationValidationRef: ref(null),
invalidateBrowserAnnotationDocumentRef: ref(vi.fn()),
@@ -32,7 +32,6 @@ export type BrowserPageWebviewLoadingHandlersArgs = {
addressBarInputRef: RefObject<HTMLInputElement | null>
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
keepAddressBarFocusRef: MutableRefObject<boolean>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
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
@@ -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
@@ -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<Electron.WebviewTag | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
retryGuestRecoveryRef: MutableRefObject<() => void>
@@ -267,7 +265,6 @@ export function useBrowserPageWebviewLifecycle({
faviconUrlRef,
addressBarInputRef,
lastKnownWebviewUrlRef,
trackNextLoadingEventRef,
invalidateBrowserAnnotationDocumentRef,
onSetUrlRef,
setBrowserOverlayViewport,
@@ -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' },
@@ -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()),
@@ -30,7 +30,6 @@ export type NavigateBrowserPageToUrlArgs = {
worktreeId: string
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
webviewRef: MutableRefObject<Electron.WebviewTag | null>
onSetUrlRef: MutableRefObject<BrowserPageUrlSetter>
@@ -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 }
@@ -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() },
@@ -40,7 +40,6 @@ export function useBrowserPageNavigationDownloads({
webviewRef,
activeLoadFailureRef,
lastKnownWebviewUrlRef,
trackNextLoadingEventRef,
recoveryNavigationValidationRef,
onSetUrlRef,
onUpdatePageStateRef,
@@ -56,7 +55,6 @@ export function useBrowserPageNavigationDownloads({
webviewRef: MutableRefObject<Electron.WebviewTag | null>
activeLoadFailureRef: MutableRefObject<BrowserLoadError | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
recoveryNavigationValidationRef: MutableRefObject<BrowserPageRecoveryNavigationValidation | null>
onSetUrlRef: MutableRefObject<BrowserPageUrlSetter>
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
]
@@ -32,15 +32,13 @@ function createWebview(overrides: Partial<Electron.WebviewTag> = {}): 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()
})
})
@@ -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<Electron.WebviewTag | null>
trackNextLoadingEventRef?: MutableRefObject<boolean>
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) => {
@@ -24,7 +24,6 @@ export function useBrowserPageWebviewUrlSync({
webviewRef,
chromeHeaderRef,
lastKnownWebviewUrlRef,
trackNextLoadingEventRef,
keepAddressBarFocusRef,
addressBarInputRef,
browserTabUrlRef,
@@ -41,7 +40,6 @@ export function useBrowserPageWebviewUrlSync({
webviewRef: MutableRefObject<Electron.WebviewTag | null>
chromeHeaderRef: RefObject<HTMLDivElement | null>
lastKnownWebviewUrlRef: MutableRefObject<string | null>
trackNextLoadingEventRef: MutableRefObject<boolean>
keepAddressBarFocusRef: MutableRefObject<boolean>
addressBarInputRef: RefObject<HTMLInputElement | null>
browserTabUrlRef: MutableRefObject<string>
@@ -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
])
@@ -108,12 +108,10 @@ function renderPane(): ReturnType<typeof render> {
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)
})
@@ -395,6 +395,7 @@ export function RemoteBrowserPagePane({
addressBarEditSession={addressBarEditSession}
busy={busy}
loading={browserTab.loading}
loadFailed={browserTab.loadError !== null}
markup={markup}
frameUrl={frameUrl}
isActive={isActive}
@@ -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 ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
<RefreshCw className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
@@ -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)}
@@ -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=""
+69 -12
View File
@@ -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<void> {
)
}
test('shows Stop and a spinner during a held reload, then returns to Reload', async ({
function readTabLoading(page: Page, worktreeId: string, tabId: string): Promise<boolean | null> {
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<boolean | null> =>
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<boolean | null> => 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('<!doctype html><title>Held</title><body>held</body>')
return
}
response.end('<!doctype html><title>Start</title><body><a href="/held">next</a></body>')
})
await new Promise<void>((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<boolean | null> => 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)
}
})