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.