feat(browser): add hard reload option and shortcut hints to reload button (#12483)

* feat(browser): add hard reload option and shortcut hints to reload button

Add a tooltip to the browser reload button showing the reload shortcut.
Add a right-click context menu with Reload and Hard Reload options.
Add localized labels for Hard Reload across EN, ZH, JA, KO, ES.

* fix(browser): add aria-labels to reload buttons

* feat(browser): make reload button contextual and extract action logic

- Button label now reflects actual action: Stop when loading, Retry on failure, Reload when idle
- Extract reload intent resolution into reusable browser-reload-action module with tests
- Add keyboard support (Enter/Space) for the reload button
- Simplify remote page reload to tooltip-only (no ignore-cache RPC for remote pages)
- Add "Stop" translations for all supported languages

* fix(browser): exhaust reload intent switch for type-aware lint

Replace the default branch with an explicit reload case so oxlint
switch-exhaustiveness-check accepts BrowserReloadIntent.

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Maxon Phong 2026-08-06 03:25:21 +08:00 committed by GitHub
parent fde816e4ee
commit b1b291db08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 240 additions and 41 deletions

View File

@ -195,6 +195,11 @@ import {
createBrowserPageGuestRecovery
} from './browser-page-guest-recovery'
import { subscribeBrowserSystemResume } from './browser-system-resume'
import {
type BrowserReloadTrigger,
resolveBrowserReloadButtonLabelKind,
resolveBrowserReloadIntent
} from './browser-reload-action'
type BrowserTabPageState = Partial<
Pick<
@ -2613,18 +2618,31 @@ function RemoteBrowserPagePane({
>
<ArrowRight className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => void runRemoteNavigation('browser.reload')}
>
{busy || browserTab.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
{/* Why: no ignore-cache RPC exists for remote pages, and this pane binds no reload chord, so there is
nothing truthful to put in a menu or a shortcut hint here tooltip only. */}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={translate(
'auto.components.browser.pane.BrowserPane.0e080d820e',
'Reload'
)}
onClick={() => void runRemoteNavigation('browser.reload')}
>
{busy || browserTab.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
</TooltipContent>
</Tooltip>
<BrowserAddressBar
value={addressBarValue}
onChange={setAddressBarValue}
@ -2879,6 +2897,9 @@ function BrowserPagePane({
getExplicitBrowserPageZoomLevel(browserTab.id) ?? normalizedBrowserDefaultZoomLevel
)
const grabElementShortcut = useShortcutLabel('browser.grabElement')
const reloadShortcut = useShortcutLabel('browser.reload')
const hardReloadShortcut = useShortcutLabel('browser.hardReload')
const [reloadMenuOpen, setReloadMenuOpen] = useState(false)
const faviconUrlRef = useRef<string | null>(browserTab.faviconUrl)
const initialBrowserUrlRef = useRef(browserTab.url)
const browserTabUrlRef = useRef(browserTab.url)
@ -2916,6 +2937,47 @@ function BrowserPagePane({
const [findOpen, setFindOpen] = useState(false)
const grab = useGrabMode(browserTab.id)
const reloadState = useMemo(
() => ({ loading: browserTab.loading, loadErrorCode: browserTab.loadError?.code ?? null }),
[browserTab.loading, browserTab.loadError]
)
const runReloadTrigger = useCallback(
(trigger: BrowserReloadTrigger) => {
const webview = webviewRef.current
if (!webview) {
return
}
switch (resolveBrowserReloadIntent(trigger, reloadState)) {
case 'stop':
webview.stop()
break
case 'retry-guest-recovery':
onUpdatePageStateRef.current(browserTab.id, { loading: true })
retryGuestRecoveryRef.current()
break
case 'retry-load':
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
break
case 'hard-reload':
webview.reloadIgnoringCache()
break
case 'reload':
webview.reload()
break
}
},
[browserTab, reloadState]
)
// Keep the accessible name honest: the same button is Stop mid-load and Retry after a failure.
const reloadButtonLabelKind = resolveBrowserReloadButtonLabelKind(reloadState)
const reloadButtonLabel =
reloadButtonLabelKind === 'stop'
? translate('auto.components.browser.pane.BrowserPane.b7e4d9c1a2', 'Stop')
: reloadButtonLabelKind === 'retry'
? translate('auto.components.browser.pane.BrowserPane.781d6459ad', 'Retry')
: translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')
const markup = useMarkupMode({
getCaptureContext: useCallback((): MarkupCaptureContext | null => {
const webview = webviewRef.current
@ -5175,35 +5237,62 @@ function BrowserPagePane({
>
<ArrowRight className="size-4" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => {
const webview = webviewRef.current
if (!webview) {
return
}
if (browserTab.loading) {
webview.stop()
} else if (browserTab.loadError) {
if (browserTab.loadError.code === BROWSER_GUEST_RECOVERY_ERROR_CODE) {
onUpdatePageStateRef.current(browserTab.id, { loading: true })
retryGuestRecoveryRef.current()
} else {
retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current)
}
} else {
webview.reload()
}
}}
>
{browserTab.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
<DropdownMenu modal={false} open={reloadMenuOpen} onOpenChange={setReloadMenuOpen}>
{/* Why: suppress the tooltip while the menu is open — both anchor below the button and would overlap. */}
<Tooltip open={reloadMenuOpen ? false : undefined}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
aria-label={reloadButtonLabel}
// Why: preventDefault suppresses Radix's open-on-left-click (composeEventHandlers skips its
// handler once defaultPrevented), keeping left-click on the primary action and the menu on right-click.
onPointerDown={(e) => {
if (e.button === 0) {
e.preventDefault()
}
}}
// Why: same trick for Radix's open-on-Enter/Space, which would otherwise preventDefault the
// synthesized click and strand keyboard users. ArrowDown still falls through to open the menu.
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
runReloadTrigger('button')
}
}}
onClick={() => runReloadTrigger('button')}
onContextMenu={(e) => {
e.preventDefault()
setReloadMenuOpen(true)
}}
>
{browserTab.loading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{reloadButtonLabel}
{/* Why: the chord maps to plain reload(), which is not what Stop or Retry do — only hint when they match. */}
{reloadShortcut && reloadButtonLabelKind === 'reload' ? ` · ${reloadShortcut}` : ''}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" alignOffset={-4}>
<DropdownMenuItem onClick={() => runReloadTrigger('reload')}>
{translate('auto.components.browser.pane.BrowserPane.0e080d820e', 'Reload')}
<DropdownMenuShortcut>{reloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => runReloadTrigger('hard-reload')}>
{translate('auto.components.browser.pane.BrowserPane.a1f3c2e4b5', 'Hard Reload')}
<DropdownMenuShortcut>{hardReloadShortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<BrowserAddressBar
value={addressBarValue}

View File

@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
import {
resolveBrowserReloadButtonLabelKind,
resolveBrowserReloadIntent
} from './browser-reload-action'
const idle = { loading: false, loadErrorCode: null }
const loading = { loading: true, loadErrorCode: null }
const failed = { loading: false, loadErrorCode: -105 }
const guestFailed = { loading: false, loadErrorCode: BROWSER_GUEST_RECOVERY_ERROR_CODE }
describe('resolveBrowserReloadIntent', () => {
it('reloads an idle page from every trigger', () => {
expect(resolveBrowserReloadIntent('button', idle)).toBe('reload')
expect(resolveBrowserReloadIntent('reload', idle)).toBe('reload')
expect(resolveBrowserReloadIntent('hard-reload', idle)).toBe('hard-reload')
})
it('stops an in-flight load only from the toolbar button', () => {
expect(resolveBrowserReloadIntent('button', loading)).toBe('stop')
expect(resolveBrowserReloadIntent('reload', loading)).toBe('reload')
expect(resolveBrowserReloadIntent('hard-reload', loading)).toBe('hard-reload')
})
// Why: reload() on chrome-error:// only refreshes the error page — every entry point must retry the load.
it('routes a failed load to the retry path from the menu too', () => {
expect(resolveBrowserReloadIntent('button', failed)).toBe('retry-load')
expect(resolveBrowserReloadIntent('reload', failed)).toBe('retry-load')
expect(resolveBrowserReloadIntent('hard-reload', failed)).toBe('retry-load')
})
it('routes a guest-recovery failure to guest recovery from the menu too', () => {
expect(resolveBrowserReloadIntent('button', guestFailed)).toBe('retry-guest-recovery')
expect(resolveBrowserReloadIntent('reload', guestFailed)).toBe('retry-guest-recovery')
expect(resolveBrowserReloadIntent('hard-reload', guestFailed)).toBe('retry-guest-recovery')
})
it('prefers stop over retry when a failed page is already reloading', () => {
expect(resolveBrowserReloadIntent('button', { loading: true, loadErrorCode: -105 })).toBe(
'stop'
)
})
})
describe('resolveBrowserReloadButtonLabelKind', () => {
it('names the button for what it actually does', () => {
expect(resolveBrowserReloadButtonLabelKind(idle)).toBe('reload')
expect(resolveBrowserReloadButtonLabelKind(loading)).toBe('stop')
expect(resolveBrowserReloadButtonLabelKind(failed)).toBe('retry')
expect(resolveBrowserReloadButtonLabelKind(guestFailed)).toBe('retry')
})
})

View File

@ -0,0 +1,47 @@
import { BROWSER_GUEST_RECOVERY_ERROR_CODE } from './browser-page-guest-recovery'
/** Where the reload request came from: the toolbar button, or an explicit menu entry. */
export type BrowserReloadTrigger = 'button' | 'reload' | 'hard-reload'
export type BrowserReloadIntent =
| 'stop'
| 'retry-guest-recovery'
| 'retry-load'
| 'reload'
| 'hard-reload'
export type BrowserReloadState = {
loading: boolean
loadErrorCode: number | null
}
/**
* Why: webview.reload() only refreshes a chrome-error:// page, so a failed load has to go through the
* recovery paths no matter which entry point asked for it. Only the toolbar button doubles as Stop.
*/
export function resolveBrowserReloadIntent(
trigger: BrowserReloadTrigger,
state: BrowserReloadState
): BrowserReloadIntent {
if (trigger === 'button' && state.loading) {
return 'stop'
}
if (state.loadErrorCode !== null) {
return state.loadErrorCode === BROWSER_GUEST_RECOVERY_ERROR_CODE
? 'retry-guest-recovery'
: 'retry-load'
}
return trigger === 'hard-reload' ? 'hard-reload' : 'reload'
}
/** Accessible name for the toolbar button, which is Stop mid-load and Retry after a failure. */
export type BrowserReloadButtonLabelKind = 'stop' | 'retry' | 'reload'
export function resolveBrowserReloadButtonLabelKind(
state: BrowserReloadState
): BrowserReloadButtonLabelKind {
if (state.loading) {
return 'stop'
}
return state.loadErrorCode !== null ? 'retry' : 'reload'
}

View File

@ -13736,6 +13736,8 @@
"1b179ab561": "Copy Page URL",
"f7ab83f7ed": "Open Page In Default Browser",
"0e080d820e": "Reload",
"a1f3c2e4b5": "Hard Reload",
"b7e4d9c1a2": "Stop",
"250a9b3e42": "Forward",
"40edfa75cb": "Back",
"efb0e8f7f3": "Copy Link Address",

View File

@ -13455,6 +13455,8 @@
"1b179ab561": "Copiar URL de página",
"f7ab83f7ed": "Abrir página en el navegador predeterminado",
"0e080d820e": "Recargar",
"a1f3c2e4b5": "Recarga forzada",
"b7e4d9c1a2": "Detener",
"250a9b3e42": "Adelante",
"40edfa75cb": "Atrás",
"efb0e8f7f3": "Copiar dirección de enlace",

View File

@ -13455,6 +13455,8 @@
"1b179ab561": "ページの URL をコピー",
"f7ab83f7ed": "デフォルトのブラウザでページを開く",
"0e080d820e": "リロード",
"a1f3c2e4b5": "ハードリロード",
"b7e4d9c1a2": "停止",
"250a9b3e42": "進む",
"40edfa75cb": "戻る",
"efb0e8f7f3": "リンクアドレスをコピーする",

View File

@ -13466,6 +13466,8 @@
"1b179ab561": "페이지 URL 복사",
"f7ab83f7ed": "기본 브라우저에서 페이지 열기",
"0e080d820e": "새로고침",
"a1f3c2e4b5": "강제 새로고침",
"b7e4d9c1a2": "중지",
"250a9b3e42": "앞으로",
"40edfa75cb": "뒤로",
"efb0e8f7f3": "링크 주소 복사",

View File

@ -13475,6 +13475,8 @@
"1b179ab561": "复制页面网址",
"f7ab83f7ed": "在默认浏览器中打开页面",
"0e080d820e": "重新加载",
"a1f3c2e4b5": "硬刷新",
"b7e4d9c1a2": "停止",
"250a9b3e42": "向前",
"40edfa75cb": "返回",
"efb0e8f7f3": "复制链接地址",