fix: keep browser tab session navigation stable

Keep the browser pane loaded URL separate from the in-frame current URL so SPA navigation updates the address bar and history without remounting the iframe or resetting the Electron webview src.

Preserve parsed ?session= route params during initial URL normalization and pass a directory hint when applying deep links, preventing embedded OpenChamber sessions from collapsing back to / while bootstrap catches up.
This commit is contained in:
Bohdan Triapitsyn
2026-07-07 20:58:54 +03:00
parent 44f44da212
commit 42e470cefa
2 changed files with 43 additions and 26 deletions
@@ -1306,6 +1306,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const startUrl = normalized !== 'about:blank' ? normalized : '';
const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
@@ -1324,12 +1325,17 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]);
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean }) => {
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean; inFrame?: boolean }) => {
const normalizedUrl = normalizeBrowserUrl(url);
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
setCurrentUrl(nextUrl);
setUrlInput(nextUrl);
setIsLoading(Boolean(nextUrl));
if (!options?.inFrame) {
setLoadedUrl(nextUrl);
setIsLoading(Boolean(nextUrl));
} else {
setIsLoading(false);
}
persistUrl(nextUrl);
setHistory((current) => {
@@ -1360,6 +1366,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
if (!nextUrl) return;
setHistoryIndex(nextIndex);
setCurrentUrl(nextUrl);
setLoadedUrl(nextUrl);
setUrlInput(nextUrl);
setIsLoading(true);
persistUrl(nextUrl);
@@ -1376,12 +1383,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
}, [currentUrl]);
React.useEffect(() => {
if (!currentUrl) {
if (!loadedUrl) {
setProxyState({ status: 'idle' });
return;
}
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
const proxyTargetKey = getBrowserProxyTargetKey(loadedUrl);
const cached = getCachedProxyTarget(proxyTargetKey);
if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt });
@@ -1401,7 +1408,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ url: currentUrl, allowExternal: true }),
body: JSON.stringify({ url: loadedUrl, allowExternal: true }),
});
if (!response.ok) {
@@ -1441,9 +1448,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
return () => {
cancelled = true;
};
}, [currentUrl, t]);
}, [loadedUrl, t]);
const proxyUrlAuthKey = currentUrl && proxyState.status === 'ready'
const proxyUrlAuthKey = loadedUrl && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: '';
@@ -1468,9 +1475,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const proxySrc = React.useMemo(() => {
if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
if (!currentUrl || proxyState.status !== 'ready') return '';
if (!loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsed = new URL(currentUrl);
const parsed = new URL(loadedUrl);
const path = parsed.pathname || '/';
const searchParams = new URLSearchParams(parsed.search);
searchParams.delete('oc_url_token');
@@ -1482,12 +1489,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
} catch {
return '';
}
}, [currentUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
}, [loadedUrl, proxyState, proxyUrlAuthKey, reloadNonce, urlAuthReadyKey]);
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
const iframeSrc = proxySrc || (proxyState.status === 'error' ? loadedUrl : '');
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
@@ -1498,18 +1505,18 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
}
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
const upstreamOrigin = new URL(currentUrl).origin;
const upstreamOrigin = new URL(loadedUrl).origin;
return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
} catch {
return '';
}
}, [currentUrl, proxyState]);
}, [loadedUrl, proxyState]);
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
const upstreamOrigin = new URL(currentUrl).origin;
const upstreamOrigin = new URL(loadedUrl).origin;
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
return '';
}
@@ -1525,7 +1532,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
} catch {
return '';
}
}, [currentUrl, proxyState]);
}, [loadedUrl, proxyState]);
const postInspectMode = React.useCallback((enabled: boolean) => {
const frameWindow = iframeRef.current?.contentWindow;
@@ -1614,7 +1621,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const frameUrl = typeof data.url === 'string' ? data.url : '';
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
if (nextUrl && nextUrl !== currentUrl) {
applyUrl(nextUrl);
applyUrl(nextUrl, { inFrame: true });
}
return;
}
@@ -1685,8 +1692,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
if (upstreamUrl) {
setIsLoading(true);
applyUrl(upstreamUrl);
applyUrl(upstreamUrl, { inFrame: true });
return;
}
} catch {
@@ -1739,7 +1745,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
{iframeSrc ? (
<div className="absolute inset-0">
<iframe
key={`${iframeSrc}:${reloadNonce}`}
key={`${tabID}:${reloadNonce}`}
ref={iframeRef}
src={iframeSrc}
title={t('contextPanel.browser.empty')}
@@ -1788,6 +1794,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== 'about:blank' ? normalized : '';
const initialWebviewSrcRef = React.useRef(normalizeBrowserUrl(initialUrl));
const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [isInspecting, setIsInspecting] = React.useState(false);
@@ -2027,7 +2034,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
<div className="relative min-h-0 flex-1 bg-background">
<webview
ref={webviewRef}
src={normalizeBrowserUrl(initialUrl)}
src={initialWebviewSrcRef.current}
partition="persist:openchamber-browser"
allowpopups
style={{ width: '100%', height: '100%', border: 'none' }}
+14 -4
View File
@@ -60,7 +60,8 @@ export function useRouter(): void {
if (route.sessionId) {
const currentSessionId = useSessionUIStore.getState().currentSessionId;
if (route.sessionId !== currentSessionId) {
await setCurrentSession(route.sessionId);
const directoryHint = useSessionUIStore.getState().getDirectoryForSession(route.sessionId);
setCurrentSession(route.sessionId, directoryHint);
}
}
@@ -143,14 +144,23 @@ export function useRouter(): void {
const initializeRoute = async () => {
await applyRoute(route);
// After applying, update URL to normalized form (use replaceState)
// After applying, update URL to normalized form (use replaceState).
// Use the parsed route values instead of an immediate store snapshot so
// deep links do not briefly normalize `?session=...` back to `/` while
// the session's directory/message bootstrap is still catching up.
if (!isVSCode) {
syncURLFromState({ replace: true });
updateBrowserURL({
...getCurrentAppState(),
sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId,
tab: route.tab ?? useUIStore.getState().activeMainTab,
settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage,
diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile,
}, { replace: true, force: true });
}
};
void initializeRoute();
}, [applyRoute, isVSCode, syncURLFromState]);
}, [applyRoute, getCurrentAppState, isVSCode]);
// Subscribe to session changes
React.useEffect(() => {