diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx index 64a8cb578b..3390690d59 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.test.tsx @@ -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() 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() + const input = address(rendered) + + fireEvent.focus(input) + fireEvent.change(input, { target: { value: 'example.org' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + rendered.rerender() + + 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() + 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() + const field = screen.getByRole('textbox', { name: 'Address' }).parentElement + + expect(field?.querySelector('.codicon-loading')).toBeTruthy() + + rerender() + + expect(container.querySelector('.codicon-loading')).toBeNull() + }) + it('spins the reload glyph only while loading', () => { const { container, rerender } = render() diff --git a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx index f5dccb9374..0056118338 100644 --- a/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx +++ b/apps/desktop/src/app/chat/right-rail/preview-browser-bar.tsx @@ -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) + // 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) // 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. */}
+ {/* 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 && ( + + )} 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} /> { 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.