diff --git a/bun.lock b/bun.lock
index 441ac53a35..7b0fed123a 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "mux",
@@ -63,6 +62,7 @@
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
"quickjs-emscripten-core": "^0.31.0",
+ "react-router-dom": "^7.11.0",
"rehype-harden": "^1.1.5",
"rehype-sanitize": "^6.0.0",
"shescape": "^2.1.6",
@@ -3136,6 +3136,10 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
+ "react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="],
+
+ "react-router-dom": ["react-router-dom@7.11.0", "", { "dependencies": { "react-router": "7.11.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g=="],
+
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"read-binary-file-arch": ["read-binary-file-arch@1.0.6", "", { "dependencies": { "debug": "^4.3.4" }, "bin": { "read-binary-file-arch": "cli.js" } }, "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg=="],
@@ -3276,6 +3280,8 @@
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
+
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
diff --git a/package.json b/package.json
index 305c41303d..842cdbafb3 100644
--- a/package.json
+++ b/package.json
@@ -102,8 +102,8 @@
"parse-duration": "^2.1.4",
"posthog-node": "^5.17.0",
"quickjs-emscripten": "^0.31.0",
- "typescript": "^5.1.3",
"quickjs-emscripten-core": "^0.31.0",
+ "react-router-dom": "^7.11.0",
"rehype-harden": "^1.1.5",
"rehype-sanitize": "^6.0.0",
"shescape": "^2.1.6",
@@ -111,6 +111,7 @@
"streamdown": "1.6.10",
"trpc-cli": "^0.12.1",
"turndown": "^7.2.2",
+ "typescript": "^5.1.3",
"undici": "^7.16.0",
"write-file-atomic": "^6.0.0",
"ws": "^8.18.3",
diff --git a/src/browser/App.tsx b/src/browser/App.tsx
index 3267245f95..fa40f402d6 100644
--- a/src/browser/App.tsx
+++ b/src/browser/App.tsx
@@ -57,10 +57,6 @@ import { getWorkspaceSidebarKey } from "./utils/workspace";
const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"];
-function isStorybookIframe(): boolean {
- return typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html");
-}
-
function AppInner() {
// Get workspace state from context
const {
@@ -139,21 +135,10 @@ function AppInner() {
// Auto-resume interrupted streams on app startup and when failures occur
useResumeManager();
- // Sync selectedWorkspace with URL hash
+ // Update window title based on selected workspace
+ // URL syncing is now handled by RouterContext
useEffect(() => {
- // Storybook's test runner treats hash updates as navigations and will retry play tests.
- // The hash deep-linking isn't needed in Storybook, so skip it there.
- const shouldSyncHash = !isStorybookIframe();
-
if (selectedWorkspace) {
- // Update URL with workspace ID
- if (shouldSyncHash) {
- const newHash = `#workspace=${encodeURIComponent(selectedWorkspace.workspaceId)}`;
- if (window.location.hash !== newHash) {
- window.history.replaceState(null, "", newHash);
- }
- }
-
// Update window title with workspace title (or name for legacy workspaces)
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
const workspaceTitle = metadata?.title ?? metadata?.name ?? selectedWorkspace.workspaceId;
@@ -162,29 +147,26 @@ function AppInner() {
document.title = title;
void api?.window.setTitle({ title });
} else {
- // Clear hash when no workspace selected
- if (shouldSyncHash && window.location.hash) {
- window.history.replaceState(null, "", window.location.pathname);
- }
// Set document.title locally for browser mode, call backend for Electron
document.title = "mux";
void api?.window.setTitle({ title: "mux" });
}
}, [selectedWorkspace, workspaceMetadata, api]);
+
// Validate selected workspace exists and has all required fields
+ // Note: workspace validity is now primarily handled by RouterContext deriving
+ // selectedWorkspace from URL + metadata. This effect handles edge cases like
+ // stale localStorage or missing fields in legacy workspaces.
useEffect(() => {
if (selectedWorkspace) {
const metadata = workspaceMetadata.get(selectedWorkspace.workspaceId);
if (!metadata) {
- // Workspace was deleted
+ // Workspace was deleted - navigate home (clears selection)
console.warn(
`Workspace ${selectedWorkspace.workspaceId} no longer exists, clearing selection`
);
setSelectedWorkspace(null);
- if (!isStorybookIframe() && window.location.hash) {
- window.history.replaceState(null, "", window.location.pathname);
- }
} else if (!selectedWorkspace.namedWorkspacePath && metadata.namedWorkspacePath) {
// Old localStorage entry missing namedWorkspacePath - update it once
console.log(`Updating workspace ${selectedWorkspace.workspaceId} with missing fields`);
diff --git a/src/browser/components/AppLoader.tsx b/src/browser/components/AppLoader.tsx
index 89e20306b2..6acdce395d 100644
--- a/src/browser/components/AppLoader.tsx
+++ b/src/browser/components/AppLoader.tsx
@@ -6,6 +6,7 @@ import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
import { ProjectProvider, useProjectContext } from "../contexts/ProjectContext";
import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API";
import { WorkspaceProvider, useWorkspaceContext } from "../contexts/WorkspaceContext";
+import { RouterProvider } from "../contexts/RouterContext";
interface AppLoaderProps {
/** Optional pre-created ORPC api?. If provided, skips internal connection setup. */
@@ -18,7 +19,8 @@ interface AppLoaderProps {
* 2. Sync stores with loaded data
* 3. Only render App when everything is ready
*
- * WorkspaceContext handles workspace selection restoration (localStorage, URL hash, launch project).
+ * WorkspaceContext handles workspace selection restoration from URL.
+ * RouterProvider must wrap WorkspaceProvider since workspace state is derived from URL.
* WorkspaceProvider must be nested inside ProjectProvider so it can call useProjectContext().
* This ensures App.tsx can assume stores are always synced and removes
* the need for conditional guards in effects.
@@ -26,11 +28,13 @@ interface AppLoaderProps {
export function AppLoader(props: AppLoaderProps) {
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx
new file mode 100644
index 0000000000..bbf3a5398f
--- /dev/null
+++ b/src/browser/contexts/RouterContext.tsx
@@ -0,0 +1,78 @@
+import { createContext, useContext, type ReactNode } from "react";
+import { MemoryRouter, useLocation, useNavigate, useSearchParams } from "react-router-dom";
+import { parseInitialUrl, useRouterUrlSync } from "../hooks/useRouterUrlSync";
+
+// Navigation context value
+export interface RouterContextValue {
+ // Navigation functions
+ navigateToWorkspace: (workspaceId: string) => void;
+ navigateToProject: (projectPath: string) => void;
+ navigateToHome: () => void;
+ // Current route state (derived from URL)
+ currentWorkspaceId: string | null;
+ currentProjectPath: string | null;
+}
+
+const RouterContext = createContext(null);
+
+export function useRouter(): RouterContextValue {
+ const ctx = useContext(RouterContext);
+ if (!ctx) {
+ throw new Error("useRouter must be used within RouterProvider");
+ }
+ return ctx;
+}
+
+/**
+ * Inner provider that has access to router hooks.
+ * Parses URL to derive current workspace/project state.
+ */
+function RouterContextInner(props: { children: ReactNode }) {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+
+ // Sync router state to browser URL
+ useRouterUrlSync();
+
+ // Parse workspace ID from pathname: /workspace/:workspaceId
+ let currentWorkspaceId: string | null = null;
+ const workspaceMatch = /^\/workspace\/(.+)$/.exec(location.pathname);
+ if (workspaceMatch) {
+ currentWorkspaceId = decodeURIComponent(workspaceMatch[1]);
+ }
+
+ // Parse project path from query: /project?path=...
+ const currentProjectPath = location.pathname === "/project" ? searchParams.get("path") : null;
+
+ const value: RouterContextValue = {
+ navigateToWorkspace: (workspaceId: string) => {
+ void navigate(`/workspace/${encodeURIComponent(workspaceId)}`, { replace: true });
+ },
+ navigateToProject: (projectPath: string) => {
+ void navigate(`/project?path=${encodeURIComponent(projectPath)}`, { replace: true });
+ },
+ navigateToHome: () => {
+ void navigate("/", { replace: true });
+ },
+ currentWorkspaceId,
+ currentProjectPath,
+ };
+
+ return {props.children};
+}
+
+/**
+ * Main router provider that wraps app with MemoryRouter and context.
+ * Handles backward compatibility with legacy hash URLs.
+ */
+export function RouterProvider(props: { children: ReactNode }) {
+ // Parse initial URL for MemoryRouter (handles legacy hash URLs)
+ const initialEntry = parseInitialUrl();
+
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx
index d41c5aa77d..e6ebc9035c 100644
--- a/src/browser/contexts/WorkspaceContext.test.tsx
+++ b/src/browser/contexts/WorkspaceContext.test.tsx
@@ -5,6 +5,7 @@ import { GlobalWindow } from "happy-dom";
import type { WorkspaceContext } from "./WorkspaceContext";
import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext";
import { ProjectProvider } from "@/browser/contexts/ProjectContext";
+import { RouterProvider } from "@/browser/contexts/RouterContext";
import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import {
SELECTED_WORKSPACE_KEY,
@@ -409,6 +410,18 @@ describe("WorkspaceContext", () => {
test("beginWorkspaceCreation clears selection and tracks pending state", async () => {
createMockAPI({
+ workspace: {
+ list: () =>
+ Promise.resolve([
+ createWorkspaceMetadata({
+ id: "ws-existing",
+ projectPath: "/existing",
+ projectName: "existing",
+ name: "main",
+ namedWorkspacePath: "/existing-main",
+ }),
+ ]),
+ },
localStorage: {
selectedWorkspace: JSON.stringify({
workspaceId: "ws-existing",
@@ -464,6 +477,18 @@ describe("WorkspaceContext", () => {
test("selectedWorkspace restores from localStorage on mount", async () => {
createMockAPI({
+ workspace: {
+ list: () =>
+ Promise.resolve([
+ createWorkspaceMetadata({
+ id: "ws-restore",
+ projectPath: "/restore",
+ projectName: "restore",
+ name: "main",
+ namedWorkspacePath: "/restore-main",
+ }),
+ ]),
+ },
localStorage: {
selectedWorkspace: JSON.stringify({
workspaceId: "ws-restore",
@@ -479,18 +504,13 @@ describe("WorkspaceContext", () => {
await waitFor(() => expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-restore"));
});
- test("launch project takes precedence over localStorage selection", async () => {
+ test("launch project auto-selects workspace when no URL hash", async () => {
+ // With the new router, URL takes precedence. When there's no URL hash,
+ // and localStorage has no saved workspace, the launch project kicks in.
createMockAPI({
workspace: {
list: () =>
Promise.resolve([
- createWorkspaceMetadata({
- id: "ws-existing",
- projectPath: "/existing",
- projectName: "existing",
- name: "main",
- namedWorkspacePath: "/existing-main",
- }),
createWorkspaceMetadata({
id: "ws-launch",
projectPath: "/launch-project",
@@ -503,18 +523,10 @@ describe("WorkspaceContext", () => {
projects: {
list: () => Promise.resolve([]),
},
- localStorage: {
- selectedWorkspace: JSON.stringify({
- workspaceId: "ws-existing",
- projectPath: "/existing",
- projectName: "existing",
- namedWorkspacePath: "/existing-main",
- }),
- },
server: {
getLaunchProject: () => Promise.resolve("/launch-project"),
},
- locationHash: "#/launch-project", // Simulate launch project via URL hash
+ // No locationHash, no localStorage - so launch project should kick in
});
const ctx = await setup();
@@ -685,13 +697,15 @@ async function setup() {
return null;
}
- // WorkspaceProvider needs ProjectProvider to call useProjectContext
+ // WorkspaceProvider needs RouterProvider and ProjectProvider
render(
-
-
-
-
-
+
+
+
+
+
+
+
);
// Inject client immediately to handle race conditions where effects run before store update
diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx
index f32fd45fe5..12d27ef312 100644
--- a/src/browser/contexts/WorkspaceContext.tsx
+++ b/src/browser/contexts/WorkspaceContext.tsx
@@ -20,16 +20,13 @@ import {
SELECTED_WORKSPACE_KEY,
} from "@/common/constants/storage";
import { useAPI } from "@/browser/contexts/API";
-import {
- readPersistedState,
- updatePersistedState,
- usePersistedState,
-} from "@/browser/hooks/usePersistedState";
+import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState";
import { useProjectContext } from "@/browser/contexts/ProjectContext";
import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { isExperimentEnabled } from "@/browser/hooks/useExperiments";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import { isWorkspaceArchived } from "@/common/utils/archive";
+import { useRouter } from "@/browser/contexts/RouterContext";
/**
* Seed per-workspace localStorage from backend workspace metadata.
@@ -139,6 +136,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
const { api } = useAPI();
// Get project refresh function from ProjectContext
const { refreshProjects } = useProjectContext();
+ // Get router navigation functions and current route state
+ const {
+ navigateToWorkspace,
+ navigateToProject,
+ navigateToHome,
+ currentWorkspaceId,
+ currentProjectPath,
+ } = useRouter();
const workspaceStore = useWorkspaceStoreRaw();
const [workspaceMetadata, setWorkspaceMetadataState] = useState<
@@ -160,12 +165,33 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
[workspaceStore]
);
const [loading, setLoading] = useState(true);
- const [pendingNewWorkspaceProject, setPendingNewWorkspaceProject] = useState(null);
- // Manage selected workspace internally with localStorage persistence
- const [selectedWorkspace, setSelectedWorkspace] = usePersistedState(
- SELECTED_WORKSPACE_KEY,
- null
+ // pendingNewWorkspaceProject is derived from currentProjectPath in URL
+ const pendingNewWorkspaceProject = currentProjectPath;
+
+ // selectedWorkspace is derived from currentWorkspaceId in URL + workspaceMetadata
+ const selectedWorkspace = useMemo(() => {
+ if (!currentWorkspaceId) return null;
+ const metadata = workspaceMetadata.get(currentWorkspaceId);
+ if (!metadata) return null;
+ return toWorkspaceSelection(metadata);
+ }, [currentWorkspaceId, workspaceMetadata]);
+
+ // setSelectedWorkspace navigates to the workspace URL (or clears if null)
+ const setSelectedWorkspace = useCallback(
+ (update: SetStateAction) => {
+ // Handle functional updates by resolving against current value
+ const newValue = typeof update === "function" ? update(selectedWorkspace) : update;
+ if (newValue) {
+ navigateToWorkspace(newValue.workspaceId);
+ // Persist to localStorage for next session
+ updatePersistedState(SELECTED_WORKSPACE_KEY, newValue);
+ } else {
+ navigateToHome();
+ updatePersistedState(SELECTED_WORKSPACE_KEY, null);
+ }
+ },
+ [selectedWorkspace, navigateToWorkspace, navigateToHome]
);
// Used by async subscription handlers to safely access the most recent metadata map
@@ -217,40 +243,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
})();
}, [loadWorkspaceMetadata, refreshProjects]);
- // Restore workspace from URL hash (overrides localStorage)
- // Runs once after metadata is loaded
- useEffect(() => {
- if (loading) return;
-
- const hash = window.location.hash;
- if (hash.startsWith("#workspace=")) {
- const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));
-
- // Find workspace in metadata
- const metadata = workspaceMetadata.get(workspaceId);
-
- if (metadata) {
- // Restore from hash (overrides localStorage)
- setSelectedWorkspace(toWorkspaceSelection(metadata));
- }
- } else if (hash.length > 1) {
- // Try to interpret hash as project path (for direct deep linking)
- // e.g. #/Users/me/project or #/launch-project
- const projectPath = decodeURIComponent(hash.substring(1));
-
- // Find first workspace with this project path
- const projectWorkspaces = Array.from(workspaceMetadata.values()).filter(
- (meta) => meta.projectPath === projectPath
- );
-
- if (projectWorkspaces.length > 0) {
- const metadata = projectWorkspaces[0];
- setSelectedWorkspace(toWorkspaceSelection(metadata));
- }
- }
- // Only run once when loading finishes
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [loading]);
+ // URL restoration is now handled by RouterContext which parses the URL on load
+ // and provides currentWorkspaceId/currentProjectPath that we derive state from.
// Check for launch project from server (for --add-project flag)
// This only applies in server mode, runs after metadata loads
@@ -594,15 +588,14 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) {
const beginWorkspaceCreation = useCallback(
(projectPath: string) => {
- setPendingNewWorkspaceProject(projectPath);
- setSelectedWorkspace(null);
+ navigateToProject(projectPath);
},
- [setSelectedWorkspace]
+ [navigateToProject]
);
const clearPendingWorkspaceCreation = useCallback(() => {
- setPendingNewWorkspaceProject(null);
- }, []);
+ navigateToHome();
+ }, [navigateToHome]);
const value = useMemo(
() => ({
diff --git a/src/browser/hooks/useRouterUrlSync.ts b/src/browser/hooks/useRouterUrlSync.ts
new file mode 100644
index 0000000000..f23be9062c
--- /dev/null
+++ b/src/browser/hooks/useRouterUrlSync.ts
@@ -0,0 +1,81 @@
+import { useEffect } from "react";
+import { useLocation } from "react-router-dom";
+import { readPersistedState } from "./usePersistedState";
+import { SELECTED_WORKSPACE_KEY } from "@/common/constants/storage";
+import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar";
+
+/**
+ * Parses the current browser URL into a router-compatible location.
+ * Handles backward compatibility with legacy hash URLs and localStorage.
+ */
+export function parseInitialUrl(): string {
+ const { pathname, search, hash } = window.location;
+
+ // Legacy hash URL: #workspace=abc123 → /workspace/abc123
+ if (hash.startsWith("#workspace=")) {
+ const workspaceId = decodeURIComponent(hash.substring("#workspace=".length));
+ return `/workspace/${encodeURIComponent(workspaceId)}`;
+ }
+
+ // Legacy hash URL: #/path/to/project → /project?path=/path/to/project
+ if (hash.length > 1 && !hash.startsWith("#/workspace") && !hash.startsWith("#/project")) {
+ const projectPath = decodeURIComponent(hash.substring(1));
+ return `/project?path=${encodeURIComponent(projectPath)}`;
+ }
+
+ // If URL is at root (dev server), about:blank (tests), file:// path (packaged Electron),
+ // or iframe.html (Storybook), check localStorage for saved workspace from previous session
+ const isRootOrFileUrl =
+ pathname === "/" ||
+ pathname === "" ||
+ pathname === "blank" ||
+ pathname.endsWith("index.html") ||
+ pathname.endsWith("iframe.html");
+ // For iframe.html (Storybook), ignore search params when checking for root URL
+ const effectiveSearch = pathname.endsWith("iframe.html") ? "" : search;
+ if (isRootOrFileUrl && !effectiveSearch) {
+ const savedWorkspace = readPersistedState(
+ SELECTED_WORKSPACE_KEY,
+ null
+ );
+ if (savedWorkspace?.workspaceId) {
+ return `/workspace/${encodeURIComponent(savedWorkspace.workspaceId)}`;
+ }
+ }
+
+ // Standard URL: use pathname + search
+ return pathname + search;
+}
+
+/**
+ * Syncs React Router's MemoryRouter state with the browser URL.
+ * Uses replaceState to update URL without adding to history.
+ *
+ * In browser/server mode, this enables proper URLs that survive refresh.
+ * In Electron (file:// protocol), we skip URL sync since it would break page reload.
+ */
+export function useRouterUrlSync(): void {
+ const location = useLocation();
+
+ useEffect(() => {
+ // Skip sync in Storybook iframe to avoid test runner issues
+ if (typeof window !== "undefined" && window.location.pathname.endsWith("iframe.html")) {
+ return;
+ }
+
+ // Skip sync in Electron (file:// protocol) - updating URL would break page reload
+ // since Electron would try to load /workspace/abc as a file instead of index.html
+ if (window.location.protocol === "file:") {
+ return;
+ }
+
+ // Build the URL from router location
+ const url = location.pathname + location.search;
+
+ // Avoid unnecessary URL updates
+ const currentUrl = window.location.pathname + window.location.search;
+ if (url !== currentUrl) {
+ window.history.replaceState(null, "", url);
+ }
+ }, [location.pathname, location.search]);
+}