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 startUrl = normalized !== 'about:blank' ? normalized : '';
const [urlInput, setUrlInput] = React.useState(startUrl); const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl); const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [loadedUrl, setLoadedUrl] = React.useState(startUrl);
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []); const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1); const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0); 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); setContextPanelTabTargetPath(directory, tabID, url);
}, [directory, tabID, setContextPanelTabTargetPath]); }, [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 normalizedUrl = normalizeBrowserUrl(url);
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : ''; const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
setCurrentUrl(nextUrl); setCurrentUrl(nextUrl);
setUrlInput(nextUrl); setUrlInput(nextUrl);
setIsLoading(Boolean(nextUrl)); if (!options?.inFrame) {
setLoadedUrl(nextUrl);
setIsLoading(Boolean(nextUrl));
} else {
setIsLoading(false);
}
persistUrl(nextUrl); persistUrl(nextUrl);
setHistory((current) => { setHistory((current) => {
@@ -1360,6 +1366,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
if (!nextUrl) return; if (!nextUrl) return;
setHistoryIndex(nextIndex); setHistoryIndex(nextIndex);
setCurrentUrl(nextUrl); setCurrentUrl(nextUrl);
setLoadedUrl(nextUrl);
setUrlInput(nextUrl); setUrlInput(nextUrl);
setIsLoading(true); setIsLoading(true);
persistUrl(nextUrl); persistUrl(nextUrl);
@@ -1376,12 +1383,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
}, [currentUrl]); }, [currentUrl]);
React.useEffect(() => { React.useEffect(() => {
if (!currentUrl) { if (!loadedUrl) {
setProxyState({ status: 'idle' }); setProxyState({ status: 'idle' });
return; return;
} }
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl); const proxyTargetKey = getBrowserProxyTargetKey(loadedUrl);
const cached = getCachedProxyTarget(proxyTargetKey); const cached = getCachedProxyTarget(proxyTargetKey);
if (cached?.previewToken) { if (cached?.previewToken) {
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, previewToken: cached.previewToken, expiresAt: cached.expiresAt }); 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', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ url: currentUrl, allowExternal: true }), body: JSON.stringify({ url: loadedUrl, allowExternal: true }),
}); });
if (!response.ok) { if (!response.ok) {
@@ -1441,9 +1448,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [currentUrl, t]); }, [loadedUrl, t]);
const proxyUrlAuthKey = currentUrl && proxyState.status === 'ready' const proxyUrlAuthKey = loadedUrl && proxyState.status === 'ready'
? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}` ? `${proxyState.proxyBasePath}|${proxyState.previewToken || ''}|${reloadNonce}`
: ''; : '';
@@ -1468,9 +1475,9 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const proxySrc = React.useMemo(() => { const proxySrc = React.useMemo(() => {
if (urlAuthReadyKey !== proxyUrlAuthKey) return ''; if (urlAuthReadyKey !== proxyUrlAuthKey) return '';
if (!currentUrl || proxyState.status !== 'ready') return ''; if (!loadedUrl || proxyState.status !== 'ready') return '';
try { try {
const parsed = new URL(currentUrl); const parsed = new URL(loadedUrl);
const path = parsed.pathname || '/'; const path = parsed.pathname || '/';
const searchParams = new URLSearchParams(parsed.search); const searchParams = new URLSearchParams(parsed.search);
searchParams.delete('oc_url_token'); searchParams.delete('oc_url_token');
@@ -1482,12 +1489,12 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
} catch { } catch {
return ''; 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 => { const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return ''; if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try { try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin); const parsedFrameUrl = new URL(frameUrl, window.location.origin);
const proxyBasePath = proxyState.proxyBasePath.endsWith('/') const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
@@ -1498,18 +1505,18 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
} }
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/'; 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()); return stripPreviewQueryParams(new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString());
} catch { } catch {
return ''; return '';
} }
}, [currentUrl, proxyState]); }, [loadedUrl, proxyState]);
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => { const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return ''; if (!frameUrl || !loadedUrl || proxyState.status !== 'ready') return '';
try { try {
const parsedFrameUrl = new URL(frameUrl, window.location.origin); 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) { if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
return ''; return '';
} }
@@ -1525,7 +1532,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
} catch { } catch {
return ''; return '';
} }
}, [currentUrl, proxyState]); }, [loadedUrl, proxyState]);
const postInspectMode = React.useCallback((enabled: boolean) => { const postInspectMode = React.useCallback((enabled: boolean) => {
const frameWindow = iframeRef.current?.contentWindow; 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 frameUrl = typeof data.url === 'string' ? data.url : '';
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl); const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
if (nextUrl && nextUrl !== currentUrl) { if (nextUrl && nextUrl !== currentUrl) {
applyUrl(nextUrl); applyUrl(nextUrl, { inFrame: true });
} }
return; return;
} }
@@ -1685,8 +1692,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
const frameUrl = iframeRef.current?.contentWindow?.location.href || ''; const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl); const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
if (upstreamUrl) { if (upstreamUrl) {
setIsLoading(true); applyUrl(upstreamUrl, { inFrame: true });
applyUrl(upstreamUrl);
return; return;
} }
} catch { } catch {
@@ -1739,7 +1745,7 @@ const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dire
{iframeSrc ? ( {iframeSrc ? (
<div className="absolute inset-0"> <div className="absolute inset-0">
<iframe <iframe
key={`${iframeSrc}:${reloadNonce}`} key={`${tabID}:${reloadNonce}`}
ref={iframeRef} ref={iframeRef}
src={iframeSrc} src={iframeSrc}
title={t('contextPanel.browser.empty')} title={t('contextPanel.browser.empty')}
@@ -1788,6 +1794,7 @@ const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, dir
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath); const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
const normalized = normalizeBrowserUrl(initialUrl); const normalized = normalizeBrowserUrl(initialUrl);
const startUrl = normalized !== 'about:blank' ? normalized : ''; const startUrl = normalized !== 'about:blank' ? normalized : '';
const initialWebviewSrcRef = React.useRef(normalizeBrowserUrl(initialUrl));
const [urlInput, setUrlInput] = React.useState(startUrl); const [urlInput, setUrlInput] = React.useState(startUrl);
const [currentUrl, setCurrentUrl] = React.useState(startUrl); const [currentUrl, setCurrentUrl] = React.useState(startUrl);
const [isInspecting, setIsInspecting] = React.useState(false); 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"> <div className="relative min-h-0 flex-1 bg-background">
<webview <webview
ref={webviewRef} ref={webviewRef}
src={normalizeBrowserUrl(initialUrl)} src={initialWebviewSrcRef.current}
partition="persist:openchamber-browser" partition="persist:openchamber-browser"
allowpopups allowpopups
style={{ width: '100%', height: '100%', border: 'none' }} style={{ width: '100%', height: '100%', border: 'none' }}
+14 -4
View File
@@ -60,7 +60,8 @@ export function useRouter(): void {
if (route.sessionId) { if (route.sessionId) {
const currentSessionId = useSessionUIStore.getState().currentSessionId; const currentSessionId = useSessionUIStore.getState().currentSessionId;
if (route.sessionId !== 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 () => { const initializeRoute = async () => {
await applyRoute(route); 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) { 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(); void initializeRoute();
}, [applyRoute, isVSCode, syncURLFromState]); }, [applyRoute, getCurrentAppState, isVSCode]);
// Subscribe to session changes // Subscribe to session changes
React.useEffect(() => { React.useEffect(() => {