diff --git a/src/browser/contexts/RouterContext.test.tsx b/src/browser/contexts/RouterContext.test.tsx
index dc6aab5f68..3ec65ea232 100644
--- a/src/browser/contexts/RouterContext.test.tsx
+++ b/src/browser/contexts/RouterContext.test.tsx
@@ -25,7 +25,12 @@ function createMatchMedia(isStandalone = false): typeof window.matchMedia {
}) satisfies MediaQueryList) as typeof window.matchMedia;
}
-function installWindow(url: string, options?: { isStandalone?: boolean }) {
+type NavigationType = "navigate" | "reload" | "back_forward" | "prerender";
+
+function installWindow(
+ url: string,
+ options?: { isStandalone?: boolean; navigationType?: NavigationType }
+) {
// Happy DOM can default to an opaque origin ("null") which breaks URL-based
// logic in RouterContext. Give it a stable origin.
const happyWindow = new GlobalWindow({ url });
@@ -34,6 +39,15 @@ function installWindow(url: string, options?: { isStandalone?: boolean }) {
globalThis.window.matchMedia = createMatchMedia(options?.isStandalone);
globalThis.window.localStorage.clear();
globalThis.window.sessionStorage.clear();
+
+ const navigationEntries = [
+ { type: options?.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming,
+ ];
+ Object.defineProperty(globalThis.window.performance, "getEntriesByType", {
+ configurable: true,
+ value: (entryType: string) =>
+ entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [],
+ });
}
function PathnameObserver() {
@@ -138,6 +152,21 @@ describe("browser startup launch behavior", () => {
});
});
+ test("same-tab browser reload preserves a /workspace/:id URL in dashboard mode", async () => {
+ installWindow("https://mux.example.com/workspace/reload-me", { navigationType: "reload" });
+ window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("dashboard"));
+
+ const view = render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(view.getByTestId("pathname").textContent).toBe("/workspace/reload-me");
+ });
+ });
+
test("last-workspace mode preserves a /workspace/:id URL", async () => {
installWindow("https://mux.example.com/workspace/stale-123");
window.localStorage.setItem(LAUNCH_BEHAVIOR_KEY, JSON.stringify("last-workspace"));
@@ -384,7 +413,6 @@ describe("standalone PWA startup", () => {
await waitFor(() => {
expect(view.getByTestId("pathname").textContent).toBe("/");
- expect(window.sessionStorage.getItem("muxStandaloneSessionInitialized")).toBe("1");
});
});
@@ -426,8 +454,10 @@ describe("standalone PWA startup", () => {
});
test("still restores the current route on reloads inside the same standalone window", async () => {
- installWindow("https://mux.example.com/workspace/reload-me", { isStandalone: true });
- window.sessionStorage.setItem("muxStandaloneSessionInitialized", "1");
+ installWindow("https://mux.example.com/workspace/reload-me", {
+ isStandalone: true,
+ navigationType: "reload",
+ });
const view = render(
diff --git a/src/browser/contexts/RouterContext.tsx b/src/browser/contexts/RouterContext.tsx
index 444964f075..2c002301b0 100644
--- a/src/browser/contexts/RouterContext.tsx
+++ b/src/browser/contexts/RouterContext.tsx
@@ -62,7 +62,7 @@ export function useRouter(): RouterContext {
return ctx;
}
-const STANDALONE_PWA_SESSION_KEY = "muxStandaloneSessionInitialized";
+type StartupNavigationType = "navigate" | "reload" | "back_forward" | "prerender" | null;
function isStandalonePwa(): boolean {
return (
@@ -71,20 +71,52 @@ function isStandalonePwa(): boolean {
);
}
-function hasStandalonePwaSessionInitialized(): boolean {
- try {
- return window.sessionStorage.getItem(STANDALONE_PWA_SESSION_KEY) === "1";
- } catch {
- return false;
+function getStartupNavigationType(): StartupNavigationType {
+ const entries = window.performance?.getEntriesByType?.("navigation");
+ const firstEntry = entries?.[0];
+ const entryType =
+ firstEntry && typeof firstEntry === "object" && "type" in firstEntry ? firstEntry.type : null;
+
+ if (
+ entryType === "navigate" ||
+ entryType === "reload" ||
+ entryType === "back_forward" ||
+ entryType === "prerender"
+ ) {
+ return entryType;
+ }
+
+ const legacyType = window.performance?.navigation?.type;
+ if (legacyType === 1) {
+ return "reload";
}
+ if (legacyType === 2) {
+ return "back_forward";
+ }
+ if (legacyType === 0) {
+ return "navigate";
+ }
+
+ return null;
}
-function markStandalonePwaSessionInitialized(): void {
- try {
- window.sessionStorage.setItem(STANDALONE_PWA_SESSION_KEY, "1");
- } catch {
- // If sessionStorage is unavailable, fall back to treating each load as a fresh launch.
+function isRouteRestoringNavigationType(type: StartupNavigationType): boolean {
+ return type === "reload" || type === "back_forward";
+}
+
+function shouldRestoreWorkspaceUrlOnStartup(options: {
+ isStandalone: boolean;
+ launchBehavior: LaunchBehavior | null;
+ navigationType: StartupNavigationType;
+}): boolean {
+ if (options.isStandalone) {
+ return isRouteRestoringNavigationType(options.navigationType);
}
+
+ return (
+ options.launchBehavior === "last-workspace" ||
+ isRouteRestoringNavigationType(options.navigationType)
+ );
}
function hasValidEncodedPathSegment(encodedValue: string): boolean {
@@ -147,12 +179,10 @@ function isRestorableRoute(route: unknown): route is string {
function getInitialRoute(): string {
const isStorybook = window.location.pathname.endsWith("iframe.html");
const isStandalone = isStandalonePwa();
- const hasStandaloneSession = hasStandalonePwaSessionInitialized();
+ const navigationType = getStartupNavigationType();
const launchBehavior = !isStandalone
? readPersistedState(LAUNCH_BEHAVIOR_KEY, "dashboard")
: null;
- const shouldIgnoreStandaloneWorkspaceUrl =
- isStandalone && !hasStandaloneSession && window.location.pathname.startsWith("/workspace/");
if (window.location.protocol === "file:") {
const persistedRoute = readPersistedState(LAST_VISITED_ROUTE_KEY, null);
@@ -161,11 +191,10 @@ function getInitialRoute(): string {
}
}
- // In browser mode (not Storybook), read route directly from URL (enables refresh restoration).
- // Standalone PWAs intentionally ignore stale workspace URLs on cold launch so opening the app
- // lands on the default root entrypoint, while preserving explicit deep links like /settings
- // or /project.
- if (window.location.protocol !== "file:" && !isStorybook && !shouldIgnoreStandaloneWorkspaceUrl) {
+ // In browser mode (not Storybook), read route directly from the current URL. Workspace
+ // routes are special: fresh launches may ignore them, but explicit restore-style navigations
+ // such as hard reload/back-forward should reopen the same chat.
+ if (window.location.protocol !== "file:" && !isStorybook) {
const url = window.location.pathname + window.location.search;
// Only use URL if it's a valid route (starts with /, not just "/" or empty)
if (url.startsWith("/") && url !== "/") {
@@ -173,9 +202,13 @@ function getInitialRoute(): string {
return url;
}
- // Respect dashboard/new-chat launch preferences in browser mode so stale workspace URLs
- // do not silently override the user's chosen startup destination on a fresh launch.
- if (isStandalone || launchBehavior === "last-workspace") {
+ if (
+ shouldRestoreWorkspaceUrlOnStartup({
+ isStandalone,
+ launchBehavior,
+ navigationType,
+ })
+ ) {
return url;
}
}
@@ -427,13 +460,6 @@ function RouterContextInner(props: { children: ReactNode }) {
// causing a flash of stale UI between normal-priority updates (e.g.
// setIsSending(false)) and the deferred route change.
export function RouterProvider(props: { children: ReactNode }) {
- useEffect(() => {
- if (!isStandalonePwa()) return;
- // Mark the standalone session after commit so StrictMode's throwaway renders cannot
- // flip a cold launch into the reload path before the first real paint.
- markStandalonePwaSessionInitialized();
- }, []);
-
return (
{props.children}
diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx
index 01fb904a6d..a2719341c6 100644
--- a/src/browser/contexts/WorkspaceContext.test.tsx
+++ b/src/browser/contexts/WorkspaceContext.test.tsx
@@ -48,6 +48,8 @@ const createWorkspaceMetadata = (
...overrides,
});
+type NavigationType = "navigate" | "reload" | "back_forward" | "prerender";
+
describe("WorkspaceContext", () => {
afterEach(() => {
cleanup();
@@ -968,6 +970,36 @@ describe("WorkspaceContext", () => {
expect(ctx().selectedWorkspace).toBeNull();
});
+ test("browser reload restores the open workspace instead of reopening project creation", async () => {
+ createMockAPI({
+ workspace: {
+ list: () =>
+ Promise.resolve([
+ createWorkspaceMetadata({
+ id: "ws-open-chat",
+ projectPath: "/existing",
+ projectName: "existing",
+ name: "main",
+ namedWorkspacePath: "/existing-main",
+ }),
+ ]),
+ },
+ localStorage: {
+ [LAUNCH_BEHAVIOR_KEY]: JSON.stringify("dashboard"),
+ },
+ locationPath: "/workspace/ws-open-chat",
+ navigationType: "reload",
+ });
+
+ const ctx = await setup();
+
+ await waitFor(() => expect(ctx().loading).toBe(false));
+ await waitFor(() => {
+ expect(ctx().selectedWorkspace?.workspaceId).toBe("ws-open-chat");
+ });
+ expect(ctx().pendingNewWorkspaceProject).toBeNull();
+ });
+
test("resolves system project route IDs for pending workspace creation", async () => {
const systemProjectPath = "/system/internal-project";
const systemProjectId = getProjectRouteId(systemProjectPath);
@@ -1566,6 +1598,7 @@ interface MockAPIOptions {
locationHash?: string;
locationPath?: string;
desktopMode?: boolean;
+ navigationType?: NavigationType;
pendingDeepLinks?: Array<{ type: string; [key: string]: unknown }>;
}
@@ -1596,6 +1629,15 @@ function createMockAPI(options: MockAPIOptions = {}) {
happyWindow.location.hash = options.locationHash;
}
+ const navigationEntries = [
+ { type: options.navigationType ?? "navigate" } as unknown as PerformanceNavigationTiming,
+ ];
+ Object.defineProperty(happyWindow.performance, "getEntriesByType", {
+ configurable: true,
+ value: (entryType: string) =>
+ entryType === "navigation" ? (navigationEntries as unknown as PerformanceEntryList) : [],
+ });
+
// Set up deep link API on the window object for pending deep-link tests
(happyWindow as unknown as { api?: Record }).api = {
...(happyWindow as unknown as { api?: Record }).api,