fix(desktop): hold the typed address in the browser bar until the page moves

Committing an address dropped the field back to the url of the page you
were leaving, so typing baby.com over google.com flashed google.com back
before baby.com arrived — and nothing said a load was underway.

The address you asked for now stays in the field until the page actually
lands somewhere (a redirect supersedes it, as it should), and progress
spins inside the field beside it. The pane owns that loading state from
the moment it accepts the address, because the reach probe it runs first
delays did-start-loading.
This commit is contained in:
Brooklyn Nicholson
2026-08-24 14:30:39 -05:00
committed by brooklyn!
parent 8a8f74e789
commit 84758ed19d
3 changed files with 76 additions and 6 deletions
@@ -163,7 +163,10 @@ describe('PreviewBrowserBar', () => {
expect(address(rendered).value).toBe('example.org/docs')
})
it('normalizes and navigates on Enter, then releases the field back to the page', () => {
// Committing used to drop the field back to `url` — the page you were
// LEAVING — so the address you typed flashed away and came back once the
// load landed. What you asked for stays put until the page actually moves.
it('normalizes and navigates on Enter, and holds the address it asked for', () => {
const onNavigate = vi.fn()
const rendered = render(<PreviewBrowserBar {...baseProps} onNavigate={onNavigate} />)
const input = address(rendered)
@@ -173,7 +176,35 @@ describe('PreviewBrowserBar', () => {
fireEvent.keyDown(input, { key: 'Enter' })
expect(onNavigate).toHaveBeenCalledWith('http://localhost:5173')
expect(address(rendered).value).toBe('https://example.com')
expect(address(rendered).value).toBe('http://localhost:5173')
})
// A redirect means the address you asked for is no longer the truth.
it('releases the asked-for address once the page lands somewhere', () => {
const rendered = render(<PreviewBrowserBar {...baseProps} />)
const input = address(rendered)
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'example.org' } })
fireEvent.keyDown(input, { key: 'Enter' })
rendered.rerender(<PreviewBrowserBar {...baseProps} url="https://www.example.org/home" />)
expect(address(rendered).value).toBe('https://www.example.org/home')
})
// Re-focusing mid-flight must offer what is on screen to edit, not resurrect
// the address of the page being left behind.
it('hands the in-flight address to the field on re-focus', () => {
const rendered = render(<PreviewBrowserBar {...baseProps} />)
const input = address(rendered)
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'example.org' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
fireEvent.focus(input)
expect(address(rendered).value).toBe('https://example.org')
})
it('refuses to navigate to a script-bearing scheme and marks the field invalid', () => {
@@ -229,6 +260,19 @@ describe('PreviewBrowserBar', () => {
expect(address(rendered).value).toBe('https://example.com')
})
// Progress belongs beside the address it describes; the reload glyph sits in
// a row of four and reads as chrome rather than as this page's state.
it('shows progress inside the address field only while loading', () => {
const { container, rerender } = render(<PreviewBrowserBar {...baseProps} loading />)
const field = screen.getByRole('textbox', { name: 'Address' }).parentElement
expect(field?.querySelector('.codicon-loading')).toBeTruthy()
rerender(<PreviewBrowserBar {...baseProps} />)
expect(container.querySelector('.codicon-loading')).toBeNull()
})
it('spins the reload glyph only while loading', () => {
const { container, rerender } = render(<PreviewBrowserBar {...baseProps} loading />)
@@ -14,13 +14,14 @@
* buttons, so a glyph here and a glyph on the strip are still the same button.
*/
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { Input } from '@/components/ui/input'
import { PaneStripGlyph } from '@/components/ui/pane-tab'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
interface PreviewBrowserBarProps {
canGoBack: boolean
@@ -105,9 +106,18 @@ export function PreviewBrowserBar({
// Null while the field is idle, so the address tracks navigation on its own;
// a string once the user takes it over, so typing survives a page load.
const [draft, setDraft] = useState<null | string>(null)
// The address we asked for and are still waiting on. Without it, committing
// dropped the field straight back to `url` — the page you were LEAVING —
// so every navigation flashed the old address before the new one arrived.
const [pending, setPending] = useState<null | string>(null)
// Only while the user is typing: a page that navigates itself is never the
// user's mistake to flag.
const invalid = draft !== null && draft.trim().length > 0 && !normalizePreviewAddress(draft)
const shown = draft ?? pending ?? url
// The page moved (or a redirect landed somewhere else entirely), so the real
// address supersedes what we asked for.
useEffect(() => setPending(null), [url])
const commit = (value: string) => {
const address = normalizePreviewAddress(value)
@@ -117,6 +127,7 @@ export function PreviewBrowserBar({
}
setDraft(null)
setPending(address)
onNavigate(address)
}
@@ -144,15 +155,26 @@ export function PreviewBrowserBar({
It copies what the field shows: on a remote gateway, that is the
reach-resolved address. */}
<div className="relative min-w-0 flex-1">
{/* Progress lives IN the field, where the address it belongs to is —
the reload glyph also spins, but it sits in a row of four and
reads as chrome rather than as this page's state. */}
{loading && (
<Codicon
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
name="loading"
size="0.75rem"
spinning
/>
)}
<Input
aria-invalid={invalid || undefined}
aria-label={copy.address}
className="pr-7"
className={cn('pr-7', loading && 'pl-6')}
inputMode="url"
onBlur={() => setDraft(null)}
onChange={event => setDraft(event.target.value)}
onFocus={event => {
setDraft(url)
setDraft(shown)
event.currentTarget.select()
}}
onKeyDown={event => {
@@ -169,7 +191,7 @@ export function PreviewBrowserBar({
placeholder={copy.addressPlaceholder}
size="xs"
spellCheck={false}
value={draft ?? url}
value={shown}
/>
<CopyButton
appearance="inline"
@@ -390,6 +390,10 @@ export function PreviewPane({ embedded = false, onRestartServer, reloadRequest =
const navigateTo = useCallback(
(url: string) => {
setLoadError(null)
// The reach probe below is a round-trip of its own, and `did-start-loading`
// can't fire until it resolves — so own the loading state from the moment
// we accept the address, or the bar sits idle over a request in flight.
setLoading(true)
// Typed addresses get the same loopback reach as agent-opened ones — on a
// remote gateway `localhost:5173` is usually the dev server the user is
// there to look at, not something on their own laptop.