mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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()
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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: () => {} }) },
|
||||
|
||||
+11
-3
@@ -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>
|
||||
)
|
||||
}
|
||||
-2
@@ -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(),
|
||||
|
||||
+5
-7
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+1
@@ -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>>
|
||||
|
||||
+1
-4
@@ -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) {
|
||||
|
||||
-1
@@ -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
|
||||
})
|
||||
|
||||
+24
-3
@@ -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({
|
||||
|
||||
-1
@@ -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()),
|
||||
|
||||
-15
@@ -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
|
||||
|
||||
+13
-1
@@ -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
|
||||
|
||||
-3
@@ -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,
|
||||
|
||||
-1
@@ -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' },
|
||||
|
||||
-1
@@ -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 }
|
||||
|
||||
-1
@@ -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() },
|
||||
|
||||
-4
@@ -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
|
||||
]
|
||||
|
||||
+1
-8
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+6
-21
@@ -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
|
||||
])
|
||||
|
||||
|
||||
+6
-8
@@ -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}
|
||||
|
||||
+6
-7
@@ -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=""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user