From 84758ed19dd0d7214bfc4677e6042e2d03d4f205 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 24 Aug 2026 14:30:39 -0500 Subject: [PATCH] fix(desktop): hold the typed address in the browser bar until the page moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../right-rail/preview-browser-bar.test.tsx | 48 ++++++++++++++++++- .../chat/right-rail/preview-browser-bar.tsx | 30 ++++++++++-- .../src/app/chat/right-rail/preview-pane.tsx | 4 ++ 3 files changed, 76 insertions(+), 6 deletions(-) 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.